"""obsidian-wiki installer CLI. Python port of ``setup.sh`` for the pip-installed package. The skill content lives inside the installed package (``obsidian_wiki/_data/skills``) instead of a cloned repo, so this wires the bundled skills into every supported AI agent's skills directory and writes ``~/.obsidian-wiki/config`` so the skills resolve the vault from any project. """ from __future__ import annotations import argparse import json import os import re import shutil import sys from pathlib import Path from typing import TypedDict from obsidian_wiki import __version__ from obsidian_wiki.access_control import audit_profile, export_profile, list_profiles from obsidian_wiki.file_assets import ( clean_config_value, db_path_for_vault, format_results_table, init_db, rebuild_from_registry, search_assets, stage_files, ) HOME = Path.home() GLOBAL_CONFIG_DIR = HOME / ".obsidian-wiki" GLOBAL_CONFIG = GLOBAL_CONFIG_DIR / "config" # Skills usable from any project (no vault context needed beyond the global # config). These are also installed globally for agents that only scope skills # per-project, so cross-project sync/query work everywhere. PORTABLE_SKILLS = ("wiki-update", "wiki-query") # ── Data resolution ────────────────────────────────────────────────────────── # Works for both a built wheel (data under /_data) and an editable/source # checkout (data at the repo root next to the package). def _pkg_dir() -> Path: return Path(__file__).resolve().parent def skills_dir() -> Path: """Return the directory holding the bundled skill folders.""" for cand in (_pkg_dir() / "_data" / "skills", _pkg_dir().parent / ".skills"): if cand.is_dir(): return cand raise FileNotFoundError( "Could not locate bundled skills. Reinstall obsidian-wiki " "(`pip install --force-reinstall obsidian-wiki`)." ) def bootstrap_dir() -> Path | None: """Return the directory containing agent bootstrap context files. For a wheel this is ``_data/bootstrap``; for a source checkout the files are spread across the repo root, so we return the repo root and resolve each file via the repo-relative layout in ``_bootstrap_files``. """ built = _pkg_dir() / "_data" / "bootstrap" if built.is_dir(): return built repo = _pkg_dir().parent if (repo / "AGENTS.md").is_file(): return repo return None def list_skills() -> list[str]: return sorted(p.name for p in skills_dir().iterdir() if p.is_dir()) # ── Skill installation ─────────────────────────────────────────────────────── def install_skills( target_dir: Path, label: str, *, subset: tuple[str, ...] | None = None, mode: str = "symlink", quiet: bool = False, ) -> int: """Install bundled skills into *target_dir*. Returns the count installed.""" src_root = skills_dir() target_dir.mkdir(parents=True, exist_ok=True) installed = 0 for skill in sorted(p for p in src_root.iterdir() if p.is_dir()): name = skill.name if subset is not None and name not in subset: continue link_path = target_dir / name if link_path.is_symlink() or link_path.is_file(): link_path.unlink() elif link_path.is_dir(): # A real directory we previously copied here is safe to replace; # anything else is the user's and we leave it alone. if (link_path / "SKILL.md").exists(): shutil.rmtree(link_path) else: print(f" ⚠️ {link_path} is not a managed skill, skipping") continue if mode == "symlink": link_path.symlink_to(skill, target_is_directory=True) else: # copy shutil.copytree(skill, link_path) if not (link_path / "SKILL.md").exists(): raise RuntimeError(f"broken skill install: {link_path} -> {skill}") installed += 1 if not quiet: print(f"✅ Installed {installed} skills → {label}") return installed # Agents whose skills directory lives under $HOME. (path-under-home, label, # subset). All get every skill — pip users have no cloned repo to host # project-scoped skills, so everything must be globally discoverable. GLOBAL_AGENT_DIRS: list[tuple[str, str, tuple[str, ...] | None]] = [ (".claude/skills", "~/.claude/skills/ (Claude Code)", None), (".gemini/skills", "~/.gemini/skills/ (Gemini CLI)", None), (".gemini/antigravity/skills", "~/.gemini/antigravity/skills/ (Antigravity, legacy)", None), (".codex/skills", "~/.codex/skills/ (Codex)", None), (".hermes/skills", "~/.hermes/skills/ (Hermes default)", None), (".openclaw/skills", "~/.openclaw/skills/ (OpenClaw)", None), (".copilot/skills", "~/.copilot/skills/ (GitHub Copilot CLI)", None), (".trae/skills", "~/.trae/skills/ (Trae)", None), (".trae-cn/skills", "~/.trae-cn/skills/ (Trae CN)", None), (".kiro/skills", "~/.kiro/skills/ (Kiro CLI)", None), (".pi/agent/skills", "~/.pi/agent/skills/ (Pi)", None), (".agents/skills", "~/.agents/skills/ (OpenCode, Aider, Droid, generic)", None), ] def install_global_skills(mode: str) -> None: for rel, label, subset in GLOBAL_AGENT_DIRS: install_skills(HOME / rel, label, subset=subset, mode=mode) _install_hermes_profiles(mode) def _install_hermes_profiles(mode: str) -> None: """Mirror setup.sh: install into the active and all named Hermes profiles.""" hermes_home = os.environ.get("HERMES_HOME") handled: set[Path] = set() if hermes_home: hp = Path(hermes_home).expanduser() if hp != HOME / ".hermes": install_skills(hp / "skills", f"{hp}/skills/ (Hermes active profile)", mode=mode) handled.add(hp) profiles = HOME / ".hermes" / "profiles" if profiles.is_dir(): for prof in sorted(p for p in profiles.iterdir() if p.is_dir()): if prof in handled: continue install_skills( prof / "skills", f"~/.hermes/profiles/{prof.name}/skills/ (Hermes profile: {prof.name})", mode=mode, ) # ── Project-local install (opt-in) ─────────────────────────────────────────── PROJECT_AGENT_DIRS = [ (".claude/skills", "Claude Code"), (".cursor/skills", "Cursor"), (".windsurf/skills", "Windsurf"), (".agents/skills", "OpenCode / generic"), (".pi/skills", "Pi"), (".kiro/skills", "Kiro"), ] # (bootstrap-relative source path, destination relative to project dir). # The source path is resolved against bootstrap_dir() for a wheel, or mapped to # the repo layout for a source checkout (see _resolve_bootstrap_src). BOOTSTRAP_FILES = [ ("AGENTS.md", "AGENTS.md"), ("cursor/rules/obsidian-wiki.mdc", ".cursor/rules/obsidian-wiki.mdc"), ("windsurf/rules/obsidian-wiki.md", ".windsurf/rules/obsidian-wiki.md"), ("kiro/steering/obsidian-wiki.md", ".kiro/steering/obsidian-wiki.md"), ("agent/rules/obsidian-wiki.md", ".agent/rules/obsidian-wiki.md"), ("agent/workflows/obsidian-wiki.md", ".agent/workflows/obsidian-wiki.md"), ("github/copilot-instructions.md", ".github/copilot-instructions.md"), ] # AGENTS.md aliases created as symlinks within the project (single source). AGENTS_ALIASES = ("CLAUDE.md", "GEMINI.md", ".hermes.md") def _resolve_bootstrap_src(boot_root: Path, rel: str) -> Path | None: """Resolve a bootstrap source path under a wheel layout or repo layout.""" built = boot_root / rel if built.exists(): return built # Source checkout: boot_root is the repo root; files use the repo layout. repo_rel = { "AGENTS.md": "AGENTS.md", "cursor/rules/obsidian-wiki.mdc": ".cursor/rules/obsidian-wiki.mdc", "windsurf/rules/obsidian-wiki.md": ".windsurf/rules/obsidian-wiki.md", "kiro/steering/obsidian-wiki.md": ".kiro/steering/obsidian-wiki.md", "agent/rules/obsidian-wiki.md": ".agent/rules/obsidian-wiki.md", "agent/workflows/obsidian-wiki.md": ".agent/workflows/obsidian-wiki.md", "github/copilot-instructions.md": ".github/copilot-instructions.md", }.get(rel) if repo_rel and (boot_root / repo_rel).exists(): return boot_root / repo_rel return None def install_project(project_dir: Path, mode: str) -> None: project_dir.mkdir(parents=True, exist_ok=True) print(f"\n📁 Installing project-local files → {project_dir}") for rel, _label in PROJECT_AGENT_DIRS: install_skills(project_dir / rel, f"{rel}/", mode=mode) boot_root = bootstrap_dir() if boot_root is None: print(" ⚠️ Bootstrap files not found in package; skipping context files") return for rel, dest in BOOTSTRAP_FILES: src = _resolve_bootstrap_src(boot_root, rel) if src is None: continue dst = project_dir / dest dst.parent.mkdir(parents=True, exist_ok=True) if dst.is_symlink() or dst.exists(): if dst.is_dir() and not dst.is_symlink(): continue dst.unlink() shutil.copyfile(src, dst) print("✅ Installed bootstrap context files (AGENTS.md, rules, workflows)") # AGENTS.md aliases as relative symlinks (copy fallback for symlink-hostile FS). for alias in AGENTS_ALIASES: link = project_dir / alias if link.is_symlink() or link.exists(): link.unlink() try: link.symlink_to("AGENTS.md") except OSError: shutil.copyfile(project_dir / "AGENTS.md", link) print(f"✅ Linked AGENTS.md aliases ({', '.join(AGENTS_ALIASES)})") # ── Config ─────────────────────────────────────────────────────────────────── def _read_config_value(key: str) -> str: if not GLOBAL_CONFIG.is_file(): return "" for line in GLOBAL_CONFIG.read_text().splitlines(): if line.startswith(f"{key}="): return clean_config_value(line.split("=", 1)[1]) return "" def resolve_vault_path(cli_vault: str | None) -> str: if cli_vault: return os.path.expanduser(clean_config_value(cli_vault)) existing = _read_config_value("OBSIDIAN_VAULT_PATH") if existing and existing != "/path/to/your/vault": return existing if sys.stdin.isatty(): try: entered = input(" Where is your Obsidian vault? (absolute path): ").strip() except EOFError: entered = "" if entered: return os.path.expanduser(entered) return existing def write_config(vault_path: str) -> None: GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True) # OBSIDIAN_WIKI_REPO points at the bundled data root so skills that reference # framework assets (templates, references) can find them post-install. repo_root = skills_dir().parent GLOBAL_CONFIG.write_text( f'OBSIDIAN_VAULT_PATH="{vault_path}"\n' f'OBSIDIAN_WIKI_REPO="{repo_root}"\n' f'OBSIDIAN_WIKI_VERSION="{__version__}"\n' ) print(f"✅ Global config written to {GLOBAL_CONFIG}") def _check_stale() -> None: """Warn if the installed version doesn't match when setup last ran, or if skills are missing.""" if not GLOBAL_CONFIG.is_file(): print( f"⚠️ obsidian-wiki {__version__} is installed but setup has never been run.\n" f" Run: obsidian-wiki setup --vault /path/to/your/vault", file=sys.stderr, ) return setup_version = _read_config_value("OBSIDIAN_WIKI_VERSION") if setup_version and setup_version != __version__: print( f"⚠️ obsidian-wiki upgraded {setup_version} → {__version__} but setup hasn't been re-run.\n" f" New skills won't be available until you run: obsidian-wiki setup", file=sys.stderr, ) return # Even if the version matches, check that ~/.claude/skills has the full set. claude_skills_dir = HOME / ".claude" / "skills" if claude_skills_dir.is_dir(): bundled = set(list_skills()) installed = {p.name for p in claude_skills_dir.iterdir() if p.is_dir()} missing = bundled - installed if missing: print( f"⚠️ {len(missing)} skill(s) missing from ~/.claude/skills/ " f"(e.g. {', '.join(sorted(missing)[:3])}{', ...' if len(missing) > 3 else ''}).\n" f" Run: obsidian-wiki setup", file=sys.stderr, ) # ── Commands ───────────────────────────────────────────────────────────────── def cmd_setup(args: argparse.Namespace) -> int: mode = "copy" if args.copy else "symlink" print("\n╔══════════════════════════════════════════════════╗") print("║ obsidian-wiki — Agent Setup ║") print("╚══════════════════════════════════════════════════╝\n") vault_path = resolve_vault_path(args.vault) write_config(vault_path) if not vault_path: print(" → Vault path not set yet. Re-run with `--vault /path/to/vault`") print(" or edit OBSIDIAN_VAULT_PATH in ~/.obsidian-wiki/config.") if not args.project_only: print() install_global_skills(mode) if args.project is not None: project_dir = Path(args.project or os.getcwd()).expanduser().resolve() install_project(project_dir, mode) n = len(list_skills()) print("\n───────────────────────────────────────────────────") print(" Setup complete!\n") print(f" Skills installed: {n} (mode: {mode})") if vault_path: print(f" Vault: {vault_path}") print("\n Next steps:") print(" 1. Open a project in your agent") print(' 2. Say: "set up my wiki"\n') print(" From any project:") print(" /wiki-update → sync knowledge into your vault") print(" /wiki-query → ask questions against your wiki") print("───────────────────────────────────────────────────\n") return 0 def cmd_list(args: argparse.Namespace) -> int: for name in list_skills(): print(name) return 0 def cmd_info(args: argparse.Namespace) -> int: bundled = list_skills() print(f"obsidian-wiki {__version__}") print(f"skills: {skills_dir()}") boot = bootstrap_dir() print(f"bootstrap: {boot if boot else '(not found)'}") print(f"config: {GLOBAL_CONFIG}{'' if GLOBAL_CONFIG.exists() else ' (not written yet)'}") if GLOBAL_CONFIG.exists(): vp = _read_config_value("OBSIDIAN_VAULT_PATH") setup_ver = _read_config_value("OBSIDIAN_WIKI_VERSION") print(f"vault: {vp or '(unset)'}") print(f"setup ran: {setup_ver or '(never)'}") print(f"bundled skills: {len(bundled)}") print() print("Agent skill install status:") bundled_set = set(bundled) for rel, label, _subset in GLOBAL_AGENT_DIRS: agent_dir = HOME / rel if not agent_dir.is_dir(): print(f" {label}: not installed") continue installed = {p.name for p in agent_dir.iterdir() if p.is_dir()} wiki_installed = installed & bundled_set missing = bundled_set - installed status = "✅" if not missing else "⚠️ " print(f" {status} {label}: {len(wiki_installed)}/{len(bundled_set)}", end="") if missing: print(f" (run: obsidian-wiki setup)", end="") print() _check_stale() return 0 def _require_vault(args: argparse.Namespace) -> str: vault_path = resolve_vault_path(getattr(args, "vault", None)) if not vault_path: raise RuntimeError("vault path is not configured; run `obsidian-wiki setup --vault /path/to/vault`") return vault_path def _resolve_file_asset_db(args: argparse.Namespace, vault_path: str) -> Path: raw = ( getattr(args, "db", None) or os.environ.get("OBSIDIAN_FILE_ASSET_DB") or _read_config_value("OBSIDIAN_FILE_ASSET_DB") ) if not raw: return db_path_for_vault(vault_path) db_path = Path(clean_config_value(raw)).expanduser() if not db_path.is_absolute(): db_path = Path(vault_path).expanduser() / db_path return db_path def cmd_file_index_init(args: argparse.Namespace) -> int: vault_path = _require_vault(args) db_path = _resolve_file_asset_db(args, vault_path) init_db(db_path) print(f"✅ File asset index ready: {db_path}") return 0 def cmd_file_index_rebuild(args: argparse.Namespace) -> int: vault_path = _require_vault(args) result = rebuild_from_registry(vault_path, db_path=_resolve_file_asset_db(args, vault_path)) print(f"✅ Rebuilt file asset index: {result.indexed} active file(s)") if result.skipped: print(f"⚠️ Skipped {result.skipped} malformed registry line(s)") print(f" DB: {result.db_path}") print(f" Registry: {result.registry_path}") return 0 def cmd_file_index_search(args: argparse.Namespace) -> int: vault_path = _require_vault(args) query = " ".join(args.query).strip() rows = search_assets( vault_path, query, db_path=_resolve_file_asset_db(args, vault_path), limit=args.limit, doc_type=args.doc_type, source_type=args.source_type, product_line=args.product_line, customer=args.customer, competitor=args.competitor, status=args.status, include_staged=not args.active_only, read_only=True, ) if args.json: print(json.dumps(rows, ensure_ascii=False, indent=2)) else: print(format_results_table(rows)) return 0 def cmd_file_index_stage(args: argparse.Namespace) -> int: vault_path = _require_vault(args) results = stage_files( vault_path, args.paths, db_path=_resolve_file_asset_db(args, vault_path), project=args.project, doc_type=args.doc_type, visibility=args.visibility, ) for result in results: print(f"✅ Staged {result.file_id[:19]}… → {result.path}") if results: print(f" DB: {results[0].db_path}") print(" Next: run wiki-ingest to classify/promote the staged source into wiki pages.") return 0 def cmd_mcp_server(args: argparse.Namespace) -> int: from obsidian_wiki.mcp_server import main as mcp_main argv: list[str] = [] if args.vault: argv.extend(["--vault", args.vault]) if args.db: argv.extend(["--db", args.db]) if args.profile: argv.extend(["--profile", args.profile]) if args.max_file_bytes is not None: argv.extend(["--max-file-bytes", str(args.max_file_bytes)]) if args.expose_paths: argv.append("--expose-paths") if args.transport: argv.extend(["--transport", args.transport]) if args.host: argv.extend(["--host", args.host]) if args.port is not None: argv.extend(["--port", str(args.port)]) if args.path: argv.extend(["--path", args.path]) if args.token_env: argv.extend(["--token-env", args.token_env]) for allowed_host in args.allowed_hosts or []: argv.extend(["--allowed-host", allowed_host]) for allowed_origin in args.allowed_origins or []: argv.extend(["--allowed-origin", allowed_origin]) return mcp_main(argv) def cmd_bot_view_list(args: argparse.Namespace) -> int: vault_path = _require_vault(args) for name in list_profiles(vault_path): print(name) return 0 def cmd_bot_view_audit(args: argparse.Namespace) -> int: vault_path = _require_vault(args) profiles = [args.profile] if args.profile else list_profiles(vault_path) rows = [audit_profile(vault_path, profile) for profile in profiles] if args.json: print(json.dumps(rows, ensure_ascii=False, indent=2)) return 0 print("profile | pages | sources | description") print("--- | ---: | ---: | ---") for row in rows: print( f"{row['profile']} | {row['pages_allowed']} allowed / {row['pages_blocked']} blocked | " f"{row['sources_allowed']} allowed / {row['sources_blocked']} blocked | {row['description']}" ) return 0 def cmd_bot_view_export(args: argparse.Namespace) -> int: vault_path = _require_vault(args) manifest = export_profile( vault_path, args.profile, args.out, include_files=args.include_files, ) print(f"✅ Exported bot view: {args.profile}") print(f" Pages: {manifest['page_count']}") print(f" Sources: {manifest['source_file_count']}") print(f" Out: {Path(args.out).expanduser() / args.profile}") return 0 # ── Argument parsing ───────────────────────────────────────────────────────── # Core maintenance commands ported from upstream v2026.10.4. class SchemaOptions(TypedDict): allowed_lifecycles: frozenset[str] allowed_relationship_types: frozenset[str] required_trust_fields: tuple[str, ...] schema_source: str def cmd_graph_analyse(args: argparse.Namespace) -> int: from obsidian_wiki import graph_analysis as ga vault = Path(args.vault).expanduser().resolve() if not vault.is_dir(): print(f"error: vault not found: {vault}", file=sys.stderr) return 1 if args.path or args.around: # Query modes: no full analysis, just the graph walk. outgoing, _ = ga.parse_vault_graph(vault) if args.path: src, tgt = args.path path = ga.shortest_path(outgoing, src, tgt, directed=args.direction == "out") result: dict = {"source": ga._slug(src), "target": ga._slug(tgt), "path": path, "hops": (len(path) - 1) if path else None} else: hits = ga.neighborhood(outgoing, args.around, depth=args.depth, direction=args.direction) result = {"seed": ga._slug(args.around), "depth": args.depth, "direction": args.direction, "pages": hits, "count": len(hits), "note": "pages and count exclude the seed itself"} else: previous = None if args.diff_against: previous = ga.load_snapshot(Path(args.diff_against).expanduser()) if previous is None: print(f"warning: no GRAPH_SNAPSHOT found in {args.diff_against}; skipping diff", file=sys.stderr) result = ga.analyse_vault(vault, top_n=args.top, previous_snapshot=previous, include_snapshot=args.snapshot) if args.pretty: print(json.dumps(result, indent=2)) else: print(json.dumps(result)) return 0 def _source_candidates(vault: Path, raw: str) -> list[Path]: """Candidate absolute paths for a source argument, in resolution order. Relative arguments resolve against the CWD first (the long-standing behavior out-of-vault sources rely on), then against the vault root — relative manifest keys are read as vault-relative by ``cache.resolve_key``, so a vault-relative argument must resolve the same way on the way in. Absolute arguments keep only themselves. """ p = Path(raw).expanduser() if p.is_absolute(): return [p.resolve()] candidates = [] for base in (Path.cwd(), vault): resolved = (base / p).resolve() if resolved not in candidates: candidates.append(resolved) return candidates def _resolve_source_arg(vault: Path, raw: str) -> Path: """First *source_candidates* form that exists on disk. When no candidate exists, the CWD-relative form is returned so read-only callers (cache-check) can classify it as missing; writers must treat a missing result as an error rather than hashing a wrong file. """ candidates = _source_candidates(vault, raw) for candidate in candidates: if candidate.exists(): return candidate return candidates[0] def _note_source_ambiguity(vault: Path, raw: str, chosen: Path) -> None: """Warn when a relative argument exists under both the CWD and the vault. The typed string is then byte-for-byte a manifest key, but the vault file it names is not the one being hashed. Staying quiet would let the vault entry keep a stale hash while a second entry appears under the CWD key. """ candidates = _source_candidates(vault, raw) if len(candidates) != 2 or not all(c.exists() for c in candidates): return cwd_path, vault_path = candidates print(f"note: {raw} matched both {cwd_path} and {vault_path}; " f"using {chosen}", file=sys.stderr) def cmd_cache_check(args: argparse.Namespace) -> int: from obsidian_wiki.cache import check_sources vault = Path(args.vault).expanduser().resolve() sources = [_resolve_source_arg(vault, p) for p in args.sources] result = check_sources(vault, sources) if args.pretty: print(json.dumps(result, indent=2)) else: print(json.dumps(result)) return 0 def cmd_projects_check(args: argparse.Namespace) -> int: from obsidian_wiki.cache import check_projects vault = Path(args.vault).expanduser().resolve() result = check_projects(vault) print(json.dumps(result, indent=2) if args.pretty else json.dumps(result)) # Exit 2 on a real staleness finding so a scheduled run can branch on it; # unavailable/unsynced are absences of evidence, not findings. return 2 if (result["behind"] or result["unreachable"]) else 0 def cmd_cache_update(args: argparse.Namespace) -> int: from obsidian_wiki.cache import stored_key, update_source from obsidian_wiki.provenance import prefer_archive_write_path vault = Path(args.vault).expanduser().resolve() source = _resolve_source_arg(vault, args.source) rel = stored_key(source, vault) or args.source preferred = None if args.key else prefer_archive_write_path(vault, rel) if not source.exists() and preferred is None: tried = " and ".join(str(c) for c in _source_candidates(vault, args.source)) print(f"error: source {args.source} does not exist (tried {tried})", file=sys.stderr) return 1 _note_source_ambiguity(vault, args.source, source) pages = args.pages # Omission must preserve an existing page association. h = update_source(vault, source, pages_produced=pages, key=args.key) stored = args.key or preferred or stored_key(source, vault) or str(source) print(json.dumps({ "path": str(source if source.exists() else vault / (preferred or rel)), "key": stored, "content_hash": h, })) return 0 def cmd_cache_hash(args: argparse.Namespace) -> int: from obsidian_wiki.cache import hash_file path = Path(args.path).expanduser().resolve() if not path.exists(): print(f"error: {path} does not exist", file=sys.stderr) return 1 print(json.dumps({"path": str(path), "sha256": hash_file(path)})) return 0 def _print_lint(report: dict[str, object]) -> None: print(f"obsidian-wiki lint: {report['status']}") stats = report["stats"] print(f"pages: {stats['pages']} links: {stats['link_count']}") for name, count in stats["findings"].items(): print(f"{name}: {count}") def _schema_csv(config: dict[str, str], key: str) -> list[str]: if key not in config: return [] values = [item.strip() for item in config[key].split(",")] if any(not item for item in values): raise ValueError(f"invalid {key} value: entries must not be empty") return values def _schema_cli_values(values: list[str] | None, flag: str) -> list[str]: normalised = [item.strip() for item in values or []] if any(not item for item in normalised): raise ValueError(f"invalid {flag} value: must not be empty") return normalised def _schema_source_value( args: argparse.Namespace, config: dict[str, str], ) -> str | None: configured_value: str | None = None if "OBSIDIAN_SCHEMA_SOURCE" in config: configured_value = config["OBSIDIAN_SCHEMA_SOURCE"].strip() if not configured_value: raise ValueError("invalid OBSIDIAN_SCHEMA_SOURCE value: must not be empty") cli_value = getattr(args, "schema_source", None) if cli_value is not None: value = cli_value.strip() if not value: raise ValueError("invalid --schema-source value: must not be empty") return value return configured_value def _read_config_file(path: Path) -> dict[str, str]: if not path.is_file(): return {} values: dict[str, str] = {} for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) values[key.strip()] = value.strip().strip("'\"") return values def _report_empty_local_vault(env_file: Path | str) -> None: # An empty OBSIDIAN_VAULT_PATH= in a project .env deliberately blocks the # global vault, but a copied .env.example does the same by accident, so # name the file that stopped the walk. print( f"error: vault not configured; {env_file} sets OBSIDIAN_VAULT_PATH to empty, " "which blocks the global config. Set it, delete the line, or pass a path", file=sys.stderr, ) def _resolve_schema_command_context( vault_arg: str | None, ) -> tuple[Path, dict[str, str], str] | None: config: dict[str, str] config_source: str if vault_arg and vault_arg.startswith("@"): name = vault_arg[1:] if not name or re.fullmatch(r"[A-Za-z0-9_-]+", name) is None: print("error: named vault must use @ followed by letters, digits, _ or -", file=sys.stderr) return None path = GLOBAL_CONFIG_DIR / f"config.{name}" config = _read_config_file(path) config_source = str(path) resolved = config.get("OBSIDIAN_VAULT_PATH", "") elif vault_arg is not None: config = {} config_source = "explicit-vault" resolved = vault_arg else: current = Path.cwd().resolve() config = {} config_source = str(GLOBAL_CONFIG) while True: candidate = current / ".env" local = _read_config_file(candidate) if "OBSIDIAN_VAULT_PATH" in local: config = local config_source = str(candidate) break if current == HOME or current.parent == current: break current = current.parent if not config: config = _read_config_file(GLOBAL_CONFIG) resolved = config.get("OBSIDIAN_VAULT_PATH", "") if not resolved: if config_source.endswith(".env"): _report_empty_local_vault(config_source) else: print("error: vault not configured; pass a path, @name, or run obsidian-wiki setup", file=sys.stderr) return None vault = Path(resolved).expanduser().resolve() if not vault.is_dir(): print(f"error: vault not found: {vault}", file=sys.stderr) return None return vault, config, config_source def _schema_options( args: argparse.Namespace, config: dict[str, str], config_source: str, *, default_required_trust_fields: tuple[str, ...] | None = None, ) -> SchemaOptions: from obsidian_wiki.lint import ( ALLOWED_RELATIONSHIP_TYPES, TRUST_REQUIRED_FRONTMATTER, ) from obsidian_wiki.trust import ( ALLOWED_LIFECYCLES, TRUST_REQUIRED_FIELD_ALLOWLIST, ) cli_lifecycles = _schema_cli_values( getattr(args, "allow_lifecycle", None), "--allow-lifecycle" ) cli_relationships = _schema_cli_values( getattr(args, "allow_relationship_type", None), "--allow-relationship-type" ) raw_cli_required = getattr(args, "required_trust_field", None) cli_required = ( _schema_cli_values(raw_cli_required, "--required-trust-field") if raw_cli_required is not None else None ) configured_lifecycles = _schema_csv(config, "OBSIDIAN_ALLOWED_LIFECYCLES") configured_relationships = _schema_csv(config, "OBSIDIAN_ALLOWED_RELATIONSHIP_TYPES") configured_required = _schema_csv(config, "OBSIDIAN_REQUIRED_TRUST_FIELDS") unknown_required = sorted( set(configured_required).union(cli_required or ()) - TRUST_REQUIRED_FIELD_ALLOWLIST ) if unknown_required: allowed = ", ".join(sorted(TRUST_REQUIRED_FIELD_ALLOWLIST)) unknown = ", ".join(unknown_required) raise ValueError( "invalid OBSIDIAN_REQUIRED_TRUST_FIELDS value(s): " f"{unknown}; allowed values: {allowed}" ) required = tuple( cli_required if cli_required is not None else configured_required or list(default_required_trust_fields or TRUST_REQUIRED_FRONTMATTER) ) cli_overrides = bool( cli_lifecycles or cli_relationships or cli_required is not None ) configured_overrides = bool( configured_lifecycles or configured_relationships or configured_required ) source = _schema_source_value(args, config) if not source: if cli_overrides and configured_overrides: source = f"cli+config:{config_source}" elif cli_overrides: source = f"cli:{config_source}" elif configured_overrides: source = f"config:{config_source}" else: source = "framework-defaults" return { "allowed_lifecycles": ALLOWED_LIFECYCLES.union(configured_lifecycles, cli_lifecycles), "allowed_relationship_types": ALLOWED_RELATIONSHIP_TYPES.union( configured_relationships, cli_relationships ), "required_trust_fields": required, "schema_source": source, } def cmd_staging(args: argparse.Namespace) -> int: """List, promote, or discard staged pages. Mirrors `/wiki-stage-commit`, which now calls this instead of restating the steps. Revision pins are opt-in: pass them for a reviewed decision, omit them for an unattended `--all`. """ from obsidian_wiki.staging import ( StagingConflict, StagingError, discard, list_staged, log_decisions, promote, ) context = _resolve_schema_command_context(args.vault) if context is None: return 1 vault = context[0] if args.staging_action == "list": entries = [entry.as_dict() for entry in list_staged(vault)] if args.json: print(json.dumps(entries, indent=2) if args.pretty else json.dumps(entries)) elif not entries: print("Nothing staged.") else: for entry in entries: print(f"{entry['kind']:>6} {entry['live_path']} ({entry['staged_path']})") print(f"\n{len(entries)} staged file(s)") return 0 if not args.path: print(f"error: `staging {args.staging_action}` needs a path", file=sys.stderr) return 1 try: if args.staging_action == "promote": result = promote( vault, args.path, expected_staged_revision=args.expect_staged, expected_live_revision=args.expect_live, pin_live_absent=args.expect_new, ) else: result = discard(vault, args.path) except StagingConflict as exc: # Distinct exit code: a conflict is "re-read and retry", not "bad input". print(f"conflict: {exc}", file=sys.stderr) return 9 except StagingError as exc: print(f"error: {exc}", file=sys.stderr) return 1 log_decisions(vault, [result]) if args.json: print(json.dumps(result, indent=2) if args.pretty else json.dumps(result)) elif result["action"] == "promote": print(f"promoted {result['staged_path']} -> {result['live_path']} ({result['kind']})") else: print(f"discarded {result['staged_path']} -> {result['raw_path']}") return 0 def cmd_lint(args: argparse.Namespace) -> int: from obsidian_wiki.lint import lint_vault context = _resolve_schema_command_context(args.vault) if context is None: return 1 vault, config, config_source = context strict_trust = args.strict_trust or config.get("OBSIDIAN_TRUST_STRICT", "").strip().lower() in ( "1", "true", "yes", ) try: schema = _schema_options(args, config, config_source) report = lint_vault( vault, require_trust_ledger=True, strict_trust=strict_trust, **schema, ) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if args.json: if args.pretty: print(json.dumps(report, indent=2)) else: print(json.dumps(report)) else: _print_lint(report) findings = report["findings"] strict_relevant = any( items for name, items in findings.items() if name != "snapshot_mismatch" and items ) if report["status"] == "fail" or (args.strict and strict_relevant): return 1 return 0 def _memory_fields(pairs: list) -> dict: fields: dict[str, str] = {} for pair in pairs or []: if "=" not in pair: raise ValueError(f"--field expects key=value, got {pair!r}") key, value = pair.split("=", 1) fields[key.strip()] = value return fields def _verb_and_fields(rest: list, verb_flag: str | None, field_flags: list) -> tuple: """Read `VERB key=value ...` positionally, falling back to the flag form. `--verb X --field a=1 --field b=2` is precise and unreadable; the six-field calls the ingest skills make ran to seven lines. A bare verb and bare key=value pairs say the same thing on one line. Both forms still work. """ verb, fields = verb_flag, {} for token in rest or []: if "=" in token: key, value = token.split("=", 1) fields[key.strip()] = value elif verb is None: verb = token else: raise ValueError( f"unexpected argument {token!r} — expected key=value " f"(the verb {verb!r} is already set)" ) fields.update(_memory_fields(field_flags)) return verb, fields def _memory_takeaways(raw: str | None) -> str | None: """`-` reads the takeaway prose from stdin, so a skill can pipe it in.""" if raw is None: return None return sys.stdin.read().strip() if raw == "-" else raw def _timezone_report(plan: dict, *, verbose: bool, applied: bool) -> list: """Human form of a timezone migration plan or result.""" name = plan["timezone"] files, count, stamps = plan["files"], plan["file_count"], plan["stamp_count"] would = "" if applied else "would " lines = [] if applied: key = " (recorded in index.md)" if plan.get("key_set") else "" lines.append(f"timezone: {name}{key}") if plan.get("backup_dir"): lines.append(f"backed up {len(plan['backup'])} file(s) to {plan['backup_dir']}") else: current = plan.get("key_current", "") key = "" if current == name else (" (not yet in index.md; --apply records it)" if not current else f" (index.md says {current}; --apply changes it)") lines.append(f"timezone: {name}{key}") lines.append( f"{count} file(s), {stamps} stamp(s) {would}change; " f"{plan['naive_skipped']} naive stamp(s) and {plan['bare_dates']} bare date(s) left alone" ) shown = files if verbose else files[:40] for entry in shown: lines.append(f" {entry['path']:<60} {len(entry['stamps'])}") samples = entry["stamps"] if verbose else entry["stamps"][:2] for change in samples: lines.append(f" {change['line']:>5}: {change['old']} -> {change['new']}") if not verbose and len(entry["stamps"]) > 2: lines.append(f" … {len(entry['stamps']) - 2} more in this file") if len(files) > len(shown): lines.append(f" … {len(files) - len(shown)} more file(s); --verbose lists every file and stamp, --json has them all") if applied: lines.append(plan["log_line"]) elif count: lines += [ "", "Every file touched is copied to _archives/pre-timezone-migration-/ first; --skip GLOB excludes a file.", f"Apply with: obsidian-wiki memory timezone{' ' + name if plan.get('key_current') != name else ''} --apply", ] return lines def cmd_memory(args: argparse.Namespace) -> int: """Maintain the vault memory surface: log, index, hot cache, profile, todos. One code path for files that fifteen skills used to each rewrite from prose, all of it under a single advisory lock so a parallel writer cannot drop an update. `--check` reports drift and exits 2 without writing, for CI. """ from obsidian_wiki import clock from obsidian_wiki import memory as mem context = _resolve_schema_command_context(args.vault) if context is None: return 1 vault, config, _source = context link_format = (config.get("OBSIDIAN_LINK_FORMAT") or _read_config_value("OBSIDIAN_LINK_FORMAT") or "wikilink").strip() rest = list(args.rest or []) def emit(payload: dict, lines: list) -> int: if args.json: print(json.dumps(payload, indent=2 if args.pretty else None)) else: for line in lines: print(line) return 0 try: action = args.memory_action if action == "status": status = mem.memory_status(vault) drift = status["index_drift"] return emit(status, [ f"vault: {status['vault']}", f"timezone: {status['timezone']}", f"pages: {status['pages']}", f"index: {'stale' if drift['stale'] else 'current'}" + (f" (+{len(drift['added'])} / -{len(drift['removed'])})" if drift["stale"] else ""), f"log: {status['log_entries']} entries", ("migrated: yes" if status["migrated"] else "migrated: NO — run `obsidian-wiki memory migrate` before index/hot writes"), f"hot: {status['hot']['words']}/{status['hot']['max_words']} words" + (" OVER BUDGET" if status["hot"]["over_budget"] else "") + ("" if status["hot"]["generated"] else " (hand-written, not yet generated)"), f"profile: {status['profile_facts']} fact(s)", f"todos: {status['todos']['open']} open, {status['todos']['stale']} stale," f" {status['todos']['closed']} closed", ]) if action == "now": # The stamp a skill should write into a page: the vault's own zone, # so an agent never has to know which .env it resolved. tz = clock.wiki_timezone(vault) payload = { "timestamp": clock.stamp(tz), "date": clock.today(tz), "timezone": clock.timezone_name(vault) or "UTC", } return emit(payload, [payload["date"] if args.date else payload["timestamp"]]) if action == "timezone": zone = rest[0] if rest else "" skip = list(args.skip or []) if args.check or not args.apply: plan = mem.timezone_migration_plan(vault, zone, skip=skip) lines = _timezone_report(plan, verbose=args.verbose, applied=False) emit(plan, lines) return 2 if plan["file_count"] else 0 result = mem.migrate_timezone(vault, zone, skip=skip) return emit(result, _timezone_report(result, verbose=args.verbose, applied=True)) if action == "migrate": if args.check or not args.apply: preview = mem.migration_status(vault) if preview["migrated"]: return emit(preview, ["already migrated — index.md and hot.md are generated"]) idx, hot = preview["index"], preview["hot"] lines = [ "This vault predates the memory writer. Migrating would:", "", f" index.md {idx['lines_before']} -> {idx['lines_after']} lines," f" {idx['entries_added']} catalog entries appended", ] lines += [f" keep, unchanged, above the generated catalog: {name}" for name in idx["sections_preserved"]] lines.append(f" hot.md regenerates: {', '.join(hot['sections_regenerated']) or 'nothing'}") lines.append(f" Key Takeaways carried over: {'yes' if hot['takeaways_carried'] else 'no'}") if hot["threads_to_seed"]: lines.append(f" {len(hot['threads_to_seed'])} thread(s) converted to todos:") lines += [f" - {t[:80]}" for t in hot["threads_to_seed"]] lines += ["", "A timestamped backup is written to _archives/ first.", "Apply with: obsidian-wiki memory migrate --apply"] emit(preview, lines) return 0 result = mem.migrate(vault, link_format=link_format) lines = [f"backed up to {result['backup_dir']}"] if result["backup"] else [] lines.append(f"index: {result['index']['total']} page(s), +{len(result['index']['added'])}") lines.append(f"hot: {result['hot']['words']} words") if result["threads_seeded"]: lines.append(f"todos: seeded {len(result['threads_seeded'])} thread(s) from hot.md") return emit(result, lines) if action == "log": verb, fields = _verb_and_fields(rest, args.verb, args.field) if not verb: print("error: memory log needs a VERB, e.g. `memory log INGEST source=x`", file=sys.stderr) return 1 line = mem.append_log(vault, verb, fields) return emit({"line": line}, [line]) if action == "index": result = mem.rebuild_index(vault, link_format=link_format, write=not args.check) payload = { "total": result.total, "added": list(result.added), "removed": list(result.removed), "changed": result.changed, "written": result.changed and not args.check, } lines = [ f"{result.total} page(s); " + (f"+{len(result.added)} / -{len(result.removed)}" if result.changed else "no drift") ] lines += [f" + {path}" for path in result.added] lines += [f" - {path}" for path in result.removed] emit(payload, lines) return 2 if (args.check and result.changed) else 0 if action == "hot": result = mem.rebuild_hot( vault, write=not args.check, takeaways=_memory_takeaways(args.takeaways), link_format=link_format, max_words=args.max_words, ) payload = { "words": result.words, "max_words": args.max_words or mem.hot_max_words(), "trimmed": result.trimmed, "activity": result.activity, "threads": result.threads, "contradictions": result.contradictions, "written": not args.check, } emit(payload, [ f"hot.md {'would be ' if args.check else ''}rebuilt: {result.words} words" + (" (trimmed to fit)" if result.trimmed else ""), f" {result.activity} activity, {result.threads} thread(s)," f" {result.contradictions} contradiction(s)", ]) return 0 if action == "recap": print( mem.build_recap( vault, max_words=args.max_words or 400, min_confidence=args.min_confidence, project=args.project, ), end="", ) return 0 if action == "sync": # The post-write call: one lock held across log, index, and hot, so # another writer cannot interleave between the three. verb, fields = _verb_and_fields(rest, args.verb, args.field) index = hot = None skipped = "" with mem.memory_lock(vault): line = mem.append_log(vault, verb, fields, lock=False) if verb else "" try: index = mem.rebuild_index(vault, link_format=link_format, lock=False) hot = mem.rebuild_hot( vault, lock=False, takeaways=_memory_takeaways(args.takeaways), link_format=link_format, max_words=args.max_words, ) except mem.MemoryError_ as exc: # An unmigrated vault must not fail the whole ingest: the # log line already landed and is append-only. Say so loudly # instead, so it is not silently skipped forever. if exc.code != "unmigrated": raise skipped = str(exc) if skipped: print(f"warning: index.md and hot.md not updated — {skipped}", file=sys.stderr) payload = { "log_line": line, "skipped": skipped, "index": None if index is None else { "total": index.total, "added": list(index.added), "removed": list(index.removed), }, "hot": None if hot is None else {"words": hot.words, "trimmed": hot.trimmed}, } lines = [line] if line else [] if index is not None and hot is not None: lines.append(f"index: {index.total} page(s), +{len(index.added)} / -{len(index.removed)}") lines.append(f"hot: {hot.words} words" + (" (trimmed)" if hot.trimmed else "")) else: lines.append("index/hot: skipped (run `obsidian-wiki memory migrate`)") return emit(payload, lines) if action == "profile": sub_action = rest[0] if rest else "list" if sub_action == "list": facts = mem.load_profile(vault) return emit( {"facts": [f.__dict__ for f in facts]}, [ f"{f.key}: {f.value} ({f.confidence:.2f}, {f.source}, {f.updated}" + (f", until {f.until}{' — expired' if f.is_expired() else ''}" if f.until else "") + ")" for f in facts ] or ["no profile facts recorded"], ) if sub_action == "set": if len(rest) < 3: print("error: memory profile set KEY VALUE", file=sys.stderr) return 1 fact = mem.set_fact( vault, rest[1], " ".join(rest[2:]), confidence=args.confidence, source=args.source, until=args.until or "", ) return emit({"fact": fact.__dict__}, [f"set {fact.key}: {fact.value} ({fact.confidence:.2f})"]) if sub_action == "forget": if len(rest) < 2: print("error: memory profile forget KEY", file=sys.stderr) return 1 removed = mem.forget_fact(vault, rest[1]) emit({"removed": removed, "key": rest[1]}, [f"forgot {rest[1]}" if removed else f"no such fact: {rest[1]}"]) return 0 if removed else 1 print(f"error: memory profile takes list|set|forget, got {sub_action!r}", file=sys.stderr) return 1 if action == "todo": sub_action = rest[0] if rest else "list" if sub_action == "list": todos = mem.load_todos(vault) shown = todos if args.all else [t for t in todos if t.status == "open"] return emit( {"todos": [dict(t.__dict__, stale=t.is_stale(days=args.stale_days)) for t in shown]}, [ f"[{t.status:7}] {t.id:4} {t.text}" + (f" ({t.origin})" if t.origin else "") + (" STALE" if t.is_stale(days=args.stale_days) else "") for t in shown ] or ["no todos recorded"], ) if sub_action == "add": if len(rest) < 2: print("error: memory todo add TEXT", file=sys.stderr) return 1 todo = mem.add_todo(vault, " ".join(rest[1:]), origin=args.origin or "") return emit({"todo": todo.__dict__}, [f"{todo.id}: {todo.text}"]) if sub_action in ("done", "drop"): if len(rest) < 2: print(f"error: memory todo {sub_action} ID", file=sys.stderr) return 1 status = "done" if sub_action == "done" else "dropped" todo = mem.set_todo_status(vault, rest[1], status) return emit({"todo": todo.__dict__}, [f"{todo.id} -> {todo.status}"]) if sub_action == "prune": removed = mem.prune_todos(vault) return emit({"pruned": removed}, [f"pruned {removed} closed item(s)"]) print(f"error: memory todo takes list|add|done|drop|prune, got {sub_action!r}", file=sys.stderr) return 1 print(f"error: unknown memory action {action!r}", file=sys.stderr) return 1 except mem.MemoryError_ as exc: print(f"error: {exc}", file=sys.stderr) return 1 except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="obsidian-wiki", description="Install the LLM-Wiki agent skills into your AI coding agents.", ) p.add_argument("-V", "--version", action="version", version=f"obsidian-wiki {__version__}") sub = p.add_subparsers(dest="command") sp = sub.add_parser("setup", help="install skills into your agents and write config (default)") _add_setup_args(sp) sp.set_defaults(func=cmd_setup) lp = sub.add_parser("list", help="list bundled skills") lp.set_defaults(func=cmd_list) ip = sub.add_parser("info", help="show install paths, version, and config") ip.set_defaults(func=cmd_info) fp = sub.add_parser("file-index", help="manage the SQLite source file asset index") file_sub = fp.add_subparsers(dest="file_index_command") fip = file_sub.add_parser("init", help="create the file asset SQLite database") _add_file_index_storage_args(fip) fip.set_defaults(func=cmd_file_index_init) frp = file_sub.add_parser("rebuild", help="rebuild the index from _meta/source-registry.jsonl") _add_file_index_storage_args(frp) frp.set_defaults(func=cmd_file_index_rebuild) fsp = file_sub.add_parser("search", help="search archived or staged source files") _add_file_index_storage_args(fsp) fsp.add_argument("query", nargs="*", help="search terms, product code, customer, report number, etc.") fsp.add_argument("-n", "--limit", type=int, default=10, help="maximum results to show") fsp.add_argument("--doc-type", help="filter by doc_type, e.g. drawing, test_report, order_data") fsp.add_argument("--source-type", help="filter by source_type, e.g. pdf, spreadsheet, cad") fsp.add_argument("--product-line", help="filter by product line text") fsp.add_argument("--customer", help="filter by customer text") fsp.add_argument("--competitor", help="filter by competitor text") fsp.add_argument("--status", help="filter by status: active or staged") fsp.add_argument("--active-only", action="store_true", help="exclude staged uploads") fsp.add_argument("--json", action="store_true", help="print full JSON records") fsp.set_defaults(func=cmd_file_index_search) ftp = file_sub.add_parser("stage", help="copy files into _sources/inbox and add staged index rows") _add_file_index_storage_args(ftp) ftp.add_argument("paths", nargs="+", help="file(s) to stage") ftp.add_argument("--project", default="misc", help="project namespace for staged files") ftp.add_argument("--doc-type", default="other", help="initial document type") ftp.add_argument("--visibility", default="internal", help="initial visibility") ftp.set_defaults(func=cmd_file_index_stage) mp = sub.add_parser("mcp-server", help="run the MCP server for safe source-file lookup") _add_file_index_storage_args(mp) mp.add_argument("--profile", help="bot profile name for ACL checks") mp.add_argument("--max-file-bytes", type=int, help="maximum resource size, 0 disables the limit") mp.add_argument("--expose-paths", action="store_true", help="include archive/absolute paths in tool output") mp.add_argument("--transport", choices=["stdio", "streamable-http"], help="MCP transport") mp.add_argument("--host", help="HTTP bind host. Used only with --transport streamable-http") mp.add_argument("--port", type=int, help="HTTP port. Used only with --transport streamable-http") mp.add_argument("--path", help="Streamable HTTP MCP path") mp.add_argument("--token-env", help="Environment variable containing the HTTP bearer token") mp.add_argument("--allowed-host", action="append", dest="allowed_hosts", help="allowed HTTP Host pattern") mp.add_argument("--allowed-origin", action="append", dest="allowed_origins", help="allowed Origin pattern") mp.set_defaults(func=cmd_mcp_server) bp = sub.add_parser("bot-view", help="derive isolated chatbot views from the mother vault") bot_sub = bp.add_subparsers(dest="bot_view_command") blp = bot_sub.add_parser("list", help="list configured bot profiles") _add_vault_arg(blp) blp.set_defaults(func=cmd_bot_view_list) bap = bot_sub.add_parser("audit", help="show what each bot profile can see") _add_vault_arg(bap) bap.add_argument("profile", nargs="?", help="optional profile name; audits all profiles when omitted") bap.add_argument("--json", action="store_true", help="print machine-readable audit JSON") bap.set_defaults(func=cmd_bot_view_audit) bep = bot_sub.add_parser("export", help="export a filtered markdown/source subset for one bot") _add_vault_arg(bep) bep.add_argument("profile", help="bot profile name") bep.add_argument("--out", default="_bot_exports", help="output directory; profile name is appended") bep.add_argument("--include-files", action="store_true", help="also copy allowed archived source files cited by exported pages") bep.set_defaults(func=cmd_bot_view_export) ga = sub.add_parser( "graph-analyse", help="analyse the vault's wikilink graph: god nodes, bridges, communities, " "surprising connections, suggested questions; or walk paths/neighbourhoods", ) ga.add_argument("vault", help="path to the Obsidian vault") ga.add_argument("--top", type=int, default=20, help="number of top results to return (default: 20)") ga.add_argument("--path", nargs=2, metavar=("FROM", "TO"), help="shortest link path between two pages (query mode)") ga.add_argument("--around", metavar="PAGE", help="pages within --depth hops of PAGE (query mode; blast radius with --direction in)") ga.add_argument("--depth", type=int, default=2, help="hops for --around (default: 2)") ga.add_argument("--direction", choices=["both", "in", "out"], default="both", help="link direction for --around / --path (default: both)") ga.add_argument("--diff-against", metavar="FILE", help="previous _insights.md (GRAPH_SNAPSHOT comment) or snapshot JSON to diff against") ga.add_argument("--snapshot", action="store_true", help="include a compact graph snapshot in the output for future --diff-against") ga.add_argument("--pretty", action="store_true", help="pretty-print JSON output") ga.set_defaults(func=cmd_graph_analyse) cc = sub.add_parser( "cache-check", help="check which sources are new/modified/unchanged vs. .manifest.json", ) cc.add_argument("vault", help="path to the Obsidian vault") cc.add_argument("sources", nargs="+", help="source file or directory paths to check") cc.add_argument("--pretty", action="store_true", help="pretty-print JSON output") cc.set_defaults(func=cmd_cache_check) pc = sub.add_parser( "projects-check", help="check how far each project's checkout has moved past last_commit_synced", ) pc.add_argument("vault", help="path to the Obsidian vault") pc.add_argument("--pretty", action="store_true", help="pretty-print JSON output") pc.set_defaults(func=cmd_projects_check) cu = sub.add_parser( "cache-update", help="record a source's current SHA-256 hash in .manifest.json after ingestion", ) cu.add_argument("vault", help="path to the Obsidian vault") cu.add_argument("source", help="source file or directory that was just ingested") cu.add_argument("--pages", nargs="*", metavar="PAGE", help="vault-relative paths of pages produced") cu.add_argument( "--key", default=None, help="explicit portable key (repo:/url:/agent:) for sources outside the vault and $HOME", ) cu.set_defaults(func=cmd_cache_update) ch = sub.add_parser( "cache-hash", help="compute the SHA-256 hash of a file or directory (no manifest I/O)", ) ch.add_argument("path", help="file or directory to hash") ch.set_defaults(func=cmd_cache_hash) st = sub.add_parser( "staging", help="list, promote, or discard pages waiting in _staging/ (WIKI_STAGED_WRITES)", ) st.add_argument("staging_action", choices=["list", "promote", "discard"], help="what to do") st.add_argument("path", nargs="?", help="staged path, as printed by `staging list`") st.add_argument("--vault", dest="vault", help="vault path or @name (defaults via CWD .env, then global config)") st.add_argument("--expect-staged", metavar="SHA", help="refuse if the staged file no longer matches this revision") st.add_argument("--expect-live", metavar="SHA", help="refuse if the live page no longer matches this revision") st.add_argument( "--expect-new", action="store_true", help="refuse if a live page has appeared since review (pairs with kind=new)", ) st.add_argument("--json", action="store_true", help="emit machine-readable JSON") st.add_argument("--pretty", action="store_true", help="pretty-print JSON output") st.set_defaults(func=cmd_staging) lt = sub.add_parser( "lint", help="lint a vault for missing frontmatter, broken links, duplicates, and orphans", ) lt.add_argument("vault", nargs="?", help="vault path or @name (defaults via CWD .env, then global config)") lt.add_argument("--json", action="store_true", help="emit machine-readable JSON") lt.add_argument("--pretty", action="store_true", help="pretty-print JSON output") lt.add_argument("--strict", action="store_true", help="exit non-zero on warnings as well as failures") lt.add_argument( "--strict-trust", action="store_true", help=( "fail lint on missing trust fields, ledger errors, stale reviews, and " "score mismatches (default: legacy mode, these are warnings only). " "Also settable per-vault via OBSIDIAN_TRUST_STRICT=1 in the config." ), ) lt.add_argument( "--allow-lifecycle", action="append", metavar="VALUE", help="extend the framework lifecycle allowlist (repeatable)", ) lt.add_argument( "--allow-relationship-type", action="append", metavar="VALUE", help="extend the framework relationship-type allowlist (repeatable)", ) lt.add_argument( "--required-trust-field", action="append", choices=("base_confidence", "lifecycle", "lifecycle_changed", "updated"), help="replace default trust-field requiredness (repeatable)", ) lt.add_argument( "--schema-source", help="authority locator recorded in the lint report (for example, vault/AGENTS.md)", ) lt.set_defaults(func=cmd_lint) mm = sub.add_parser( "memory", help="maintain the memory surface: log, index, hot cache, owner profile, todos", usage="obsidian-wiki memory [ARGS ...] [options]", description="Maintain the vault's memory: index.md, log.md, hot.md, and the _meta/ tables.", epilog="""examples: memory status is the surface current? memory now the timestamp to write into a page memory timezone America/Detroit --apply convert every existing stamp to the zone memory sync INGEST source=x.pdf pages=3 log + index + hot, one lock memory sync reconcile after writing pages memory recap --project myapp what a new session should know memory profile set stack "Python, FastAPI" a durable fact about the owner memory todo add "Ship the parser" a thread for the next session memory todo done t1 close it memory migrate adopt a vault with curated files memory index --check CI gate; exit 2 on drift `sync` and `log` take a VERB then bare key=value pairs. Values with spaces need quoting: query="how do transformers work".""", formatter_class=argparse.RawDescriptionHelpFormatter, ) mm.add_argument( "memory_action", choices=["status", "now", "log", "index", "hot", "recap", "sync", "migrate", "timezone", "profile", "todo"], help="what to do", ) mm.add_argument( "rest", nargs="*", metavar="ARGS", help="sync/log: VERB then key=value pairs. profile: list|set KEY VALUE|forget KEY. " "todo: list|add TEXT|done ID|drop ID|prune. timezone: [ZONE]", ) mm.add_argument("--vault", help="vault path or @name (defaults via CWD .env, then global config)") mm.add_argument("--json", action="store_true", help="emit machine-readable JSON") mm.add_argument("--pretty", action="store_true", help="pretty-print JSON output") mm_write = mm.add_argument_group("sync / hot") mm_write.add_argument( "--takeaways", help="replace the Key Takeaways section; `-` reads it from stdin", ) mm_write.add_argument("--max-words", type=int, help="word cap for hot/recap (default: OBSIDIAN_HOT_MAX_WORDS or 500)") mm_write.add_argument("--check", action="store_true", help="report drift and exit 2 without writing (CI gate)") mm_recap = mm.add_argument_group("recap") mm_recap.add_argument("--project", help="scope to one project's threads and activity") mm_recap.add_argument("--min-confidence", type=float, default=0.0, help="drop profile facts below this") mm_profile = mm.add_argument_group("profile set") mm_profile.add_argument("--confidence", type=float, default=0.6, help="how sure you are, 0..1 (default: 0.6)") mm_profile.add_argument("--source", default="conversation", help="where the fact came from") mm_profile.add_argument("--until", metavar="YYYY-MM-DD", help="time-bound fact: drop it from the recap after this date") mm_todo = mm.add_argument_group("todo") mm_todo.add_argument("--origin", help="originating page for `todo add`") mm_todo.add_argument("--stale-days", type=int, default=30, help="staleness threshold (default: 30)") mm_todo.add_argument("--all", action="store_true", help="include closed items in `todo list`") mm_now = mm.add_argument_group("now") mm_now.add_argument("--date", action="store_true", help="print the bare date (YYYY-MM-DD) instead of the timestamp") mm_tz = mm.add_argument_group("timezone") mm_tz.add_argument("--skip", action="append", metavar="GLOB", help="leave files matching this vault-relative glob alone; repeatable") mm_tz.add_argument("--verbose", action="store_true", help="list every file and every stamp in the preview") mm_other = mm.add_argument_group("migrate / compatibility") mm_other.add_argument("--apply", action="store_true", help="perform the migration / timezone conversion (default is a preview)") mm_other.add_argument("--verb", help="older form of the positional VERB") mm_other.add_argument( "--field", action="append", metavar="KEY=VALUE", help="older form of a positional key=value; repeatable", ) mm.set_defaults(func=cmd_memory) return p def _add_vault_arg(sp: argparse.ArgumentParser) -> None: sp.add_argument("--vault", metavar="PATH", help="absolute path to your Obsidian vault") def _add_file_index_storage_args(sp: argparse.ArgumentParser) -> None: _add_vault_arg(sp) sp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to /_meta/file-assets.sqlite)") def _add_setup_args(sp: argparse.ArgumentParser) -> None: _add_vault_arg(sp) sp.add_argument( "--project", nargs="?", const="", default=None, metavar="DIR", help="also install project-local skills + bootstrap files into DIR " "(defaults to the current directory if no DIR given)", ) sp.add_argument( "--project-only", action="store_true", help="skip the global agent install (use with --project)", ) sp.add_argument( "--copy", action="store_true", help="copy skill files instead of symlinking to the installed package", ) def main(argv: list[str] | None = None) -> int: parser = build_parser() argv = list(sys.argv[1:] if argv is None else argv) # No subcommand → default to `setup` (the common case). if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help", "-V", "--version")): argv = ["setup", *argv] args = parser.parse_args(argv) if not getattr(args, "func", None): parser.print_help() return 0 # Warn about stale installs on every command except `setup` (which fixes it) # and `info` (which calls _check_stale itself with richer output). if getattr(args, "command", None) not in ( "setup", "info", "file-index", "bot-view", "graph-analyse", "cache-check", "projects-check", "cache-update", "cache-hash", "lint", "memory", "staging", None, ): _check_stale() try: return args.func(args) except (FileNotFoundError, RuntimeError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())