Files
vault-cli/vault
T
Hermes Agent 48681b00c5 vault-cli: Librarian tooling for the R/J/F/I vault
Adds packages/vault-cli (the `vault` CLI: search/show/tree, clobber-safe
create/move/rename across both /vault and /data + manifest regen, sync-data,
report, analyze) and wires it onto $PATH via modules/hosts/core/vault.nix for
server + vault-client hosts. Scripts self-locate siblings via realpath so they
run from the Nix store. Includes the deterministic $0 report generator, the
topology-manifest generator, and the shared Librarian prompt.
2026-07-22 11:11:03 -04:00

141 lines
4.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""
vault — CLI for Ryan's R/J/F/I Obsidian vault.
Clobber-safe. Dry-run by default; pass --execute to act.
Operates on BOTH trees (/vault map + /data territory) and the topology manifest.
Subcommands:
vault tree [SYS] print topology (optionally one system)
vault search <query> find IDs/notes by code, title, or content
vault show <ID> show an ID's note + its /data home + blob count
vault create <ID> "<title>" create ID note (folder+folder-note) in /vault AND /data
vault move <oldID> <newID> renumber: rename note+folder, rewrite backlinks, move /data dir
vault rename <ID> "<title>" retitle in place, rewrite backlinks
vault sync-data reconcile /data skeleton to match manifest (create missing dirs)
vault manifest regenerate the topology manifest note
vault report [--pretty] deterministic Librarian report (delegates to vault-librarian.py)
vault analyze spawn a Hermes Librarian pass (ad-hoc; uses the cron prompt)
"""
import os, re, sys, subprocess
# Resolve sibling modules whether running from /var/lib/hermes, the repo, or the Nix store.
_SELF_DIR = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, _SELF_DIR)
import vault_lib as vl
HERMES = "/run/current-system/sw/bin/hermes"
LIBRARIAN = os.path.join(_SELF_DIR, "vault-librarian.py")
PROMPT = os.path.join(_SELF_DIR, "vault-librarian-prompt.md")
WIKILINK_RE = re.compile(r'(!?\[\[)([^\]#|]+)((?:#[^\]|]+)?(?:\|[^\]]+)?)(\]\])')
def die(msg, code=1):
print("ERROR: " + msg, file=sys.stderr)
sys.exit(code)
def executing(argv):
return "--execute" in argv
def sys_root(sys_letter):
"""Return abs /vault path of a system root, e.g. 'R' -> /vault/R Ryan."""
s = vl.walk_structure(vl.VAULT, include_meta=True)["systems"]
if sys_letter not in s:
die(f"unknown system '{sys_letter}' (have {sorted(s)})")
return os.path.join(vl.VAULT, s[sys_letter])
def find_id_folder(root, id_code):
"""Return abs folder path for an ID code under root, or None."""
for dp, dns, fns in os.walk(root):
dns[:] = [d for d in dns if d not in vl.SKIP_DIRS]
if vl.id_code(os.path.basename(dp)) == id_code:
return dp
return None
# ---------- read-only commands ----------
def cmd_tree(argv):
s = vl.walk_structure(vl.VAULT, include_meta=True)
want = argv[0] if argv else None
for code in sorted(s["cats"]):
if want and not code.startswith(want):
continue
n = sum(1 for i in s["ids"] if i.startswith(code + "."))
print(f" {code:8} {os.path.basename(s['cats'][code])} ({n} IDs)")
def cmd_search(argv):
if not argv:
die("usage: vault search <query>")
q = " ".join(argv).lower()
hits = []
for p in vl.all_notes(vl.VAULT):
rel = os.path.relpath(p, vl.VAULT)
stem = os.path.splitext(os.path.basename(p))[0]
if q in stem.lower() or q in rel.lower():
hits.append((rel, "name"))
continue
try:
if q in open(p, encoding="utf-8").read().lower():
hits.append((rel, "content"))
except Exception:
pass
for rel, how in hits[:50]:
print(f" [{how}] {rel}")
print(f"\n{len(hits)} match(es).")
def cmd_show(argv):
if not argv:
die("usage: vault show <ID>")
code = argv[0]
root = sys_root(code[0])
folder = find_id_folder(root, code)
if not folder:
die(f"ID {code} not found in /vault")
print("VAULT folder: " + os.path.relpath(folder, vl.VAULT))
note = os.path.join(folder, os.path.basename(folder) + ".md")
print("Folder-note: " + ("present" if os.path.exists(note) else "MISSING"))
# /data home
drel = os.path.relpath(folder, vl.VAULT)
dpath = os.path.join(vl.DATA, drel)
if os.path.isdir(dpath):
nblobs = sum(len(fs) for _, _, fs in os.walk(dpath))
print(f"/data home: {drel} ({nblobs} blobs)")
else:
print(f"/data home: MISSING ({drel})")
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(0)
cmd, argv = sys.argv[1], sys.argv[2:]
dispatch = {
"tree": cmd_tree,
"search": cmd_search,
"show": cmd_show,
}
# write-commands live in vault_ops (imported lazily to keep this file readable)
if cmd in dispatch:
dispatch[cmd](argv)
elif cmd in ("create", "move", "rename", "sync-data", "manifest"):
import vault_ops
vault_ops.run(cmd, argv, executing(sys.argv))
elif cmd == "report":
sys.exit(subprocess.run([sys.executable, LIBRARIAN] + argv).returncode)
elif cmd == "analyze":
prompt = open(PROMPT, encoding="utf-8").read()
subprocess.run([HERMES, "-z", prompt])
else:
die(f"unknown command '{cmd}'")
if __name__ == "__main__":
main()