mirror of
https://github.com/Ar9av/obsidian-wiki.git
synced 2026-09-14 20:36:34 +08:00
ea4508114e
setup.sh had an interactive git-sync flow (git init, .gitignore, remote, a generated sync.sh, optional cron) that `obsidian-wiki setup` — the entry point pip/uv installs actually run — never had, so those installs silently skipped it and .env.example documented a var nothing ever read. Move the git plumbing into obsidian_wiki/sync.py (configure_sync/run_sync/ get_remote) and expose it as `obsidian-wiki sync-setup <url>` and `obsidian-wiki sync`. `obsidian-wiki setup` now offers the same interactive prompt setup.sh always had (or accepts --remote non-interactively), and setup.sh's own prompt delegates into the same module via the CLI instead of re-implementing it. Sync status is read live from the vault's own `git remote` (via `obsidian-wiki info`) rather than a config var that could drift.
1237 lines
47 KiB
Python
1237 lines
47 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 shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from obsidian_wiki import __version__
|
|
|
|
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 line.split("=", 1)[1].strip().strip('"')
|
|
return ""
|
|
|
|
|
|
def _read_config() -> dict[str, str]:
|
|
if not GLOBAL_CONFIG.is_file():
|
|
return {}
|
|
values: dict[str, str] = {}
|
|
for raw in GLOBAL_CONFIG.read_text().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 resolve_vault_path(cli_vault: str | None) -> str:
|
|
if cli_vault:
|
|
return os.path.expanduser(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,
|
|
)
|
|
|
|
|
|
def _doctor_add(
|
|
checks: list[dict[str, str]],
|
|
*,
|
|
name: str,
|
|
status: str,
|
|
detail: str,
|
|
hint: str = "",
|
|
) -> None:
|
|
checks.append({
|
|
"name": name,
|
|
"status": status,
|
|
"detail": detail,
|
|
"hint": hint,
|
|
})
|
|
|
|
|
|
def _doctor_status(checks: list[dict[str, str]]) -> str:
|
|
statuses = {check["status"] for check in checks}
|
|
if "fail" in statuses:
|
|
return "fail"
|
|
if "warn" in statuses:
|
|
return "warn"
|
|
return "pass"
|
|
|
|
|
|
def _required_vault_paths(vault: Path) -> list[Path]:
|
|
return [
|
|
vault / "index.md",
|
|
vault / "log.md",
|
|
vault / "hot.md",
|
|
vault / ".manifest.json",
|
|
]
|
|
|
|
|
|
def _doctor_project_check(project_dir: Path) -> dict[str, str]:
|
|
required = [project_dir / "AGENTS.md", *[project_dir / dest for _src, dest in BOOTSTRAP_FILES[1:]]]
|
|
missing = [str(path.relative_to(project_dir)) for path in required if not path.exists()]
|
|
if missing:
|
|
return {
|
|
"status": "warn",
|
|
"detail": f"missing {len(missing)} bootstrap file(s)",
|
|
"hint": f"run: obsidian-wiki setup --project {project_dir}",
|
|
}
|
|
aliases_missing = [alias for alias in AGENTS_ALIASES if not (project_dir / alias).exists()]
|
|
if aliases_missing:
|
|
return {
|
|
"status": "warn",
|
|
"detail": f"missing AGENTS aliases: {', '.join(aliases_missing)}",
|
|
"hint": f"run: obsidian-wiki setup --project {project_dir}",
|
|
}
|
|
return {"status": "pass", "detail": "bootstrap files and aliases present", "hint": ""}
|
|
|
|
|
|
def run_doctor(*, vault_override: str | None = None, project_dir: str | None = None) -> dict[str, object]:
|
|
checks: list[dict[str, str]] = []
|
|
|
|
try:
|
|
bundled = list_skills()
|
|
_doctor_add(
|
|
checks,
|
|
name="bundled-skills",
|
|
status="pass" if bundled else "fail",
|
|
detail=f"{len(bundled)} bundled skill(s) available",
|
|
hint="" if bundled else "reinstall obsidian-wiki",
|
|
)
|
|
except FileNotFoundError as exc:
|
|
_doctor_add(checks, name="bundled-skills", status="fail", detail=str(exc), hint="reinstall obsidian-wiki")
|
|
bundled = []
|
|
|
|
boot = bootstrap_dir()
|
|
_doctor_add(
|
|
checks,
|
|
name="bootstrap-assets",
|
|
status="pass" if boot else "fail",
|
|
detail=str(boot) if boot else "bootstrap files not found",
|
|
hint="" if boot else "reinstall obsidian-wiki",
|
|
)
|
|
|
|
config = _read_config()
|
|
config_present = GLOBAL_CONFIG.is_file()
|
|
_doctor_add(
|
|
checks,
|
|
name="global-config",
|
|
status="pass" if config_present else "fail",
|
|
detail=str(GLOBAL_CONFIG) if config_present else "global config not written",
|
|
hint="" if config_present else "run: obsidian-wiki setup --vault /path/to/your/vault",
|
|
)
|
|
|
|
vault_path = ""
|
|
if vault_override:
|
|
vault_path = os.path.expanduser(vault_override)
|
|
elif config_present:
|
|
vault_path = config.get("OBSIDIAN_VAULT_PATH", "")
|
|
|
|
if not vault_path:
|
|
_doctor_add(
|
|
checks,
|
|
name="vault-config",
|
|
status="fail",
|
|
detail="OBSIDIAN_VAULT_PATH is not set",
|
|
hint="run: obsidian-wiki setup --vault /path/to/your/vault",
|
|
)
|
|
vault = None
|
|
else:
|
|
vault = Path(vault_path).expanduser().resolve()
|
|
_doctor_add(
|
|
checks,
|
|
name="vault-config",
|
|
status="pass",
|
|
detail=str(vault),
|
|
hint="",
|
|
)
|
|
|
|
setup_version = config.get("OBSIDIAN_WIKI_VERSION", "") if config_present else ""
|
|
if setup_version and setup_version != __version__:
|
|
_doctor_add(
|
|
checks,
|
|
name="setup-version",
|
|
status="warn",
|
|
detail=f"setup ran with {setup_version}; installed package is {__version__}",
|
|
hint="run: obsidian-wiki setup",
|
|
)
|
|
elif config_present:
|
|
_doctor_add(
|
|
checks,
|
|
name="setup-version",
|
|
status="pass",
|
|
detail=f"setup version matches installed package ({__version__})" if setup_version else "setup version not recorded",
|
|
hint="" if setup_version else "re-run setup to record install metadata",
|
|
)
|
|
|
|
if vault is not None:
|
|
if vault.is_dir():
|
|
_doctor_add(checks, name="vault-path", status="pass", detail="vault directory exists", hint="")
|
|
missing_core = [str(path.relative_to(vault)) for path in _required_vault_paths(vault) if not path.exists()]
|
|
if missing_core:
|
|
_doctor_add(
|
|
checks,
|
|
name="vault-core-files",
|
|
status="warn",
|
|
detail=f"missing {len(missing_core)} core file(s): {', '.join(missing_core)}",
|
|
hint="run the wiki setup skill or create the missing files",
|
|
)
|
|
else:
|
|
_doctor_add(checks, name="vault-core-files", status="pass", detail="core vault files present", hint="")
|
|
|
|
manifest_path = vault / ".manifest.json"
|
|
if manifest_path.exists():
|
|
try:
|
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
sources = data.get("sources", {})
|
|
_doctor_add(
|
|
checks,
|
|
name="manifest-json",
|
|
status="pass",
|
|
detail=f"valid JSON with {len(sources)} tracked source(s)",
|
|
hint="",
|
|
)
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
_doctor_add(
|
|
checks,
|
|
name="manifest-json",
|
|
status="fail",
|
|
detail=f"invalid manifest: {exc}",
|
|
hint="repair or regenerate .manifest.json",
|
|
)
|
|
else:
|
|
_doctor_add(
|
|
checks,
|
|
name="vault-path",
|
|
status="fail",
|
|
detail=f"vault directory not found: {vault}",
|
|
hint="fix OBSIDIAN_VAULT_PATH or re-run setup",
|
|
)
|
|
|
|
agent_summaries: list[str] = []
|
|
partial_agents: list[str] = []
|
|
full_agents = 0
|
|
bundled_set = set(bundled)
|
|
for rel, label, _subset in GLOBAL_AGENT_DIRS:
|
|
agent_dir = HOME / rel
|
|
if not agent_dir.is_dir():
|
|
continue
|
|
installed = {p.name for p in agent_dir.iterdir() if (p.is_dir() or p.is_symlink())}
|
|
missing = bundled_set - installed
|
|
count = len(installed & bundled_set)
|
|
agent_summaries.append(f"{label}: {count}/{len(bundled_set)}")
|
|
if missing:
|
|
partial_agents.append(label)
|
|
else:
|
|
full_agents += 1
|
|
|
|
if not agent_summaries:
|
|
_doctor_add(
|
|
checks,
|
|
name="agent-installs",
|
|
status="warn",
|
|
detail="no global agent skill installs found",
|
|
hint="run: obsidian-wiki setup",
|
|
)
|
|
elif partial_agents:
|
|
_doctor_add(
|
|
checks,
|
|
name="agent-installs",
|
|
status="warn",
|
|
detail="; ".join(agent_summaries),
|
|
hint="re-run obsidian-wiki setup to fill missing skills",
|
|
)
|
|
else:
|
|
_doctor_add(
|
|
checks,
|
|
name="agent-installs",
|
|
status="pass",
|
|
detail=f"{full_agents} agent install(s) fully provisioned",
|
|
hint="",
|
|
)
|
|
|
|
if project_dir:
|
|
project = Path(project_dir).expanduser().resolve()
|
|
if project.is_dir():
|
|
project_check = _doctor_project_check(project)
|
|
_doctor_add(
|
|
checks,
|
|
name="project-bootstrap",
|
|
status=project_check["status"],
|
|
detail=project_check["detail"],
|
|
hint=project_check["hint"],
|
|
)
|
|
else:
|
|
_doctor_add(
|
|
checks,
|
|
name="project-bootstrap",
|
|
status="fail",
|
|
detail=f"project directory not found: {project}",
|
|
hint="pass an existing directory",
|
|
)
|
|
|
|
return {
|
|
"status": _doctor_status(checks),
|
|
"checks": checks,
|
|
}
|
|
|
|
|
|
def _print_doctor(report: dict[str, object]) -> None:
|
|
icon = {"pass": "✅", "warn": "⚠️ ", "fail": "❌"}
|
|
print(f"obsidian-wiki doctor: {report['status']}")
|
|
for check in report["checks"]:
|
|
name = check["name"]
|
|
status = check["status"]
|
|
detail = check["detail"]
|
|
hint = check["hint"]
|
|
print(f"{icon.get(status, '•')} {name}: {detail}")
|
|
if hint:
|
|
print(f" hint: {hint}")
|
|
|
|
|
|
# ── Commands ─────────────────────────────────────────────────────────────────
|
|
def _maybe_configure_sync(vault_path: Path, remote_arg: str | None) -> bool:
|
|
"""Offer (or apply) GitHub sync setup for the vault.
|
|
|
|
Non-interactive (`--remote` passed, or no TTY and no remote given): only
|
|
acts when a remote was explicitly supplied. Interactive: prompts, mirroring
|
|
setup.sh's flow, so pip/uv installs get the same offer shell/curl installs
|
|
always had (see #153).
|
|
"""
|
|
from obsidian_wiki.sync import configure_sync, get_remote
|
|
|
|
if get_remote(vault_path):
|
|
return True # already configured — nothing to do
|
|
|
|
remote = remote_arg
|
|
if not remote:
|
|
if not sys.stdin.isatty():
|
|
return False
|
|
print()
|
|
try:
|
|
answer = input(" Set up GitHub sync for your vault? [y/N]: ").strip()
|
|
except EOFError:
|
|
answer = ""
|
|
if answer.lower() != "y":
|
|
return False
|
|
try:
|
|
remote = input(" GitHub repo URL (e.g. https://github.com/you/my-wiki.git): ").strip()
|
|
except EOFError:
|
|
remote = ""
|
|
if not remote:
|
|
return False
|
|
|
|
try:
|
|
messages = configure_sync(vault_path, remote)
|
|
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
print(f"⚠️ GitHub sync setup skipped: {exc}", file=sys.stderr)
|
|
return False
|
|
for m in messages:
|
|
print(f"✅ {m}")
|
|
print("✅ Run `obsidian-wiki sync` any time to commit and push vault changes.")
|
|
return True
|
|
|
|
|
|
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)
|
|
|
|
sync_configured = False
|
|
if vault_path and Path(vault_path).expanduser().is_dir():
|
|
sync_configured = _maybe_configure_sync(Path(vault_path).expanduser(), args.remote)
|
|
|
|
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}")
|
|
if sync_configured:
|
|
print(" GitHub sync: obsidian-wiki sync")
|
|
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_sync_setup(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.sync import configure_sync
|
|
|
|
vault_str = resolve_vault_path(args.vault)
|
|
if not vault_str:
|
|
print("error: no vault configured — pass --vault or run `obsidian-wiki setup` first", file=sys.stderr)
|
|
return 1
|
|
vault_path = Path(vault_str).expanduser()
|
|
try:
|
|
messages = configure_sync(vault_path, args.remote)
|
|
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
for m in messages:
|
|
print(f"✅ {m}")
|
|
print("✅ Run `obsidian-wiki sync` any time to commit and push vault changes.")
|
|
return 0
|
|
|
|
|
|
def cmd_sync(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.sync import run_sync
|
|
|
|
vault_str = resolve_vault_path(args.vault)
|
|
if not vault_str:
|
|
print("error: no vault configured — pass --vault or run `obsidian-wiki setup` first", file=sys.stderr)
|
|
return 1
|
|
code, message = run_sync(Path(vault_str).expanduser())
|
|
print(message)
|
|
return code
|
|
|
|
|
|
def cmd_graph_query(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.graphrag import query
|
|
vault = Path(args.vault).expanduser().resolve()
|
|
if not vault.is_dir():
|
|
print(f"error: vault not found: {vault}", file=sys.stderr)
|
|
return 1
|
|
result = query(vault, args.question, top_n=args.top, max_should_read=args.max_read)
|
|
if args.pretty:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(json.dumps(result))
|
|
return 0
|
|
|
|
|
|
def cmd_batch_plan(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.batch import plan_batches
|
|
source_dir = Path(args.source_dir).expanduser().resolve()
|
|
vault = Path(args.vault).expanduser().resolve()
|
|
if not source_dir.is_dir():
|
|
print(f"error: source directory not found: {source_dir}", file=sys.stderr)
|
|
return 1
|
|
result = plan_batches(
|
|
source_dir,
|
|
vault,
|
|
max_batch_mb=args.max_mb,
|
|
max_batch_files=args.max_files,
|
|
skip_unchanged=not args.no_cache,
|
|
include_code=args.include_code,
|
|
)
|
|
if args.pretty:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(json.dumps(result))
|
|
return 0
|
|
|
|
|
|
def cmd_graph_analyse(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.graph_analysis import analyse_vault
|
|
vault = Path(args.vault).expanduser().resolve()
|
|
if not vault.is_dir():
|
|
print(f"error: vault not found: {vault}", file=sys.stderr)
|
|
return 1
|
|
result = analyse_vault(vault, top_n=args.top)
|
|
if args.pretty:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(json.dumps(result))
|
|
return 0
|
|
|
|
|
|
def cmd_cache_check(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.cache import check_sources
|
|
vault = Path(args.vault).expanduser().resolve()
|
|
sources = [Path(p).expanduser().resolve() 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_cache_update(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.cache import update_source
|
|
vault = Path(args.vault).expanduser().resolve()
|
|
source = Path(args.source).expanduser().resolve()
|
|
pages = args.pages or []
|
|
h = update_source(vault, source, pages_produced=pages)
|
|
print(json.dumps({"path": str(source), "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 cmd_ast_extract(args: argparse.Namespace) -> int:
|
|
from pathlib import Path
|
|
from obsidian_wiki.ast_extractor import extract
|
|
path = Path(args.path).expanduser().resolve()
|
|
try:
|
|
result = extract(path)
|
|
except FileNotFoundError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if args.pretty:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(json.dumps(result))
|
|
return 0
|
|
|
|
|
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
report = run_doctor(vault_override=args.vault, project_dir=args.project)
|
|
if args.json:
|
|
if args.pretty:
|
|
print(json.dumps(report, indent=2))
|
|
else:
|
|
print(json.dumps(report))
|
|
else:
|
|
_print_doctor(report)
|
|
statuses = {check["status"] for check in report["checks"]}
|
|
if "fail" in statuses or (args.strict and "warn" in statuses):
|
|
return 1
|
|
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 cmd_lint(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.lint import lint_vault
|
|
|
|
vault_arg = args.vault or _read_config_value("OBSIDIAN_VAULT_PATH")
|
|
if not vault_arg:
|
|
print("error: vault not configured; pass a path or run obsidian-wiki setup", file=sys.stderr)
|
|
return 1
|
|
|
|
vault = Path(vault_arg).expanduser().resolve()
|
|
if not vault.is_dir():
|
|
print(f"error: vault not found: {vault}", file=sys.stderr)
|
|
return 1
|
|
|
|
strict_trust = args.strict_trust or _read_config_value("OBSIDIAN_TRUST_STRICT").strip().lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
report = lint_vault(vault, require_trust_ledger=True, strict_trust=strict_trust)
|
|
if args.json:
|
|
if args.pretty:
|
|
print(json.dumps(report, indent=2))
|
|
else:
|
|
print(json.dumps(report))
|
|
else:
|
|
_print_lint(report)
|
|
if report["status"] == "fail" or (args.strict and report["status"] == "warn"):
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _resolve_command_vault(vault_arg: str | None) -> Path | None:
|
|
resolved = vault_arg or _read_config_value("OBSIDIAN_VAULT_PATH")
|
|
if not resolved:
|
|
print("error: vault not configured; pass a path 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
|
|
|
|
|
|
def cmd_trust_record(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.trust import (
|
|
TRUST_LEDGER_RELATIVE_PATH,
|
|
build_trust_ledger,
|
|
update_trust_ledger,
|
|
write_trust_ledger,
|
|
)
|
|
|
|
vault = _resolve_command_vault(args.vault)
|
|
if vault is None:
|
|
return 1
|
|
path = vault / TRUST_LEDGER_RELATIVE_PATH
|
|
try:
|
|
if args.all:
|
|
ledger = build_trust_ledger(vault, reviewed_at=args.reviewed_at)
|
|
recorded_pages = len(ledger["pages"])
|
|
else:
|
|
ledger = update_trust_ledger(
|
|
vault,
|
|
path,
|
|
reviewed_at=args.reviewed_at,
|
|
page_paths=args.page,
|
|
)
|
|
recorded_pages = len(set(args.page))
|
|
write_trust_ledger(path, ledger, vault=vault)
|
|
except ValueError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
result = {
|
|
"status": "recorded",
|
|
"ledger_path": str(path),
|
|
"recorded_pages": recorded_pages,
|
|
"reviewed_at": args.reviewed_at,
|
|
"method": ledger["method"],
|
|
}
|
|
if args.json:
|
|
print(json.dumps(result, indent=2 if args.pretty else None))
|
|
else:
|
|
print(f"recorded {result['recorded_pages']} reviewed page(s) in {path}")
|
|
return 0
|
|
|
|
|
|
def cmd_trust_check(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.trust import check_trust_ledger
|
|
|
|
vault = _resolve_command_vault(args.vault)
|
|
if vault is None:
|
|
return 1
|
|
report = check_trust_ledger(vault)
|
|
if args.json:
|
|
print(json.dumps(report, indent=2 if args.pretty else None))
|
|
else:
|
|
print(f"obsidian-wiki trust-check: {report['status']}")
|
|
for name, count in report["counts"].items():
|
|
print(f"{name}: {count}")
|
|
if report["status"] == "fail" or (args.strict and report["status"] == "warn"):
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _print_query(result: dict[str, object]) -> None:
|
|
print(f"answer_type: {result['answer_type']}")
|
|
candidates = result.get("candidates", [])
|
|
if candidates:
|
|
print("candidates:")
|
|
for item in candidates:
|
|
print(f"- {item['title']} ({item['page']}) score={item['score']}")
|
|
path = result.get("path") or []
|
|
if path:
|
|
print("path:")
|
|
print(" -> ".join(path))
|
|
should_read = result.get("should_read") or []
|
|
if should_read:
|
|
print("should_read:")
|
|
for page in should_read:
|
|
print(f"- {page}")
|
|
|
|
|
|
def cmd_query(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.graphrag import query
|
|
|
|
vault_arg = args.vault or _read_config_value("OBSIDIAN_VAULT_PATH")
|
|
if not vault_arg:
|
|
print("error: vault not configured; pass --vault or run obsidian-wiki setup", file=sys.stderr)
|
|
return 1
|
|
|
|
vault = Path(vault_arg).expanduser().resolve()
|
|
if not vault.is_dir():
|
|
print(f"error: vault not found: {vault}", file=sys.stderr)
|
|
return 1
|
|
|
|
result = query(vault, args.question, top_n=args.top, max_should_read=args.max_read)
|
|
if args.json:
|
|
if args.pretty:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(json.dumps(result))
|
|
else:
|
|
_print_query(result)
|
|
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)'}")
|
|
if vp:
|
|
from obsidian_wiki.sync import get_remote
|
|
remote = get_remote(Path(vp).expanduser())
|
|
print(f"sync: {remote if remote else '(not configured — run: obsidian-wiki sync-setup <url>)'}")
|
|
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
|
|
|
|
|
|
# ── Argument parsing ─────────────────────────────────────────────────────────
|
|
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)
|
|
|
|
ssp = sub.add_parser(
|
|
"sync-setup",
|
|
help="configure GitHub sync for your vault (git init, .gitignore, remote)",
|
|
)
|
|
ssp.add_argument("remote", help="GitHub (or any git host) repo URL, e.g. https://github.com/you/my-wiki.git")
|
|
ssp.add_argument("--vault", metavar="PATH", help="absolute path to your Obsidian vault")
|
|
ssp.set_defaults(func=cmd_sync_setup)
|
|
|
|
syp = sub.add_parser("sync", help="commit and push pending vault changes (git add -A, commit, push)")
|
|
syp.add_argument("--vault", metavar="PATH", help="absolute path to your Obsidian vault")
|
|
syp.set_defaults(func=cmd_sync)
|
|
|
|
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)
|
|
|
|
gq = sub.add_parser(
|
|
"graph-query",
|
|
help="answer a question from the vault's wikilink index without reading page bodies",
|
|
)
|
|
gq.add_argument("vault", help="path to the Obsidian vault")
|
|
gq.add_argument("question", help="question to answer")
|
|
gq.add_argument("--top", type=int, default=8, help="number of candidate pages to rank (default: 8)")
|
|
gq.add_argument("--max-read", type=int, default=3, help="max pages to return in should_read (default: 3)")
|
|
gq.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
gq.set_defaults(func=cmd_graph_query)
|
|
|
|
bp = sub.add_parser(
|
|
"batch-plan",
|
|
help="split a source directory into parallel-ingest batches, skipping unchanged files",
|
|
)
|
|
bp.add_argument("vault", help="path to the Obsidian vault")
|
|
bp.add_argument("source_dir", help="directory of source documents to ingest")
|
|
bp.add_argument("--max-mb", type=float, default=2.0, help="max MB per batch (default: 2)")
|
|
bp.add_argument("--max-files", type=int, default=20, help="max files per batch (default: 20)")
|
|
bp.add_argument("--no-cache", action="store_true", help="disable manifest-based skip of unchanged files")
|
|
bp.add_argument("--include-code", action="store_true", help="include code files (default: excluded; use ast-extract instead)")
|
|
bp.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
bp.set_defaults(func=cmd_batch_plan)
|
|
|
|
ga = sub.add_parser(
|
|
"graph-analyse",
|
|
help="analyse the vault's wikilink graph: god nodes, communities, surprising connections",
|
|
)
|
|
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("--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)
|
|
|
|
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.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)
|
|
|
|
ap = sub.add_parser(
|
|
"ast-extract",
|
|
help="extract code structure (classes, functions, imports) from a file or directory — no LLM, no API calls",
|
|
)
|
|
ap.add_argument("path", help="file or directory to extract from")
|
|
ap.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
ap.set_defaults(func=cmd_ast_extract)
|
|
|
|
dr = sub.add_parser(
|
|
"doctor",
|
|
help="check config, vault shape, bootstrap assets, and installed skills",
|
|
)
|
|
dr.add_argument("--vault", help="override OBSIDIAN_VAULT_PATH for this health check")
|
|
dr.add_argument("--project", help="also check project-local bootstrap files in this directory")
|
|
dr.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
dr.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
dr.add_argument("--strict", action="store_true", help="exit non-zero on warnings as well as failures")
|
|
dr.set_defaults(func=cmd_doctor)
|
|
|
|
lt = sub.add_parser(
|
|
"lint",
|
|
help="lint a vault for missing frontmatter, broken links, duplicates, and orphans",
|
|
)
|
|
lt.add_argument("vault", nargs="?", help="path to the Obsidian vault (defaults to configured OBSIDIAN_VAULT_PATH)")
|
|
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.set_defaults(func=cmd_lint)
|
|
|
|
tr = sub.add_parser(
|
|
"trust-record",
|
|
help="record explicitly approved manual confidence reviews in the vault trust ledger",
|
|
)
|
|
tr.add_argument("vault", nargs="?", help="path to the Obsidian vault (defaults to configured OBSIDIAN_VAULT_PATH)")
|
|
selection = tr.add_mutually_exclusive_group(required=True)
|
|
selection.add_argument("--all", action="store_true", help="record every current trust-schema page")
|
|
selection.add_argument(
|
|
"--page",
|
|
action="append",
|
|
metavar="VAULT_RELATIVE_PATH",
|
|
help="record only this explicitly reviewed page (repeatable)",
|
|
)
|
|
tr.add_argument("--reviewed-at", required=True, help="ISO timestamp for the approved review")
|
|
tr.add_argument(
|
|
"--approved",
|
|
action="store_true",
|
|
required=True,
|
|
help="confirm a human approved every confidence value being recorded",
|
|
)
|
|
tr.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
tr.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
tr.set_defaults(func=cmd_trust_record)
|
|
|
|
tc = sub.add_parser(
|
|
"trust-check",
|
|
help="validate confidence values and material fingerprints against the manual trust ledger",
|
|
)
|
|
tc.add_argument("vault", nargs="?", help="path to the Obsidian vault (defaults to configured OBSIDIAN_VAULT_PATH)")
|
|
tc.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
tc.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
tc.add_argument("--strict", action="store_true", help="exit non-zero on warnings as well as failures")
|
|
tc.set_defaults(func=cmd_trust_check)
|
|
|
|
qq = sub.add_parser(
|
|
"query",
|
|
help="query the configured vault without passing the raw path each time",
|
|
)
|
|
qq.add_argument("question", help="question to ask against the vault index")
|
|
qq.add_argument("--vault", help="override OBSIDIAN_VAULT_PATH for this query")
|
|
qq.add_argument("--top", type=int, default=8, help="number of candidate pages to rank (default: 8)")
|
|
qq.add_argument("--max-read", type=int, default=3, help="max pages to return in should_read (default: 3)")
|
|
qq.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
qq.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
qq.set_defaults(func=cmd_query)
|
|
|
|
return p
|
|
|
|
|
|
def _add_setup_args(sp: argparse.ArgumentParser) -> None:
|
|
sp.add_argument("--vault", metavar="PATH", help="absolute path to your Obsidian vault")
|
|
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",
|
|
)
|
|
sp.add_argument(
|
|
"--remote",
|
|
metavar="URL",
|
|
help="GitHub (or any git host) repo URL for vault sync — skips the interactive "
|
|
"prompt and configures it non-interactively (see also: obsidian-wiki sync-setup)",
|
|
)
|
|
|
|
|
|
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", "doctor", 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())
|