From f0b611ee47d670b829e4c435e673b96335dd5b5e Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 09:42:44 -0400 Subject: [PATCH 1/5] docs: add multi-repo grep demo (git mounts as agent session sandboxes) A runnable, deterministic example of the just_bash + vfs + exgit stack as an agent sandbox: four public GitHub repos cloned in pure Elixir (no git binary, in-memory), mounted programmatically one per loop iteration, searched with a single grep whose scope grows with the mount table, then shrunk again with umount. Closes with Exgit.FS.grep streaming matches host-side off the unmounted clone. Also adds examples/**/*.exs to the formatter inputs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H2mmV55riKhWgzBS8G2oMT --- .formatter.exs | 2 +- examples/multi_repo_grep.exs | 102 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 examples/multi_repo_grep.exs diff --git a/.formatter.exs b/.formatter.exs index d2cda26..0ae52e4 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,4 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "examples/**/*.exs"] ] diff --git a/examples/multi_repo_grep.exs b/examples/multi_repo_grep.exs new file mode 100644 index 0000000..a1ce4da --- /dev/null +++ b/examples/multi_repo_grep.exs @@ -0,0 +1,102 @@ +# Multi-repo grep on the just_bash + vfs + exgit stack. +# +# Code-mode in its simplest form: host code drives a sandboxed bash +# with command strings. The sandbox's filesystem is a %VFS{} mount +# table — just_bash's in-memory backend at "/", plus real GitHub +# repositories (cloned in pure Elixir, no git binary) mounted as exgit +# workspaces under /repos. Mounting is programmatic and incremental: +# each loop iteration below mounts one more repo, and the same grep +# sees a bigger world. Unmounting shrinks it back — and the repo +# handle still works host-side, where exgit's own streaming grep +# searches the clone without any mount at all. +# +# The sandbox is a value: JustBash.exec/2 returns {result, bash} and +# you thread the returned struct forward. Mount, exec, umount — all +# pure transformations of that value. +# +# Run it: +# +# elixir examples/multi_repo_grep.exs +# +Mix.install([ + {:just_bash, github: "elixir-ai-tools/just_bash"}, + {:exgit, "~> 0.1.0"} +]) + +defmodule Shell do + @doc "Run one command, print a transcript, return the threaded sandbox." + def run(bash, command) do + IO.puts("$ #{command}") + {result, bash} = JustBash.exec(bash, command) + + IO.write(indent(result.stdout)) + if result.stderr != "", do: IO.write(indent(result.stderr)) + if result.exit_code != 0, do: IO.puts(" [exit code: #{result.exit_code}]") + + bash + end + + defp indent(""), do: "" + + defp indent(out), + do: out |> String.trim_trailing() |> String.replace(~r/^/m, " ") |> Kernel.<>("\n") +end + +# Ordered so the match list grows one repo at a time. +repos = [ + {"vfs", "https://github.com/ivarvong/vfs"}, + {"exgit", "https://github.com/ivarvong/exgit"}, + {"just_bash", "https://github.com/elixir-ai-tools/just_bash"}, + {"pyex", "https://github.com/ivarvong/pyex"} +] + +# The question we grep for: every implementation of the VFS.Mountable +# protocol across the mounted repos. The /repos/*/lib glob expands +# across mount roots like any other directory (and skips each repo's +# tests and docs). +grep = "grep -rln 'defimpl VFS.Mountable, for:' /repos/*/lib" + +# Mount one repo per iteration and re-run the same grep — the search +# space grows as the mount table does. (exgit finds its own impl: the +# library doing the mounting is itself mountable.) +# +# Exgit.clone/1 is a full clone: everything is fetched up front, so +# every later read is local — right for grep-the-whole-tree workloads. +# For reading a handful of files from a large repo, clone with +# `filter: {:blob, :none}` instead: blobs then fetch lazily on first +# read, and the mount table threads that cache forward. +{handles, bash} = + Enum.map_reduce(repos, JustBash.new(), fn {name, url}, bash -> + IO.puts("\n== mount #{name} (#{url})") + {:ok, repo} = Exgit.clone(url) + + bash = + bash + |> JustBash.mount("/repos/#{name}", Exgit.Workspace.open(repo)) + |> Shell.run(grep) + + {{name, repo}, bash} + end) + +# Unmounting is just as programmatic: drop pyex from the table and the +# same grep no longer sees it. +IO.puts("\n== umount pyex") + +bash +|> JustBash.umount("/repos/pyex") +|> Shell.run(grep) + +# The mount is gone, but the clone isn't: the repo handle still works +# host-side. Exgit.FS.grep streams matches lazily straight off the +# object store — path-glob filtered, line-numbered, and consumer- +# driven: taking 2 halts the tree walk right there, and the walk never +# grows the cache. +IO.puts("\n== host-side: Exgit.FS.grep over the unmounted pyex clone (first 2 matches)") +{_name, pyex} = List.keyfind!(handles, "pyex", 0) + +pyex +|> Exgit.FS.grep("HEAD", "defimpl VFS.Mountable, for:", path: "lib/**") +|> Enum.take(2) +|> Enum.each(fn match -> + IO.puts(" #{match.path}:#{match.line_number}: #{String.trim(match.line)}") +end) From 3befc3a44e3f5d736137d97168d66f6941842c64 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 11:11:49 -0400 Subject: [PATCH 2/5] docs: fail the demo loudly on nonzero exit; note eager-clone grep boundary A nonzero exit from any command now raises, so the deterministic transcript genuinely doubles as a smoke test. The lazy-clone comment notes that Exgit.FS.grep requires an eager clone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H2mmV55riKhWgzBS8G2oMT --- examples/multi_repo_grep.exs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/multi_repo_grep.exs b/examples/multi_repo_grep.exs index a1ce4da..efab4be 100644 --- a/examples/multi_repo_grep.exs +++ b/examples/multi_repo_grep.exs @@ -24,14 +24,22 @@ Mix.install([ ]) defmodule Shell do - @doc "Run one command, print a transcript, return the threaded sandbox." + @doc """ + Run one command, print a transcript, return the threaded sandbox. + + Every command in this demo is expected to succeed, so a nonzero exit + raises — the transcript doubles as a smoke test of the whole stack. + """ def run(bash, command) do IO.puts("$ #{command}") {result, bash} = JustBash.exec(bash, command) IO.write(indent(result.stdout)) if result.stderr != "", do: IO.write(indent(result.stderr)) - if result.exit_code != 0, do: IO.puts(" [exit code: #{result.exit_code}]") + + if result.exit_code != 0 do + raise "command failed (exit #{result.exit_code}): #{command}" + end bash end @@ -64,7 +72,9 @@ grep = "grep -rln 'defimpl VFS.Mountable, for:' /repos/*/lib" # every later read is local — right for grep-the-whole-tree workloads. # For reading a handful of files from a large repo, clone with # `filter: {:blob, :none}` instead: blobs then fetch lazily on first -# read, and the mount table threads that cache forward. +# read, and the mount table threads that cache forward. (Note the +# trade: Exgit.FS.grep below requires an eager clone — lazy repos +# grep through the mount, or after Exgit.Repository.materialize/2.) {handles, bash} = Enum.map_reduce(repos, JustBash.new(), fn {name, url}, bash -> IO.puts("\n== mount #{name} (#{url})") From 5959f8a8266b7ef0bbef61f22649833594fda16f Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 11:45:57 -0400 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20drop=20the=20demo=20helper=20module?= =?UTF-8?q?=20=E2=80=94=20show=20real=20calling=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Shell helper hid the load-bearing line of the whole stack. Every exec is now written the way a real caller writes it: {%{exit_code: 0} = result, bash} = JustBash.exec(bash, cmd) — execute, destructure, and crash-assert in one match, with the full Result visible in the MatchError on failure. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H2mmV55riKhWgzBS8G2oMT --- examples/multi_repo_grep.exs | 53 ++++++++++-------------------------- 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/examples/multi_repo_grep.exs b/examples/multi_repo_grep.exs index efab4be..b2948c7 100644 --- a/examples/multi_repo_grep.exs +++ b/examples/multi_repo_grep.exs @@ -10,9 +10,12 @@ # handle still works host-side, where exgit's own streaming grep # searches the clone without any mount at all. # +# There are no helpers here — every line is what a real caller writes. # The sandbox is a value: JustBash.exec/2 returns {result, bash} and -# you thread the returned struct forward. Mount, exec, umount — all -# pure transformations of that value. +# you thread the returned struct forward. Each exec asserts +# `%{exit_code: 0}`, so the deterministic transcript doubles as a +# smoke test of the whole stack (a failure crashes with the full +# Result in the MatchError). # # Run it: # @@ -23,33 +26,6 @@ Mix.install([ {:exgit, "~> 0.1.0"} ]) -defmodule Shell do - @doc """ - Run one command, print a transcript, return the threaded sandbox. - - Every command in this demo is expected to succeed, so a nonzero exit - raises — the transcript doubles as a smoke test of the whole stack. - """ - def run(bash, command) do - IO.puts("$ #{command}") - {result, bash} = JustBash.exec(bash, command) - - IO.write(indent(result.stdout)) - if result.stderr != "", do: IO.write(indent(result.stderr)) - - if result.exit_code != 0 do - raise "command failed (exit #{result.exit_code}): #{command}" - end - - bash - end - - defp indent(""), do: "" - - defp indent(out), - do: out |> String.trim_trailing() |> String.replace(~r/^/m, " ") |> Kernel.<>("\n") -end - # Ordered so the match list grows one repo at a time. repos = [ {"vfs", "https://github.com/ivarvong/vfs"}, @@ -77,24 +53,23 @@ grep = "grep -rln 'defimpl VFS.Mountable, for:' /repos/*/lib" # grep through the mount, or after Exgit.Repository.materialize/2.) {handles, bash} = Enum.map_reduce(repos, JustBash.new(), fn {name, url}, bash -> - IO.puts("\n== mount #{name} (#{url})") + IO.puts("\n== mount #{name} (#{url})\n$ #{grep}") {:ok, repo} = Exgit.clone(url) + bash = JustBash.mount(bash, "/repos/#{name}", Exgit.Workspace.open(repo)) - bash = - bash - |> JustBash.mount("/repos/#{name}", Exgit.Workspace.open(repo)) - |> Shell.run(grep) + {%{exit_code: 0} = result, bash} = JustBash.exec(bash, grep) + IO.write(result.stdout) {{name, repo}, bash} end) # Unmounting is just as programmatic: drop pyex from the table and the # same grep no longer sees it. -IO.puts("\n== umount pyex") +IO.puts("\n== umount pyex\n$ #{grep}") +bash = JustBash.umount(bash, "/repos/pyex") -bash -|> JustBash.umount("/repos/pyex") -|> Shell.run(grep) +{%{exit_code: 0} = result, _bash} = JustBash.exec(bash, grep) +IO.write(result.stdout) # The mount is gone, but the clone isn't: the repo handle still works # host-side. Exgit.FS.grep streams matches lazily straight off the @@ -108,5 +83,5 @@ pyex |> Exgit.FS.grep("HEAD", "defimpl VFS.Mountable, for:", path: "lib/**") |> Enum.take(2) |> Enum.each(fn match -> - IO.puts(" #{match.path}:#{match.line_number}: #{String.trim(match.line)}") + IO.puts("#{match.path}:#{match.line_number}: #{String.trim(match.line)}") end) From 134cea4260a00a09473a035240ef66f943651879 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 12:48:21 -0400 Subject: [PATCH 4/5] docs: sharpen the demo into a stateful-session showcase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename multi_repo_grep.exs -> agent_session.exs and restructure into four beats: build the world (mount-per-loop, growing grep), state threading (redirect one exec's output, read it in the next), fork and roll back (checkpoint = session, rm in the live session, checkpoint asserted intact), shrink the world (umount). Drops the host-side Exgit.FS.grep coda — backends now appear only at mount lines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H2mmV55riKhWgzBS8G2oMT --- examples/agent_session.exs | 61 +++++++++++++++++++++++++ examples/multi_repo_grep.exs | 87 ------------------------------------ 2 files changed, 61 insertions(+), 87 deletions(-) create mode 100644 examples/agent_session.exs delete mode 100644 examples/multi_repo_grep.exs diff --git a/examples/agent_session.exs b/examples/agent_session.exs new file mode 100644 index 0000000..8a14525 --- /dev/null +++ b/examples/agent_session.exs @@ -0,0 +1,61 @@ +# Stateful agent sessions on the just_bash + vfs + exgit stack. +# +# A session is a value. Its filesystem is a mount table: an in-memory +# backend at "/", real GitHub repositories (cloned in pure Elixir — +# no git binary, no disk, no container) mounted under /repos. Drive +# it with bash strings; thread the returned value forward. Forking a +# session is variable binding; rolling back is using the old binding. +# +# No helpers — every line is what a real caller writes. Each exec +# crash-asserts its exit code, so this deterministic transcript +# doubles as a smoke test of the whole stack. +# +# elixir examples/agent_session.exs +# +Mix.install([ + {:just_bash, github: "elixir-ai-tools/just_bash"}, + {:exgit, "~> 0.1.0"} +]) + +repos = [ + {"vfs", "https://github.com/ivarvong/vfs"}, + {"exgit", "https://github.com/ivarvong/exgit"}, + {"just_bash", "https://github.com/elixir-ai-tools/just_bash"}, + {"pyex", "https://github.com/ivarvong/pyex"} +] + +# Every VFS.Mountable implementation across whatever is mounted. +# /repos/*/lib globs across mount roots like ordinary directories. +grep = "grep -rln 'defimpl VFS.Mountable, for:' /repos/*/lib" + +# ── Build the world: one more mount per iteration, same grep ──────── +session = + Enum.reduce(repos, JustBash.new(), fn {name, url}, session -> + IO.puts("\n== mount #{name}\n$ #{grep}") + {:ok, repo} = Exgit.clone(url) + session = JustBash.mount(session, "/repos/#{name}", Exgit.Workspace.open(repo)) + + {%{exit_code: 0} = result, session} = JustBash.exec(session, grep) + IO.write(result.stdout) + session + end) + +# ── State threads: one exec's writes are the next exec's world ────── +{%{exit_code: 0}, session} = JustBash.exec(session, "#{grep} > /work/matches.txt") +{%{exit_code: 0} = result, session} = JustBash.exec(session, "wc -l < /work/matches.txt") +IO.puts("\n== agent wrote /work/matches.txt: #{String.trim(result.stdout)} matches") + +# ── Fork and roll back: sessions are values ───────────────────────── +checkpoint = session + +{%{exit_code: 0}, session} = JustBash.exec(session, "rm /work/matches.txt") +{%{exit_code: 1}, _} = JustBash.exec(session, "cat /work/matches.txt") +{%{exit_code: 0}, _} = JustBash.exec(checkpoint, "cat /work/matches.txt") +IO.puts("== rm'd in the live session; the checkpoint still has it") + +# ── Shrink the world ──────────────────────────────────────────────── +IO.puts("\n== umount pyex\n$ #{grep}") +session = JustBash.umount(session, "/repos/pyex") + +{%{exit_code: 0} = result, _session} = JustBash.exec(session, grep) +IO.write(result.stdout) diff --git a/examples/multi_repo_grep.exs b/examples/multi_repo_grep.exs deleted file mode 100644 index b2948c7..0000000 --- a/examples/multi_repo_grep.exs +++ /dev/null @@ -1,87 +0,0 @@ -# Multi-repo grep on the just_bash + vfs + exgit stack. -# -# Code-mode in its simplest form: host code drives a sandboxed bash -# with command strings. The sandbox's filesystem is a %VFS{} mount -# table — just_bash's in-memory backend at "/", plus real GitHub -# repositories (cloned in pure Elixir, no git binary) mounted as exgit -# workspaces under /repos. Mounting is programmatic and incremental: -# each loop iteration below mounts one more repo, and the same grep -# sees a bigger world. Unmounting shrinks it back — and the repo -# handle still works host-side, where exgit's own streaming grep -# searches the clone without any mount at all. -# -# There are no helpers here — every line is what a real caller writes. -# The sandbox is a value: JustBash.exec/2 returns {result, bash} and -# you thread the returned struct forward. Each exec asserts -# `%{exit_code: 0}`, so the deterministic transcript doubles as a -# smoke test of the whole stack (a failure crashes with the full -# Result in the MatchError). -# -# Run it: -# -# elixir examples/multi_repo_grep.exs -# -Mix.install([ - {:just_bash, github: "elixir-ai-tools/just_bash"}, - {:exgit, "~> 0.1.0"} -]) - -# Ordered so the match list grows one repo at a time. -repos = [ - {"vfs", "https://github.com/ivarvong/vfs"}, - {"exgit", "https://github.com/ivarvong/exgit"}, - {"just_bash", "https://github.com/elixir-ai-tools/just_bash"}, - {"pyex", "https://github.com/ivarvong/pyex"} -] - -# The question we grep for: every implementation of the VFS.Mountable -# protocol across the mounted repos. The /repos/*/lib glob expands -# across mount roots like any other directory (and skips each repo's -# tests and docs). -grep = "grep -rln 'defimpl VFS.Mountable, for:' /repos/*/lib" - -# Mount one repo per iteration and re-run the same grep — the search -# space grows as the mount table does. (exgit finds its own impl: the -# library doing the mounting is itself mountable.) -# -# Exgit.clone/1 is a full clone: everything is fetched up front, so -# every later read is local — right for grep-the-whole-tree workloads. -# For reading a handful of files from a large repo, clone with -# `filter: {:blob, :none}` instead: blobs then fetch lazily on first -# read, and the mount table threads that cache forward. (Note the -# trade: Exgit.FS.grep below requires an eager clone — lazy repos -# grep through the mount, or after Exgit.Repository.materialize/2.) -{handles, bash} = - Enum.map_reduce(repos, JustBash.new(), fn {name, url}, bash -> - IO.puts("\n== mount #{name} (#{url})\n$ #{grep}") - {:ok, repo} = Exgit.clone(url) - bash = JustBash.mount(bash, "/repos/#{name}", Exgit.Workspace.open(repo)) - - {%{exit_code: 0} = result, bash} = JustBash.exec(bash, grep) - IO.write(result.stdout) - - {{name, repo}, bash} - end) - -# Unmounting is just as programmatic: drop pyex from the table and the -# same grep no longer sees it. -IO.puts("\n== umount pyex\n$ #{grep}") -bash = JustBash.umount(bash, "/repos/pyex") - -{%{exit_code: 0} = result, _bash} = JustBash.exec(bash, grep) -IO.write(result.stdout) - -# The mount is gone, but the clone isn't: the repo handle still works -# host-side. Exgit.FS.grep streams matches lazily straight off the -# object store — path-glob filtered, line-numbered, and consumer- -# driven: taking 2 halts the tree walk right there, and the walk never -# grows the cache. -IO.puts("\n== host-side: Exgit.FS.grep over the unmounted pyex clone (first 2 matches)") -{_name, pyex} = List.keyfind!(handles, "pyex", 0) - -pyex -|> Exgit.FS.grep("HEAD", "defimpl VFS.Mountable, for:", path: "lib/**") -|> Enum.take(2) -|> Enum.each(fn match -> - IO.puts("#{match.path}:#{match.line_number}: #{String.trim(match.line)}") -end) From 83e29daef0a032aeece83789982365880a20b4b2 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 14:31:32 -0400 Subject: [PATCH 5/5] docs: replace narrative section comments with factual ones Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H2mmV55riKhWgzBS8G2oMT --- examples/agent_session.exs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/agent_session.exs b/examples/agent_session.exs index 8a14525..a7b2f53 100644 --- a/examples/agent_session.exs +++ b/examples/agent_session.exs @@ -28,7 +28,7 @@ repos = [ # /repos/*/lib globs across mount roots like ordinary directories. grep = "grep -rln 'defimpl VFS.Mountable, for:' /repos/*/lib" -# ── Build the world: one more mount per iteration, same grep ──────── +# Mount one repo per iteration; the same grep sees each addition. session = Enum.reduce(repos, JustBash.new(), fn {name, url}, session -> IO.puts("\n== mount #{name}\n$ #{grep}") @@ -40,12 +40,14 @@ session = session end) -# ── State threads: one exec's writes are the next exec's world ────── +# Writes persist across execs: one command's output is the next +# command's input, carried by the returned session value. {%{exit_code: 0}, session} = JustBash.exec(session, "#{grep} > /work/matches.txt") {%{exit_code: 0} = result, session} = JustBash.exec(session, "wc -l < /work/matches.txt") IO.puts("\n== agent wrote /work/matches.txt: #{String.trim(result.stdout)} matches") -# ── Fork and roll back: sessions are values ───────────────────────── +# Checkpoint and rollback: a session is a value, so checkpointing is +# a variable binding and rollback is using the old binding. checkpoint = session {%{exit_code: 0}, session} = JustBash.exec(session, "rm /work/matches.txt") @@ -53,7 +55,7 @@ checkpoint = session {%{exit_code: 0}, _} = JustBash.exec(checkpoint, "cat /work/matches.txt") IO.puts("== rm'd in the live session; the checkpoint still has it") -# ── Shrink the world ──────────────────────────────────────────────── +# Umount: the same grep no longer sees pyex. IO.puts("\n== umount pyex\n$ #{grep}") session = JustBash.umount(session, "/repos/pyex")