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.
1697 lines
71 KiB
Python
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())
|