mirror of
https://github.com/Ar9av/obsidian-wiki.git
synced 2026-09-14 20:36:34 +08:00
7620af79ff
Windows agent installs can contain directory junctions, and standard accounts often cannot create new symbolic links. Detect link-like entries safely, remove junctions without traversing their targets, fall back to copied skills only for WinError 1314, and make status output tolerant of GBK consoles. Constraint: Windows users may lack Developer Mode or Create symbolic links privilege Rejected: Require elevation or Developer Mode | blocks standard Windows accounts Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep --copy as the explicit deterministic mode; only WinError 1314 may trigger automatic fallback Tested: pytest -q tests/test_cli_install.py tests/test_ast_extractor.py tests/test_cache.py tests/test_graph_analysis.py tests/test_graphrag.py (196 passed); py_compile; actual obsidian-wiki setup with the target vault (exit 0) Not-tested: Full suite still has unrelated Windows doctor/environment failures Related: #186 #187 #188 Co-authored-by: Chen Li <george_lichne@163.com>
2306 lines
88 KiB
Python
2306 lines
88 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 stat
|
|
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 _is_link_or_junction(path: Path) -> bool:
|
|
"""Return whether *path* is a link, including a Windows directory junction."""
|
|
if path.is_symlink():
|
|
return True
|
|
|
|
is_junction = getattr(path, "is_junction", None)
|
|
if is_junction is not None:
|
|
return bool(is_junction())
|
|
|
|
if os.name != "nt":
|
|
return False
|
|
try:
|
|
file_info = path.lstat()
|
|
except OSError:
|
|
return False
|
|
|
|
reparse_tag = getattr(file_info, "st_reparse_tag", None)
|
|
if reparse_tag is not None:
|
|
return reparse_tag == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
|
|
|
|
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x0400)
|
|
return bool(getattr(file_info, "st_file_attributes", 0) & reparse_flag)
|
|
|
|
|
|
def _is_symlink_privilege_error(error: OSError) -> bool:
|
|
"""Return whether Windows rejected link creation for missing privilege."""
|
|
return os.name == "nt" and getattr(error, "winerror", None) == 1314
|
|
|
|
|
|
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
|
|
install_mode = mode
|
|
warned_copy_fallback = False
|
|
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 _is_link_or_junction(link_path) 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 install_mode == "symlink":
|
|
try:
|
|
link_path.symlink_to(skill, target_is_directory=True)
|
|
except OSError as error:
|
|
if not _is_symlink_privilege_error(error):
|
|
raise
|
|
install_mode = "copy"
|
|
if not warned_copy_fallback:
|
|
print(
|
|
"Warning: symbolic links are unavailable; "
|
|
"copying skills instead. Use Developer Mode or "
|
|
"--copy to choose this explicitly."
|
|
)
|
|
warned_copy_fallback = True
|
|
shutil.copytree(skill, link_path)
|
|
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 _configure_console_output() -> None:
|
|
"""Keep status output from aborting when a console encoding lacks Unicode."""
|
|
for stream in (sys.stdout, sys.stderr):
|
|
reconfigure = getattr(stream, "reconfigure", None)
|
|
if reconfigure is None:
|
|
continue
|
|
try:
|
|
reconfigure(errors="replace")
|
|
except (OSError, ValueError):
|
|
continue
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
_configure_console_output()
|
|
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())
|