Repositories / agent-snapshot.git

agent-snapshot.git

Clone (read-only): git clone http://git.guha-anderson.com/git/agent-snapshot.git

Branch

Optional snapshot output, bootstrap ignore.json, test cleanup

- Make --output optional; when omitted, write under a second-resolution
  timestamped directory next to ignore.json.
- If ignore.json is missing, create agent-snapshot config dir and write
  the documented default ignore list plus /usr and /bin.
- Document behavior in README; replace missing-config abort test with
  default-file assertion.
- Remove Opam stublibs LD_LIBRARY_PATH helper from tests (build uses env.sh).

Co-authored-by: Cursor <cursoragent@cursor.com>
Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-05-03 13:40:10 -0400
Commit
bf50dbf01282eb31d72c5b4bc100b8353040b9fb
README.md
index f61fb49..6ad67a4 100644
--- a/README.md
+++ b/README.md
@@ -179,12 +179,10 @@ The project depends on OCaml, Dune, Yojson, Camomile, and the `ocaml-git`
 package. Install the latter with opam (for a local checkout next to this repo,
 `opam install ../../homebox/ocaml-git` from the repository root).
 
-Create the required ignore configuration before running snapshots:
-
-```bash
-mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/agent-snapshot"
-printf '[]\n' > "${XDG_CONFIG_HOME:-$HOME/.config}/agent-snapshot/ignore.json"
-```
+On first use, if `ignore.json` is missing, Agent Snapshot creates the config
+directory and writes that file using the default path list in the
+[Configuration](#configuration) section. You can edit the file before or after
+the first run.
 
 Run a command under Agent Snapshot:
 
@@ -210,8 +208,10 @@ not rewritten by restore.
 
 ## Configuration
 
-Snapshot runs require an ignore configuration file. If it is missing or not a
-JSON array of strings, Agent Snapshot aborts before launching the traced command.
+Snapshot runs read an ignore configuration file. If it is missing, Agent
+Snapshot creates it with the default JSON array shown below and continues. If
+the file exists but is not a JSON array of strings, Agent Snapshot aborts before
+launching the traced command.
 
 The config path is:
 
@@ -231,7 +231,9 @@ The file is a JSON list of file or directory paths:
 [
   "$HOME/.cache",
   "$XDG_CONFIG_HOME/agent-snapshot/ignore.json",
-  "/tmp/scratch-output"
+  "/tmp/scratch-output",
+  "/usr",
+  "/bin"
 ]
 ```
 
src/ocaml/agent_snapshot.ml
index 5639d6b..89a3115 100644
--- a/src/ocaml/agent_snapshot.ml
+++ b/src/ocaml/agent_snapshot.ml
@@ -190,11 +190,28 @@ let expand_ignore_entry (entry : string) : string =
   else if String.starts_with ~prefix:(xdg ^ "/") entry then concat_path (xdg_config_home_dir ()) (String.sub entry 17 (String.length entry - 17))
   else entry
 
+(** Shipped default when [ignore.json] is missing; matches the documented example plus common system trees. *)
+let default_ignore_file_entries () : ignore_file_entries =
+  [
+    "$HOME/.cache";
+    "$XDG_CONFIG_HOME/agent-snapshot/ignore.json";
+    "/tmp/scratch-output";
+    "/usr";
+    "/bin";
+  ]
+
 let load_ignore_config () : unit =
   ignore_config_path := best_effort_canonical (xdg_ignore_config_path ());
   let json =
     try Json.from_file !ignore_config_path
-    with Sys_error _ -> failwith ("ignore config does not exist: " ^ !ignore_config_path)
+    with Sys_error _ ->
+      mkdir_p (dirname !ignore_config_path);
+      let text = Json.pretty_to_string ~std:true (ignore_file_entries_to_yojson (default_ignore_file_entries ())) ^ "\n" in
+      let oc = open_out_bin !ignore_config_path in
+      Fun.protect
+        ~finally:(fun () -> close_out_noerr oc)
+        (fun () -> output_string oc text);
+      Json.from_file !ignore_config_path
   in
   match ignore_file_entries_of_yojson json with
   | Ok entries ->
@@ -202,6 +219,15 @@ let load_ignore_config () : unit =
         !ignore_config_path :: List.map (fun entry -> best_effort_canonical (expand_ignore_entry entry)) entries
   | Error msg -> failwith ("ignore config: " ^ msg ^ " (" ^ !ignore_config_path ^ ")")
 
+(** Directory name for a default snapshot bundle next to [ignore.json]. *)
+let timestamped_snapshot_dir_next_to_config () : string =
+  let tm = Unix.(localtime (gettimeofday ())) in
+  let name =
+    Printf.sprintf "%04d-%02d-%02dT%02d-%02d-%02d"
+      (tm.tm_year + 1900) (tm.tm_mon + 1) tm.tm_mday tm.tm_hour tm.tm_min tm.tm_sec
+  in
+  concat_path (dirname !ignore_config_path) name
+
 let mode_of_kind (kind : Unix.file_kind) : int =
   match kind with
   | Unix.S_REG -> 0o100000
@@ -705,8 +731,13 @@ let restore_snapshot (dir : string) : unit =
       | _ -> ())
     files
 
-let run_snapshot (output : string) (command : string list) : int =
+let run_snapshot (output : string option) (command : string list) : int =
   load_ignore_config ();
+  let output =
+    match output with
+    | Some dir -> dir
+    | None -> timestamped_snapshot_dir_next_to_config ()
+  in
   snapshot_dir := output;
   reset_blob_writer ();
   remove_all output;
@@ -722,10 +753,12 @@ open Cmdliner
 
 let output_arg =
   Arg.(
-    required
+    value
     & opt (some string) None
     & info [ "o"; "output" ] ~docv:"SNAPDIR"
-        ~doc:"Write the snapshot under this directory.")
+        ~doc:
+          "Write the snapshot under this directory. When omitted, a new timestamped directory is created next to the \
+           ignore configuration file (the same directory as ignore.json).")
 
 let command_arg =
   Arg.(
@@ -744,7 +777,7 @@ let snapshot_term : unit Term.t =
   Term.(
     const (fun output cmd ->
       if cmd = [] then
-        failwith "usage: agent-snapshot --output SNAPDIR -- command args...";
+        failwith "usage: agent-snapshot [--output SNAPDIR] -- command args...";
       ignore (run_snapshot output cmd : int))
     $ output_arg
     $ command_arg)
@@ -761,6 +794,7 @@ let cmd_main : unit Cmd.t =
       `S Manpage.s_examples;
       `P "$(tool) $(b,--output) /tmp/snap $(b,--) make all";
       `P "$(tool) $(b,--output) /tmp/snap make all";
+      `P "$(tool) $(b,--) make all";
       `P "$(tool) $(b,restore) /tmp/snap";
     ]
   in
tests/test_agent_snapshot.py
index 82cceec..e8941f1 100644
--- a/tests/test_agent_snapshot.py
+++ b/tests/test_agent_snapshot.py
@@ -20,24 +20,6 @@ WORKTREE = TESTDATA / "runtime_repo"
 PYTHON = "/usr/bin/python3"
 
 
-def _prepend_opam_stublibs_to_ld_path() -> None:
-    """Arrow links libparquet dynamically; dune-built binaries need Opam stublibs on LD_LIBRARY_PATH."""
-    try:
-        prefix = subprocess.run(
-            ["opam", "var", "prefix"],
-            cwd=ROOT,
-            capture_output=True,
-            text=True,
-            check=True,
-        ).stdout.strip()
-    except (FileNotFoundError, subprocess.CalledProcessError):
-        return
-    stublibs = str(Path(prefix) / "lib" / "stublibs")
-    prev = os.environ.get("LD_LIBRARY_PATH", "")
-    if stublibs not in prev.split(":"):
-        os.environ["LD_LIBRARY_PATH"] = f"{stublibs}:{prev}" if prev else stublibs
-
-
 def run(cmd, **kwargs):
     return subprocess.run(cmd, cwd=ROOT, text=True, check=True, **kwargs)
 
@@ -47,7 +29,6 @@ def build_agent_snapshot():
     # The tests exercise the real CLI binary instead of calling internal helper
     # functions. That keeps the acceptance criteria aligned with ptrace behavior,
     # process launch, Dune wiring, and manifest writing as users will run them.
-    _prepend_opam_stublibs_to_ld_path()
     run(["bash", "-lc", ". /scratch/arjun/ocaml/env.sh && dune build src/ocaml/agent_snapshot.exe"])
     assert BIN.exists()
 
@@ -137,17 +118,21 @@ def capture(tmp_path: Path, *command: str) -> Snapshot:
     return Snapshot(out)
 
 
-def test_missing_ignore_config_aborts_at_startup(tmp_path, ignore_config):
+def test_missing_ignore_config_creates_defaults(tmp_path, ignore_config):
     ignore_config.unlink()
-    result = subprocess.run(
-        [str(BIN), "--output", str(tmp_path / "snapshot"), "--", PYTHON, "test_programs/read_clean.py"],
-        cwd=ROOT,
-        text=True,
-        capture_output=True,
-    )
-
-    assert result.returncode != 0
-    assert "ignore" in result.stderr
+    assert not ignore_config.exists()
+    out = tmp_path / "snapshot"
+    run([str(BIN), "--output", str(out), "--", PYTHON, "test_programs/read_clean.py"])
+
+    assert ignore_config.exists()
+    data = json.loads(ignore_config.read_text())
+    assert data == [
+        "$HOME/.cache",
+        "$XDG_CONFIG_HOME/agent-snapshot/ignore.json",
+        "/tmp/scratch-output",
+        "/usr",
+        "/bin",
+    ]
 
 
 def test_ignore_config_suppresses_files_directories_and_itself(tmp_path, ignore_config):