mirror of
https://github.com/Ar9av/obsidian-wiki.git
synced 2026-09-14 20:36:34 +08:00
382c3da29c
Brings the graph analyser up to the algorithm family graphify runs on code graphs, in pure Python (no networkx/numpy — the package stays dependency-free): - `bridges` — exact Brandes betweenness centrality. The pages that sit on the most shortest paths; removing one fragments the vault. - `cohesion` per community — intra-cluster edge density; < 0.15 with 5+ pages marks a fragmented cluster. - `--path A B` — shortest wikilink chain between two pages. - `--around PAGE --depth N --direction in|out|both` — N-hop neighbourhood; `--direction in` is the blast radius if the page were renamed or removed. - `--diff-against` / `--snapshot` — compare against a previous `_insights.md` snapshot (added/removed pages and links, newly connected, lost incoming). - `suggested_questions` — derived from bridges, sink hubs, isolates and low-cohesion clusters. Two correctness fixes fall out of this: - Vault bookkeeping files (`index`, `log`, `hot`, `_insights` at the vault root, plus `_meta/` and `_readouts/`) are no longer graph nodes. They link to nearly every page, so they dominated every degree and centrality ranking. A nested `concepts/index.md` is still an ordinary page. - Pages are now iterated in sorted order rather than filesystem order, so results are reproducible across machines. That exposed a latent flaw in the label-propagation fallback: a sequential sweep cascades one label along chains and collapsed a barbell graph into a single community. Nodes are now visited in a seeded shuffle — deterministic, and clusters survive. Betweenness is O(V*E) and the only expensive step in the module (~43s on a 5,000-page vault, versus 0.13s to parse it and 0.03s for communities), so it is memoised in `.graph-cache.json`. The cache key is a hash of the graph topology rather than file mtimes, which makes a stale hit impossible by construction: retargeting any link changes the key, while editing prose without touching links keeps the cached result valid. The file is only written when the computation actually exceeded 0.5s, so small and medium vaults never accumulate one; it is bounded to 3 keys, written atomically, and ignored if corrupt. Sampled betweenness (k pivots) was measured as an alternative and rejected: 25-50x faster but only 60-90% top-10 overlap and wrong ordering even on a 38-page vault, which is not acceptable for a ranked list a human reads.
2248 lines
86 KiB
Python
2248 lines
86 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 the global config (XDG-style, under
|
|
``$XDG_CONFIG_HOME/obsidian-wiki`` by default) 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 datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import TypedDict
|
|
|
|
from obsidian_wiki import __version__
|
|
|
|
HOME = Path.home()
|
|
|
|
|
|
def _resolve_global_config_dir() -> Path:
|
|
"""Resolve the global config directory, XDG-first with legacy fallback.
|
|
|
|
New installs land under ``$XDG_CONFIG_HOME/obsidian-wiki`` (default
|
|
``~/.config/obsidian-wiki``, per the XDG Base Directory spec). Installs that
|
|
already have a ``~/.obsidian-wiki`` directory keep using it, so upgrading
|
|
doesn't strand a working config.
|
|
"""
|
|
xdg_home = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
xdg_dir = (Path(xdg_home).expanduser() if xdg_home else HOME / ".config") / "obsidian-wiki"
|
|
legacy_dir = HOME / ".obsidian-wiki"
|
|
if legacy_dir.is_dir() and not xdg_dir.exists():
|
|
return legacy_dir
|
|
return xdg_dir
|
|
|
|
|
|
GLOBAL_CONFIG_DIR = _resolve_global_config_dir()
|
|
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/context work everywhere.
|
|
PORTABLE_SKILLS = ("wiki-update", "wiki-query", "wiki-context-pack")
|
|
|
|
|
|
class SchemaOptions(TypedDict):
|
|
allowed_lifecycles: frozenset[str]
|
|
allowed_relationship_types: frozenset[str]
|
|
required_trust_fields: tuple[str, ...]
|
|
schema_source: str
|
|
|
|
|
|
# ── 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 extension_dir() -> Path | None:
|
|
"""Return the browser extension folder to load unpacked, if bundled.
|
|
|
|
Wheel installs get it at ``_data/extension``; a source checkout keeps it at
|
|
``extensions/brain``.
|
|
"""
|
|
for cand in (_pkg_dir() / "_data" / "extension", _pkg_dir().parent / "extensions" / "brain"):
|
|
if (cand / "manifest.json").is_file():
|
|
return cand
|
|
return None
|
|
|
|
|
|
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:
|
|
"""Write the setup-managed keys, preserving everything else in the file.
|
|
|
|
Only ``OBSIDIAN_VAULT_PATH``, ``OBSIDIAN_WIKI_REPO`` and
|
|
``OBSIDIAN_WIKI_VERSION`` are owned by setup. Any other key the user added
|
|
(``OBSIDIAN_LINK_FORMAT``, ``QMD_WIKI_COLLECTION``, sync settings, …) is
|
|
carried over untouched, along with comments and ordering, so re-running
|
|
setup on an existing install is non-destructive.
|
|
"""
|
|
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
|
|
managed = {
|
|
"OBSIDIAN_VAULT_PATH": vault_path,
|
|
"OBSIDIAN_WIKI_REPO": str(repo_root),
|
|
"OBSIDIAN_WIKI_VERSION": __version__,
|
|
}
|
|
|
|
existing: list[str] = []
|
|
if GLOBAL_CONFIG.is_file():
|
|
existing = GLOBAL_CONFIG.read_text().splitlines()
|
|
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw in existing:
|
|
stripped = raw.strip()
|
|
key = stripped.split("=", 1)[0].strip() if "=" in stripped else ""
|
|
if key in managed and not stripped.startswith("#"):
|
|
if key not in seen:
|
|
out.append(f'{key}="{managed[key]}"')
|
|
seen.add(key)
|
|
# Drop duplicate definitions of a managed key.
|
|
continue
|
|
out.append(raw)
|
|
for key, value in managed.items():
|
|
if key not in seen:
|
|
out.append(f'{key}="{value}"')
|
|
|
|
GLOBAL_CONFIG.write_text("\n".join(out) + "\n")
|
|
print(f"✅ Global config written to {GLOBAL_CONFIG}")
|
|
|
|
|
|
VAULT_SUBDIRS = (
|
|
"concepts",
|
|
"entities",
|
|
"skills",
|
|
"references",
|
|
"synthesis",
|
|
"journal",
|
|
"projects",
|
|
"_archives",
|
|
"_raw",
|
|
"_staging",
|
|
".obsidian",
|
|
)
|
|
|
|
|
|
def scaffold_vault(vault_path: Path) -> bool:
|
|
"""Create the vault directory structure and special files if they don't exist yet.
|
|
|
|
Idempotent: existing files/dirs are left untouched. Returns True if the vault
|
|
directory itself had to be created (i.e. this is a brand new vault).
|
|
"""
|
|
created = not vault_path.is_dir()
|
|
for name in VAULT_SUBDIRS:
|
|
(vault_path / name).mkdir(parents=True, exist_ok=True)
|
|
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
index_md = vault_path / "index.md"
|
|
if not index_md.exists():
|
|
index_md.write_text(
|
|
"---\n"
|
|
"title: Wiki Index\n"
|
|
"---\n\n"
|
|
"# Wiki Index\n\n"
|
|
f"*This index is automatically maintained. Last updated: {timestamp}*\n\n"
|
|
"## Concepts\n\n"
|
|
"*No pages yet. Use `wiki-ingest` to add your first source.*\n\n"
|
|
"## Entities\n\n"
|
|
"## Skills\n\n"
|
|
"## References\n\n"
|
|
"## Synthesis\n\n"
|
|
"## Journal\n"
|
|
)
|
|
|
|
log_md = vault_path / "log.md"
|
|
if not log_md.exists():
|
|
log_md.write_text(
|
|
"---\n"
|
|
"title: Wiki Log\n"
|
|
"---\n\n"
|
|
"# Wiki Log\n\n"
|
|
f'- [{timestamp}] INIT vault_path="{vault_path}" '
|
|
"categories=concepts,entities,skills,references,synthesis,journal\n"
|
|
)
|
|
|
|
hot_md = vault_path / "hot.md"
|
|
if not hot_md.exists():
|
|
hot_md.write_text(
|
|
"---\n"
|
|
"title: Hot Cache\n"
|
|
f"updated: {timestamp}\n"
|
|
"---\n\n"
|
|
"# Hot Cache\n\n"
|
|
"*A ~500-word semantic snapshot of recent activity. Updated after every major write operation.*\n\n"
|
|
"## Recent Activity\n\n"
|
|
f"- [{timestamp}] INIT — vault created at {vault_path}\n\n"
|
|
"## Active Threads\n\n"
|
|
"*None yet — start ingesting sources to populate.*\n\n"
|
|
"## Key Takeaways\n\n"
|
|
"*None yet.*\n\n"
|
|
"## Flagged Contradictions\n\n"
|
|
"*None yet.*\n"
|
|
)
|
|
|
|
manifest_json = vault_path / ".manifest.json"
|
|
if not manifest_json.exists():
|
|
manifest_json.write_text("{}\n")
|
|
|
|
app_json = vault_path / ".obsidian" / "app.json"
|
|
if not app_json.exists():
|
|
app_json.write_text(
|
|
json.dumps(
|
|
{
|
|
"strictLineBreaks": False,
|
|
"showFrontmatter": False,
|
|
"defaultViewMode": "preview",
|
|
"livePreview": True,
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
appearance_json = vault_path / ".obsidian" / "appearance.json"
|
|
if not appearance_json.exists():
|
|
appearance_json.write_text(json.dumps({"baseFontSize": 16}, indent=2) + "\n")
|
|
|
|
return created
|
|
|
|
|
|
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 _doctor_code_understanding_checks(
|
|
project_dir: Path, backend_setting: str, bin_path: str | None
|
|
) -> list[dict[str, str]]:
|
|
"""Code-understanding readiness checks for a project (issue #167)."""
|
|
checks: list[dict[str, str]] = []
|
|
|
|
from obsidian_wiki.ast_extractor import extract
|
|
|
|
try:
|
|
data = extract(project_dir)
|
|
if data.get("nodes"):
|
|
checks.append({
|
|
"name": "code-understanding.builtin",
|
|
"status": "pass",
|
|
"detail": f"found {len(data['nodes'])} AST node(s)",
|
|
"hint": "",
|
|
})
|
|
else:
|
|
checks.append({
|
|
"name": "code-understanding.builtin",
|
|
"status": "warn",
|
|
"detail": "no code files found",
|
|
"hint": "code-understand will produce an empty focus map",
|
|
})
|
|
except (OSError, ValueError) as exc:
|
|
checks.append({
|
|
"name": "code-understanding.builtin",
|
|
"status": "warn",
|
|
"detail": f"AST extraction failed: {exc}",
|
|
"hint": "code-understand may not find any symbols",
|
|
})
|
|
|
|
rg_path = shutil.which("rg")
|
|
checks.append({
|
|
"name": "code-understanding.rg",
|
|
"status": "pass" if rg_path else "warn",
|
|
"detail": rg_path or "ripgrep (rg) not found on PATH",
|
|
"hint": "" if rg_path else "install ripgrep for cross-file reference evidence",
|
|
})
|
|
|
|
from obsidian_wiki.code_understanding import index_state
|
|
|
|
codegraph_path = bin_path or shutil.which("codegraph")
|
|
if codegraph_path:
|
|
checks.append({
|
|
"name": "code-understanding.codegraph",
|
|
"status": "pass",
|
|
"detail": str(codegraph_path),
|
|
"hint": "",
|
|
})
|
|
elif backend_setting == "codegraph":
|
|
checks.append({
|
|
"name": "code-understanding.codegraph",
|
|
"status": "fail",
|
|
"detail": "codegraph backend requested but binary not found",
|
|
"hint": "set CODE_UNDERSTANDING_CODEGRAPH_BIN or install codegraph",
|
|
})
|
|
else:
|
|
checks.append({
|
|
"name": "code-understanding.codegraph",
|
|
"status": "warn",
|
|
"detail": "codegraph binary not found (builtin backend will be used)",
|
|
"hint": "set CODE_UNDERSTANDING_CODEGRAPH_BIN or install codegraph",
|
|
})
|
|
|
|
if codegraph_path:
|
|
initialized, fresh, detail = index_state(project_dir)
|
|
checks.append({
|
|
"name": "code-understanding.codegraph-index",
|
|
"status": "pass" if initialized else "warn",
|
|
"detail": detail,
|
|
"hint": "" if initialized else "run: codegraph index <project>",
|
|
})
|
|
if initialized:
|
|
if not (project_dir / ".git").exists():
|
|
# index_state's freshness heuristic needs git-tracked files;
|
|
# without git it cannot see a stale index — compare mtimes directly.
|
|
db = project_dir / ".codegraph" / "codegraph.db"
|
|
codegraph_prefix = str((project_dir / ".codegraph").resolve())
|
|
try:
|
|
newest = max(
|
|
p.stat().st_mtime
|
|
for p in project_dir.rglob("*")
|
|
if p.is_file()
|
|
and not str(p.resolve()).startswith(codegraph_prefix)
|
|
)
|
|
except OSError:
|
|
newest = 0.0
|
|
if db.stat().st_mtime < newest:
|
|
fresh = False
|
|
detail = "stale (codegraph.db older than sources)"
|
|
checks.append({
|
|
"name": "code-understanding.codegraph-fresh",
|
|
"status": "pass" if fresh else "warn",
|
|
"detail": detail,
|
|
"hint": "" if fresh else "re-run: codegraph index <project>",
|
|
})
|
|
return checks
|
|
|
|
|
|
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"],
|
|
)
|
|
backend_setting = (
|
|
os.environ.get("CODE_UNDERSTANDING_BACKEND")
|
|
or config.get("CODE_UNDERSTANDING_BACKEND")
|
|
or "auto"
|
|
)
|
|
bin_path = os.environ.get("CODE_UNDERSTANDING_CODEGRAPH_BIN")
|
|
for check in _doctor_code_understanding_checks(project, backend_setting, bin_path):
|
|
_doctor_add(
|
|
checks,
|
|
name=check["name"],
|
|
status=check["status"],
|
|
detail=check["detail"],
|
|
hint=check.get("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(f" or edit OBSIDIAN_VAULT_PATH in {GLOBAL_CONFIG}.")
|
|
else:
|
|
vault_dir = Path(vault_path).expanduser()
|
|
vault_created = scaffold_vault(vault_dir)
|
|
if vault_created:
|
|
print(f"✅ Vault created at {vault_dir}")
|
|
else:
|
|
print(f"✅ Vault verified at {vault_dir}")
|
|
|
|
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(" /wiki-context-pack → compile bounded context for another agent")
|
|
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 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
|
|
|
|
|
|
DEFAULT_CLAUDE_DIR = "~/.claude"
|
|
DEFAULT_BRAIN_DIR = "~/.claude/session-brain"
|
|
|
|
|
|
def _brain_dir(args: argparse.Namespace) -> Path:
|
|
return Path(
|
|
args.out or os.environ.get("WIKI_SESSION_BRAIN_DIR") or DEFAULT_BRAIN_DIR
|
|
).expanduser()
|
|
|
|
|
|
def _skip_list(args: argparse.Namespace) -> list[str]:
|
|
raw = args.skip or os.environ.get("WIKI_SKIP_PROJECTS", "")
|
|
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
|
|
|
|
def cmd_sessions_build(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.session_graph import build
|
|
claude_dir = Path(args.claude_dir).expanduser()
|
|
bookmarks = Path(args.bookmarks).expanduser() if args.bookmarks else \
|
|
Path("~/.bookmark-agent/bookmarks.json").expanduser()
|
|
|
|
def progress(message: str) -> None:
|
|
if args.verbose:
|
|
print(f"… {message}", file=sys.stderr)
|
|
|
|
result = build(
|
|
claude_dir,
|
|
_brain_dir(args),
|
|
k=args.k,
|
|
min_sim=args.min_sim,
|
|
mutual=args.mutual,
|
|
half_life_days=args.half_life,
|
|
full=args.full,
|
|
since=args.since,
|
|
skip=_skip_list(args),
|
|
bookmarks_path=bookmarks,
|
|
write_html=not args.no_html,
|
|
progress=progress,
|
|
)
|
|
if args.json:
|
|
print(json.dumps(result, indent=2) if args.pretty else json.dumps(result))
|
|
return 0
|
|
|
|
stats = result["stats"]
|
|
print(f"{stats['sessions']} sessions ({stats['full']} with transcripts, "
|
|
f"{stats['thin']} history-only) · {stats['edges']} links · "
|
|
f"{stats['clusters']} topics · {stats['unclustered']} unclustered")
|
|
print(f"read {stats['read_this_run']} this run, reused {stats['reused']} cached")
|
|
for cluster in result["clusters"][:15]:
|
|
flag = " [dormant]" if cluster["dormant"] else (" [hot]" if cluster["momentum"] >= 2 else "")
|
|
print(f" {cluster['size']:4} {cluster['name'] or cluster['label']}{flag}")
|
|
if result["unnamed"]:
|
|
print(f"{result['unnamed']} unnamed topic(s) — run the session-brain skill to name them")
|
|
print(f"-> {result['out_dir']}")
|
|
return 0
|
|
|
|
|
|
def cmd_sessions_query(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.session_query import query
|
|
try:
|
|
result = query(
|
|
_brain_dir(args), args.question,
|
|
top_n=args.top, max_load=args.max_load, half_life_days=args.half_life,
|
|
project=args.project, cluster=args.cluster, since=args.since,
|
|
min_score=args.min_score,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if args.json:
|
|
print(json.dumps(result, indent=2) if args.pretty else json.dumps(result))
|
|
return 0
|
|
if not result["candidates"]:
|
|
print("no matching sessions")
|
|
return 0
|
|
for c in result["candidates"]:
|
|
loadable = "" if c["loadable"] else " (no transcript)"
|
|
print(f"{c['score']:.2f} {c['end_ts'][:10]} {c['project'][:18]:18} "
|
|
f"{(c['title'] or '(untitled)')[:52]:52}{loadable}")
|
|
print(f" {c['why']}")
|
|
if result["should_load"]:
|
|
print(f"\nload: {result['load_command']}")
|
|
return 0
|
|
|
|
|
|
def cmd_sessions_show(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.session_query import show
|
|
try:
|
|
result = show(_brain_dir(args), args.session_id, neighbors=args.neighbors)
|
|
except (FileNotFoundError, KeyError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(json.dumps(result, indent=2) if args.pretty else json.dumps(result))
|
|
return 0
|
|
|
|
|
|
def cmd_sessions_clusters(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.session_graph import load_graph
|
|
try:
|
|
_, clusters_doc = load_graph(_brain_dir(args))
|
|
except FileNotFoundError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
clusters = clusters_doc.get("clusters", [])
|
|
if args.unnamed:
|
|
clusters = [c for c in clusters if not c.get("name")]
|
|
clusters = clusters[:args.top]
|
|
if args.json:
|
|
payload = {"clusters": clusters}
|
|
print(json.dumps(payload, indent=2) if args.pretty else json.dumps(payload))
|
|
return 0
|
|
for c in clusters:
|
|
flag = " [dormant]" if c.get("dormant") else (" [hot]" if c.get("momentum", 0) >= 2 else "")
|
|
print(f"{c['id']:3} {c['size']:4} {c.get('name') or c['label']}{flag}")
|
|
print(f" terms: {', '.join(t for t, _ in c['top_terms'][:8])}")
|
|
return 0
|
|
|
|
|
|
def cmd_sessions_name(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.session_graph import set_cluster_names
|
|
raw = sys.stdin.read() if args.from_file == "-" else \
|
|
Path(args.from_file).expanduser().read_text(encoding="utf-8")
|
|
try:
|
|
updates = json.loads(raw)
|
|
except ValueError as exc:
|
|
print(f"error: invalid JSON: {exc}", file=sys.stderr)
|
|
return 1
|
|
if not isinstance(updates, list):
|
|
print('error: expected a JSON array of {"id": N, "name": "...", "summary": "..."}',
|
|
file=sys.stderr)
|
|
return 1
|
|
try:
|
|
result = set_cluster_names(_brain_dir(args), updates)
|
|
except FileNotFoundError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
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_code_understand(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.code_understanding import ProviderError, code_understand
|
|
|
|
project = Path(args.project or os.getcwd())
|
|
try:
|
|
result = code_understand(
|
|
project,
|
|
# "auto" must pass through as None so CODE_UNDERSTANDING_BACKEND can win (flag > env > auto).
|
|
backend_flag=None if args.backend == "auto" else args.backend,
|
|
changed=args.changed,
|
|
since=args.since,
|
|
max_symbols=args.max_symbols,
|
|
)
|
|
except ProviderError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if args.pretty:
|
|
print(f"backend: {result['backend']}")
|
|
print(f"project: {result['project']}")
|
|
print(f"focus map: {len(result['focus_map'])} symbol(s)")
|
|
for item in result["focus_map"]:
|
|
lines = item.get("lines") or []
|
|
span = str(lines[0]) if lines else ""
|
|
if len(lines) > 1:
|
|
span += f"-{lines[-1]}"
|
|
print(
|
|
f" {item.get('rank', '?')}. {item['symbol']} "
|
|
f"({item['kind']}) {item['file']}:{span} [{item.get('evidence', '')}]"
|
|
)
|
|
if result["warnings"]:
|
|
print("warnings:")
|
|
for warning in result["warnings"]:
|
|
print(f" - {warning}")
|
|
else:
|
|
print("warnings: none")
|
|
else:
|
|
print(json.dumps(result, indent=2))
|
|
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 _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 _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:
|
|
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_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)
|
|
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
|
|
if vault_arg is not None
|
|
else _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 _read_env_value(path: Path, key: str) -> tuple[bool, str]:
|
|
if not path.is_file():
|
|
return False, ""
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if line.startswith(f"{key}="):
|
|
return True, line.split("=", 1)[1].strip().strip('"')
|
|
return False, ""
|
|
|
|
|
|
def _resolve_context_pack_vault(vault_arg: str | None) -> Path | None:
|
|
if vault_arg is not None:
|
|
return _resolve_command_vault(vault_arg)
|
|
|
|
current = Path.cwd().resolve()
|
|
home = HOME.resolve()
|
|
while True:
|
|
found, local_vault = _read_env_value(
|
|
current / ".env",
|
|
"OBSIDIAN_VAULT_PATH",
|
|
)
|
|
if found:
|
|
if not local_vault:
|
|
print(
|
|
"error: vault not configured; pass a path or run obsidian-wiki setup",
|
|
file=sys.stderr,
|
|
)
|
|
return None
|
|
return _resolve_command_vault(local_vault)
|
|
if current == home or current.parent == current:
|
|
break
|
|
current = current.parent
|
|
return _resolve_command_vault(None)
|
|
|
|
|
|
def cmd_trust_record(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.trust import (
|
|
TRUST_LEDGER_RELATIVE_PATH,
|
|
build_trust_ledger,
|
|
check_trust_ledger,
|
|
update_trust_ledger,
|
|
write_trust_ledger,
|
|
)
|
|
|
|
context = _resolve_schema_command_context(args.vault)
|
|
if context is None:
|
|
return 1
|
|
vault, config, config_source = context
|
|
try:
|
|
schema = _schema_options(
|
|
args,
|
|
config,
|
|
config_source,
|
|
default_required_trust_fields=("base_confidence", "lifecycle", "updated"),
|
|
)
|
|
except ValueError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
path = vault / TRUST_LEDGER_RELATIVE_PATH
|
|
try:
|
|
if args.all:
|
|
removed_not_applicable: list[str] = []
|
|
if path.is_file():
|
|
previous = check_trust_ledger(
|
|
vault,
|
|
path,
|
|
allowed_lifecycles=schema["allowed_lifecycles"],
|
|
required_trust_keys=schema["required_trust_fields"],
|
|
schema_source=schema["schema_source"],
|
|
)
|
|
removed_not_applicable = sorted(
|
|
item["page"]
|
|
for item in previous["stale"]
|
|
if item.get("reason")
|
|
== "confidence_not_applicable_but_ledger_entry_exists"
|
|
)
|
|
ledger = build_trust_ledger(
|
|
vault,
|
|
reviewed_at=args.reviewed_at,
|
|
allowed_lifecycles=schema["allowed_lifecycles"],
|
|
required_trust_keys=schema["required_trust_fields"],
|
|
)
|
|
ledger["removed_not_applicable"] = removed_not_applicable
|
|
recorded_pages = len(ledger["pages"])
|
|
else:
|
|
ledger = update_trust_ledger(
|
|
vault,
|
|
path,
|
|
reviewed_at=args.reviewed_at,
|
|
page_paths=args.page,
|
|
allowed_lifecycles=schema["allowed_lifecycles"],
|
|
required_trust_keys=schema["required_trust_fields"],
|
|
)
|
|
requested = {
|
|
Path(raw).as_posix().removeprefix("./") for raw in args.page
|
|
}
|
|
recorded_pages = len(requested.intersection(ledger["pages"]))
|
|
write_trust_ledger(path, ledger, vault=vault)
|
|
except (RuntimeError, ValueError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
result = {
|
|
"status": "recorded",
|
|
"ledger_path": str(path),
|
|
"recorded_pages": recorded_pages,
|
|
"not_applicable_pages": list(ledger.get("not_applicable", [])),
|
|
"removed_not_applicable": list(ledger.get("removed_not_applicable", [])),
|
|
"reviewed_at": args.reviewed_at,
|
|
"method": ledger["method"],
|
|
"schema": {
|
|
"source": schema["schema_source"],
|
|
"allowed_lifecycles": sorted(schema["allowed_lifecycles"]),
|
|
"required_trust_fields": list(schema["required_trust_fields"]),
|
|
},
|
|
}
|
|
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}")
|
|
print(
|
|
"not applicable (excluded from trust review): "
|
|
f"{len(result['not_applicable_pages'])} page(s)"
|
|
)
|
|
for page in result["not_applicable_pages"]:
|
|
print(f" - {page}")
|
|
print(
|
|
"obsolete ledger entries removed: "
|
|
f"{len(result['removed_not_applicable'])} page(s)"
|
|
)
|
|
for page in result["removed_not_applicable"]:
|
|
print(f" - {page}")
|
|
if result["removed_not_applicable"]:
|
|
removed = ", ".join(result["removed_not_applicable"])
|
|
print(
|
|
"warning: removed obsolete trust ledger entries because "
|
|
f"base_confidence is not applicable: {removed}",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
def cmd_trust_check(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.trust import check_trust_ledger
|
|
|
|
context = _resolve_schema_command_context(args.vault)
|
|
if context is None:
|
|
return 1
|
|
vault, config, config_source = context
|
|
try:
|
|
schema = _schema_options(
|
|
args,
|
|
config,
|
|
config_source,
|
|
default_required_trust_fields=("base_confidence", "lifecycle", "updated"),
|
|
)
|
|
report = check_trust_ledger(
|
|
vault,
|
|
allowed_lifecycles=schema["allowed_lifecycles"],
|
|
required_trust_keys=schema["required_trust_fields"],
|
|
schema_source=schema["schema_source"],
|
|
)
|
|
except ValueError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
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_context_pack(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.context_pack import ContextError, build_context_pack, render_markdown
|
|
|
|
vault = _resolve_context_pack_vault(args.vault)
|
|
if vault is None:
|
|
return 1
|
|
try:
|
|
pack = build_context_pack(
|
|
vault,
|
|
args.topic or "",
|
|
budget=args.budget,
|
|
recent=args.recent,
|
|
public_only=args.public_only,
|
|
metadata_only=args.metadata_only,
|
|
)
|
|
except ContextError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if args.json:
|
|
print(json.dumps(pack, indent=2 if args.pretty else None))
|
|
else:
|
|
print(render_markdown(pack), end="")
|
|
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)'}")
|
|
ext = extension_dir()
|
|
print(f"extension: {ext if ext else '(not bundled)'}")
|
|
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, 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)
|
|
|
|
sb = sub.add_parser(
|
|
"sessions-build",
|
|
help="build a topic graph over your agent session history (writes a sidecar, not the vault)",
|
|
)
|
|
sb.add_argument("--claude-dir", default=DEFAULT_CLAUDE_DIR,
|
|
help=f"agent session cache to read (default: {DEFAULT_CLAUDE_DIR})")
|
|
sb.add_argument("--out", default=None,
|
|
help=f"output directory (default: $WIKI_SESSION_BRAIN_DIR or {DEFAULT_BRAIN_DIR})")
|
|
sb.add_argument("--k", type=int, default=8, help="neighbours per session (default: 8)")
|
|
sb.add_argument("--min-sim", type=float, default=0.08,
|
|
help="minimum cosine similarity for an edge (default: 0.08)")
|
|
sb.add_argument("--mutual", action="store_true",
|
|
help="keep only mutual kNN edges — tighter, smaller clusters")
|
|
sb.add_argument("--half-life", type=float, default=90.0,
|
|
help="recency half-life in days (default: 90)")
|
|
sb.add_argument("--since", help="only read sessions modified on or after this ISO date")
|
|
sb.add_argument("--skip",
|
|
help="comma-separated substrings of project dirs to skip (or $WIKI_SKIP_PROJECTS). "
|
|
"Cache dir names begin with '-', which argparse reads as a flag — pass the "
|
|
"bare name ('game') or use --skip=-w-game")
|
|
sb.add_argument("--full", action="store_true", help="ignore caches and re-read every session")
|
|
sb.add_argument("--no-html", action="store_true", help="skip writing graph.html")
|
|
sb.add_argument("--bookmarks", help="path to bookmarks.json (default: ~/.bookmark-agent/bookmarks.json)")
|
|
sb.add_argument("--json", action="store_true", help="emit JSON instead of a human summary")
|
|
sb.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
sb.add_argument("-v", "--verbose", action="store_true", help="report progress to stderr")
|
|
sb.set_defaults(func=cmd_sessions_build)
|
|
|
|
sq = sub.add_parser(
|
|
"sessions-query",
|
|
help="find the sessions most relevant to a topic, ranked by similarity and recency",
|
|
)
|
|
sq.add_argument("question", help="topic or question to search for")
|
|
sq.add_argument("--out", default=None, help="session-brain directory")
|
|
sq.add_argument("--top", type=int, default=10, help="candidates to return (default: 10)")
|
|
sq.add_argument("--max-load", type=int, default=3,
|
|
help="max sessions to recommend loading (default: 3)")
|
|
sq.add_argument("--half-life", type=float, default=None,
|
|
help="override the recency half-life used at build time")
|
|
sq.add_argument("--project", help="restrict to one project")
|
|
sq.add_argument("--cluster", type=int, help="restrict to one topic cluster id")
|
|
sq.add_argument("--since", help="only consider sessions ending on or after this ISO date")
|
|
sq.add_argument("--min-score", type=float, default=0.05, help="drop candidates below this score")
|
|
sq.add_argument("--json", action="store_true", help="emit JSON instead of a human summary")
|
|
sq.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
sq.set_defaults(func=cmd_sessions_query)
|
|
|
|
ssh = sub.add_parser(
|
|
"sessions-show",
|
|
help="show one session's graph node and its nearest neighbours",
|
|
)
|
|
ssh.add_argument("session_id", help="session id (full or unique prefix)")
|
|
ssh.add_argument("--out", default=None, help="session-brain directory")
|
|
ssh.add_argument("--neighbors", type=int, default=8, help="neighbours to include (default: 8)")
|
|
ssh.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
ssh.set_defaults(func=cmd_sessions_show)
|
|
|
|
scl = sub.add_parser("sessions-clusters", help="list the discovered topic clusters")
|
|
scl.add_argument("--out", default=None, help="session-brain directory")
|
|
scl.add_argument("--unnamed", action="store_true", help="only clusters that still need a name")
|
|
scl.add_argument("--top", type=int, default=20, help="max clusters to list (default: 20)")
|
|
scl.add_argument("--json", action="store_true", help="emit JSON instead of a human summary")
|
|
scl.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
scl.set_defaults(func=cmd_sessions_clusters)
|
|
|
|
snm = sub.add_parser("sessions-name", help="assign names to topic clusters (durable across rebuilds)")
|
|
snm.add_argument("--out", default=None, help="session-brain directory")
|
|
snm.add_argument("--from", dest="from_file", required=True, metavar="FILE",
|
|
help='JSON array of {"id": N, "name": "...", "summary": "..."}; use - for stdin')
|
|
snm.set_defaults(func=cmd_sessions_name)
|
|
|
|
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)
|
|
|
|
cdu = sub.add_parser(
|
|
"code-understand",
|
|
help="build a ranked code-understanding focus map for a project — CodeGraph when available, builtin AST + rg otherwise",
|
|
)
|
|
cdu.add_argument("--project", default=None, help="project directory (defaults to the current directory)")
|
|
cdu.add_argument(
|
|
"--backend",
|
|
choices=["auto", "builtin", "codegraph"],
|
|
default="auto",
|
|
help="code-understanding backend (default: auto)",
|
|
)
|
|
cdu.add_argument(
|
|
"--changed",
|
|
action="append",
|
|
default=None,
|
|
metavar="FILE",
|
|
help="treat FILE as a seed file (repeatable; overrides --since)",
|
|
)
|
|
cdu.add_argument(
|
|
"--since",
|
|
default=None,
|
|
metavar="SHA",
|
|
help="seed files changed since this git ref",
|
|
)
|
|
cdu.add_argument(
|
|
"--max-symbols",
|
|
type=int,
|
|
default=50,
|
|
help="maximum focus-map entries (default: 50)",
|
|
)
|
|
cdu.add_argument("--pretty", action="store_true", help="print a human-readable summary instead of JSON")
|
|
cdu.set_defaults(func=cmd_code_understand)
|
|
|
|
dr = sub.add_parser(
|
|
"doctor",
|
|
help="check config, vault shape, bootstrap assets, installed skills, and code-understanding readiness",
|
|
)
|
|
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="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)
|
|
|
|
tr = sub.add_parser(
|
|
"trust-record",
|
|
help="record explicitly approved manual confidence reviews in the vault trust ledger",
|
|
)
|
|
tr.add_argument("vault", nargs="?", help="vault path or @name (defaults via CWD .env, then global config)")
|
|
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.add_argument(
|
|
"--allow-lifecycle",
|
|
action="append",
|
|
metavar="VALUE",
|
|
help="extend the resolved vault lifecycle allowlist (repeatable)",
|
|
)
|
|
tr.add_argument(
|
|
"--required-trust-field",
|
|
action="append",
|
|
choices=("base_confidence", "lifecycle", "lifecycle_changed", "updated"),
|
|
help="replace resolved vault trust-field requiredness (repeatable)",
|
|
)
|
|
tr.add_argument("--schema-source", help="authority locator recorded in the result")
|
|
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="vault path or @name (defaults via CWD .env, then global config)")
|
|
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.add_argument(
|
|
"--allow-lifecycle",
|
|
action="append",
|
|
metavar="VALUE",
|
|
help="extend the framework lifecycle allowlist (repeatable)",
|
|
)
|
|
tc.add_argument(
|
|
"--required-trust-field",
|
|
action="append",
|
|
choices=("base_confidence", "lifecycle", "lifecycle_changed", "updated"),
|
|
help="replace default trust-field requiredness (repeatable)",
|
|
)
|
|
tc.add_argument(
|
|
"--schema-source",
|
|
help="authority locator recorded in the trust report",
|
|
)
|
|
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)
|
|
|
|
cp = sub.add_parser(
|
|
"context-pack",
|
|
aliases=["context"],
|
|
help="compile a token-bounded vault slice for a downstream agent",
|
|
)
|
|
cp.add_argument("topic", nargs="?", help="topic to retrieve; omit only with --recent")
|
|
cp.add_argument("--vault", help="override OBSIDIAN_VAULT_PATH")
|
|
cp.add_argument(
|
|
"--budget",
|
|
type=int,
|
|
default=8_000,
|
|
help="maximum estimated output tokens, 256..100000 (default: 8000)",
|
|
)
|
|
cp.add_argument("--recent", action="store_true", help="select recently updated notes")
|
|
cp.add_argument(
|
|
"--public-only",
|
|
action="store_true",
|
|
help="exclude visibility/internal and visibility/pii notes",
|
|
)
|
|
cp.add_argument(
|
|
"--metadata-only",
|
|
action="store_true",
|
|
help="emit titles, provenance, and summaries without body excerpts",
|
|
)
|
|
cp.add_argument("--json", action="store_true", help="emit structured JSON")
|
|
cp.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
|
|
cp.set_defaults(func=cmd_context_pack)
|
|
|
|
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())
|