Files
obsidian-wiki/obsidian_wiki/cli.py
T
Codex 82890606aa feat(wiki): sync upstream v2026.10.4 core maintenance
Selectively port source hashing, locked manifest/memory writes, path-aware
link lint and graph analysis, and revision-checked staging from upstream
4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions.

Preserve local archive/catalog citations, curated manifest metadata, config
location, file-index and MCP behavior. Wire maintenance guidance into four
skills without migrating vault contents or changing running services.

Validation: 609 tests passed, including existing MCP/file-asset regressions
and imported upstream maintenance tests. Read-only vault status left shared
metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
2026-10-31 12:39:48 +00:00

1697 lines
71 KiB
Python

"""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 <pkg>/_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-<stamp>/ 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 <action> [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 <vault>/_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())