feat(agents-vault): add status, restore, adopt, remote, and push

--status reports link health, orphaned entries, and how far the vault is
ahead of its remote, which is how an unpushed backup gets noticed. --adopt
rebinds a machine-specific local-* entry by hand. --push is explicit;
autopush stays opt-in via __fish_agent_vault_autopush.

Three corrections to the planned shape:

--status is dispatched ahead of the scaffold instead of behind it. As
planned it sat after the tool install, the agy knowledge copy, and the
global memory link, so asking for a report would first sync global state
and claim ~/.claude/memory. It is now read-only and reports a missing
vault rather than creating one. The global-state block moved below the
mode dispatch so it runs only on a default or --link run; the mutating
modes still need the vault repo, so they sit between the scaffold and it.

--adopt validates its slug before using it. It is interpolated into
"$vault/projects/$slug" and handed to `git mv`, so --adopt=../../../etc
walked straight out of the vault. Only the charset the slug formula emits
is accepted, with no slash and no leading dot.

--remote captures the git exit status explicitly rather than chaining an
`or` off the block terminator. That construct does work in fish, but it
reads as the silent-false-success shape that a hook-rejected commit once
produced here, and it stops working the moment the `else` goes away.

Also pins the dangling-global-symlink case the suite never covered: for a
broken ~/.claude/memory link both -d and -e are false, so the -L disjunct
in the global-memory guard is the only thing that notices it. That is the
state a buggy earlier run left on a real machine; the test asserts it is
detected, repinned into the vault, and exits 0.
This commit is contained in:
2026-09-03 18:56:46 -04:00
parent 4c7334c46e
commit 45f0fb9688
3 changed files with 534 additions and 9 deletions
+250 -9
View File
@@ -55,6 +55,29 @@
# If both the old and new entries already hold content the migration is
# ambiguous and is refused; resolve it with --adopt=SLUG.
#
# Run with no flags, the command scaffolds the vault, syncs global state,
# links the current project, and commits. The other modes are exclusive
# and each returns as soon as it is done:
#
# --status is a report and mutates nothing at all. It is answered before
# the vault is even scaffolded, so asking what the vault looks like never
# creates it, never copies agy state into it, and never claims
# ~/.claude/memory. A missing vault is reported rather than built.
#
# --restore walks every vault entry and relinks the live memory directory
# of each one whose recorded origin path still exists, naming the rest so
# they can be rebound by hand. It is a convenience: the ordinary per-
# project run restores a cloned vault's memory on its own.
#
# --adopt=SLUG rebinds the current project's entry to SLUG, which is how
# a machine-specific local-* key or an ambiguous migration is resolved.
# SLUG must match [a-z0-9._-]+ with no slash and no leading dot -- the
# charset the slug formula itself emits -- since it is interpolated into
# a vault path and handed to git mv.
#
# --remote=URL points the vault at a remote; --push commits and then
# pushes there.
#
# ARGUMENTS
# --link Scaffold the vault and link this project's memory; skip
# the final commit
@@ -70,13 +93,16 @@
#
# EXIT STATUS
# 0 Completed successfully
# 1 Fatal error (vault unavailable, git failure, ambiguous migration)
# 1 Fatal error (vault unavailable, git failure, ambiguous migration,
# invalid --adopt slug, or --push with no remote configured)
#
# EXAMPLE
# agents-vault
# agents-vault --status
# agents-vault --remote=https://git.rootiest.dev/rootiest/agent-vault.git
# agents-vault --push
# agents-vault --adopt=git.rootiest.dev-rootiest-fish-config
# agents-vault --restore
#
# NOTES
# Set __fish_agent_vault_dir to relocate the vault. Set
@@ -84,6 +110,12 @@
# it defaults to off so a backgrounded push can never hang or prompt
# invisibly underneath a starting agent.
#
# An entry's origin file records the project path once, when the entry is
# created, and is never refreshed. A project that later moves on disk
# therefore keeps a stale path there and --restore degrades to reporting
# it as unplaceable rather than relinking the wrong directory. Rebind
# such an entry from the project itself with --adopt=SLUG.
#
# The agy knowledge copy is merge-only. Files are copied into the vault
# but are never removed from it, so a fact deleted upstream from agy's
# knowledge store persists in the vault indefinitely, and a restore or a
@@ -112,6 +144,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped
set -l c_ok (set_color green)
set -l c_warn (set_color yellow)
set -l c_err (set_color red)
set -l c_dim (set_color brblack)
set -l c_reset (set_color normal)
argparse h/help link push restore status 'adopt=' 'remote=' \
@@ -155,6 +188,69 @@ function agents-vault --description 'track curated agent memory in a host-scoped
set -l changed 0
set -l did_init 0
# ─────────────────────────── --status ──────────────────────────────
# A report, and nothing but a report. This is dispatched here -- ahead
# of the scaffold, the tool install, and the global-state sync -- on
# purpose: asking what the vault looks like must never be the thing
# that creates it, copies the agy knowledge store into it, or claims
# ~/.claude/memory. A status command that mutates cannot be trusted to
# diagnose the thing it just changed.
if set -q _flag_status
echo "$c_head""Vault:$c_reset $vault"
if not test -d "$vault"
echo " $c_warn""no vault yet — run agents-vault inside a project to create one$c_reset"
return 0
end
set -l url (git -C "$vault" remote get-url origin 2>/dev/null)
if test -z "$url"
echo "$c_head""Remote:$c_reset $c_warn""no remote configured — nothing is backed up off this machine$c_reset"
else
echo "$c_head""Remote:$c_reset $url"
if git -C "$vault" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
set -l ahead (git -C "$vault" rev-list --count '@{u}..HEAD' 2>/dev/null)
if test -n "$ahead"; and test "$ahead" != 0
echo " $c_warn$ahead commit(s) not yet pushed$c_reset"
end
else
echo " $c_warn""no upstream branch — never pushed$c_reset"
end
end
if test -d "$vault/.git/rebase-merge"; or test -d "$vault/.git/rebase-apply"
echo " $c_err""unresolved rebase in progress — resolve it before syncing$c_reset"
end
set -l claude_root $__fish_agent_vault_claude_root
test -n "$claude_root"; or set claude_root "$HOME/.claude/projects"
echo ""
echo "$c_head""Entries:$c_reset"
set -l seen 0
for entry in "$vault"/projects/*
test -d "$entry"; or continue
set seen 1
set -l eslug (path basename "$entry")
set -l count (command ls -A "$entry/claude/memory" 2>/dev/null | count)
set -l want (path resolve "$entry/claude/memory")
set -l linked 0
for cand in "$claude_root"/*/memory
test -L "$cand"; or continue
if test (path resolve "$cand") = "$want"
set linked 1
break
end
end
if test $linked -eq 1
echo " $c_ok""linked$c_reset $eslug $c_dim($count file(s))$c_reset"
else
echo " $c_warn""orphan$c_reset $eslug $c_dim($count file(s)) — no live project links here$c_reset"
end
end
test $seen -eq 0; and echo " $c_dim(none)$c_reset"
return 0
end
# ────────────────────── ensure the vault repo ──────────────────────
if not test -d "$vault"
if not mkdir -p "$vault"
@@ -229,6 +325,132 @@ function agents-vault --description 'track curated agent memory in a host-scoped
set changed 1
end
# ─────────────────────────── --remote ──────────────────────────────
# Mutating modes are dispatched here: after the vault repo exists (they
# all need one) but before the global-state sync below, which belongs
# to a default backup run and has no business running as a side effect
# of rebinding an entry or setting a URL.
if set -q _flag_remote
if test -z "$_flag_remote"
echo "$c_err""agents-vault: --remote needs a URL$c_reset" >&2
return 1
end
# The result is captured explicitly rather than chained off the
# block terminator with `or`. `end` does carry the taken branch's
# status in fish, but only when a branch was taken at all: the same
# construct one `else` away silently reports success, which is
# exactly how a hook-rejected commit once passed for a good one.
set -l rc 0
if git -C "$vault" remote get-url origin >/dev/null 2>&1
git -C "$vault" remote set-url origin "$_flag_remote"
set rc $status
else
git -C "$vault" remote add origin "$_flag_remote"
set rc $status
end
if test $rc -ne 0
echo "$c_err""agents-vault: could not set the vault remote to $_flag_remote$c_reset" >&2
return 1
end
test $verbose -eq 1; and echo "$c_ok→ Vault remote set to $_flag_remote$c_reset"
return 0
end
# ─────────────────────────── --adopt ───────────────────────────────
if set -q _flag_adopt
# The requested slug is interpolated into a vault path and handed
# to `git mv`, so it is validated before it is used anywhere:
# --adopt=../../../etc would otherwise walk straight out of the
# vault. Only the charset the slug formula itself emits is
# accepted, and a leading dot is refused as well, which also rules
# out the bare "." and ".." entries.
if not string match -qr '^[a-z0-9_-][a-z0-9._-]*$' -- "$_flag_adopt"
echo "$c_err""agents-vault: invalid slug '$_flag_adopt'$c_reset" >&2
echo "$c_err"" A slug is [a-z0-9._-]+ with no slash and no leading dot.$c_reset" >&2
return 1
end
set -l root (git rev-parse --show-toplevel 2>/dev/null)
if test -z "$root"
echo "$c_err""agents-vault: --adopt must run inside a project$c_reset" >&2
return 1
end
set -l cur (_agents_repo_slug "$root")
set -l from "$vault/projects/$cur"
set -l to "$vault/projects/$_flag_adopt"
if test "$cur" = "$_flag_adopt"
test $verbose -eq 1; and echo "$c_ok→ Already bound to $_flag_adopt$c_reset"
return 0
end
if not test -d "$from"
echo "$c_err""agents-vault: no vault entry for this project ($cur)$c_reset" >&2
return 1
end
set -l to_content
test -d "$to/claude/memory"; and set to_content (command ls -A "$to/claude/memory" 2>/dev/null)
if test (count $to_content) -gt 0
echo "$c_err""agents-vault: $_flag_adopt already holds content; refusing to overwrite$c_reset" >&2
return 1
end
test -d "$to"; and rm -rf "$to"
if not git -C "$vault" mv "projects/$cur" "projects/$_flag_adopt" 2>/dev/null
command mv "$from" "$to"; or return 1
end
printf 'adopted: %s → %s (%s)\n' "$cur" "$_flag_adopt" (date -I) >>"$to/origin"
set -l claude_root $__fish_agent_vault_claude_root
test -n "$claude_root"; or set claude_root "$HOME/.claude/projects"
set -l mangled (string replace -a '/' '-' -- "$root" | string replace -a '.' '-')
# The live path still points at the old entry, which no longer
# exists; drop it so ensure_symlink is not asked to resolve a
# broken link before repinning it.
test -L "$claude_root/$mangled/memory"; and rm -f "$claude_root/$mangled/memory"
if not _agents_repo_ensure_symlink "$claude_root/$mangled/memory" "$to/claude/memory" >/dev/null
echo "$c_err""agents-vault: adopted $_flag_adopt but could not relink $claude_root/$mangled/memory$c_reset" >&2
return 1
end
_agents_repo_sync "$vault" "chore: adopt $cur as $_flag_adopt" >/dev/null
test $verbose -eq 1; and echo "$c_ok→ Adopted $cur as $_flag_adopt$c_reset"
return 0
end
# ────────────────────────── --restore ──────────────────────────────
# The batch counterpart of the emergent per-project restore. Each entry
# records the path it was created at; where that path still exists the
# live memory directory is relinked, and where it does not the entry is
# named so it can be rebound with --adopt. The origin file is written
# once and never refreshed, so a project that has since moved on disk
# simply degrades to "cannot place" rather than relinking the wrong
# directory.
if set -q _flag_restore
set -l claude_root $__fish_agent_vault_claude_root
test -n "$claude_root"; or set claude_root "$HOME/.claude/projects"
for entry in "$vault"/projects/*
test -d "$entry/claude/memory"; or continue
set -l eslug (path basename "$entry")
set -l opath ""
if test -f "$entry/origin"
set -l line (command grep -m1 '^path:' "$entry/origin" 2>/dev/null)
test -n "$line"; and set opath (string replace -r '^path:\s+' '' -- "$line")
end
if test -n "$opath"; and test -d "$opath"
set -l m (string replace -a '/' '-' -- "$opath" | string replace -a '.' '-')
set -l msg (_agents_repo_ensure_symlink "$claude_root/$m/memory" "$entry/claude/memory")
if test $status -ne 0
echo "$c_err""agents-vault: could not relink $eslug$c_reset" >&2
else if test -n "$msg"
test $verbose -eq 1; and echo "$c_ok→ Restored $eslug$c_reset"
end
else
test $verbose -eq 1
and echo "$c_warn→ Cannot place $eslug: no live project found; use --adopt from the project$c_reset"
end
end
return 0
end
# ────────────────────────── global state ───────────────────────────
# Allowlist, never a denylist. The agy root and ~/.claude also hold
# .credentials.json, history.jsonl, sessions/, session-env/,
@@ -327,14 +549,6 @@ function agents-vault --description 'track curated agent memory in a host-scoped
end
end
# ─────────────────────── unimplemented modes ───────────────────────
for f in _flag_push _flag_restore _flag_status _flag_adopt _flag_remote
if set -q $f
echo "$c_err""agents-vault: that mode is not implemented yet$c_reset" >&2
return 1
end
end
# ──────────────────── link the current project ─────────────────────
set -l root (git rev-parse --show-toplevel 2>/dev/null)
if test -n "$root"
@@ -441,6 +655,33 @@ function agents-vault --description 'track curated agent memory in a host-scoped
end
end
# ────────────────────────────── push ───────────────────────────────
# Pushing is explicit. Autopush exists but is opt-in, because this runs
# on every agent launch and a network operation there can hang or
# prompt for credentials invisibly underneath a starting agent.
set -l do_push 0
set -q _flag_push; and set do_push 1
if set -q __fish_agent_vault_autopush; and test "$__fish_agent_vault_autopush" = 1
set do_push 1
end
if test $do_push -eq 1
if git -C "$vault" remote get-url origin >/dev/null 2>&1
if git -C "$vault" push -q origin HEAD
test $verbose -eq 1; and echo "$c_ok→ Pushed the vault to origin$c_reset"
else
# Not fatal: the commit above already happened, so the
# memory is safe locally and the next push will carry it.
echo "$c_warn""agents-vault: push failed; the vault is committed locally$c_reset" >&2
end
else if set -q _flag_push
# An explicit --push that pushed nowhere must not read as a
# successful backup. Autopush stays quiet: it is a background
# convenience on a vault that may deliberately have no remote.
echo "$c_err""agents-vault: no remote configured; set one with --remote=URL$c_reset" >&2
return 1
end
end
if test $quiet -eq 1; and test $changed -eq 1
if test $did_init -eq 1
echo "$c_ok→ Initialized agent memory vault$c_reset"