Files
obsidian-wiki/obsidian_wiki/cli.py
T
nac 777607dc29 chore(repo): establish project baseline
Track the source-of-truth code, skills, bootstrap rules, docs, and packaging files before adding new features.

Exclude local vault config, generated skill mirrors, temporary exports, virtual environments, and business data files from version control.

Validation: inspected staged file list and git ignored status before commit.
2026-07-05 11:03:40 +00:00

651 lines
26 KiB
Python

"""obsidian-wiki installer CLI.
Python port of ``setup.sh`` for the pip-installed package. The skill content
lives inside the installed package (``obsidian_wiki/_data/skills``) instead of a
cloned repo, so this wires the bundled skills into every supported AI agent's
skills directory and writes ``~/.obsidian-wiki/config`` so the skills resolve
the vault from any project.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from pathlib import Path
from obsidian_wiki import __version__
from obsidian_wiki.access_control import audit_profile, export_profile, list_profiles
from obsidian_wiki.file_assets import (
clean_config_value,
db_path_for_vault,
format_results_table,
init_db,
rebuild_from_registry,
search_assets,
stage_files,
)
HOME = Path.home()
GLOBAL_CONFIG_DIR = HOME / ".obsidian-wiki"
GLOBAL_CONFIG = GLOBAL_CONFIG_DIR / "config"
# Skills usable from any project (no vault context needed beyond the global
# config). These are also installed globally for agents that only scope skills
# per-project, so cross-project sync/query work everywhere.
PORTABLE_SKILLS = ("wiki-update", "wiki-query")
# ── Data resolution ──────────────────────────────────────────────────────────
# Works for both a built wheel (data under <pkg>/_data) and an editable/source
# checkout (data at the repo root next to the package).
def _pkg_dir() -> Path:
return Path(__file__).resolve().parent
def skills_dir() -> Path:
"""Return the directory holding the bundled skill folders."""
for cand in (_pkg_dir() / "_data" / "skills", _pkg_dir().parent / ".skills"):
if cand.is_dir():
return cand
raise FileNotFoundError(
"Could not locate bundled skills. Reinstall obsidian-wiki "
"(`pip install --force-reinstall obsidian-wiki`)."
)
def bootstrap_dir() -> Path | None:
"""Return the directory containing agent bootstrap context files.
For a wheel this is ``_data/bootstrap``; for a source checkout the files are
spread across the repo root, so we return the repo root and resolve each
file via the repo-relative layout in ``_bootstrap_files``.
"""
built = _pkg_dir() / "_data" / "bootstrap"
if built.is_dir():
return built
repo = _pkg_dir().parent
if (repo / "AGENTS.md").is_file():
return repo
return None
def list_skills() -> list[str]:
return sorted(p.name for p in skills_dir().iterdir() if p.is_dir())
# ── Skill installation ───────────────────────────────────────────────────────
def install_skills(
target_dir: Path,
label: str,
*,
subset: tuple[str, ...] | None = None,
mode: str = "symlink",
quiet: bool = False,
) -> int:
"""Install bundled skills into *target_dir*. Returns the count installed."""
src_root = skills_dir()
target_dir.mkdir(parents=True, exist_ok=True)
installed = 0
for skill in sorted(p for p in src_root.iterdir() if p.is_dir()):
name = skill.name
if subset is not None and name not in subset:
continue
link_path = target_dir / name
if link_path.is_symlink() or link_path.is_file():
link_path.unlink()
elif link_path.is_dir():
# A real directory we previously copied here is safe to replace;
# anything else is the user's and we leave it alone.
if (link_path / "SKILL.md").exists():
shutil.rmtree(link_path)
else:
print(f" ⚠️ {link_path} is not a managed skill, skipping")
continue
if mode == "symlink":
link_path.symlink_to(skill, target_is_directory=True)
else: # copy
shutil.copytree(skill, link_path)
if not (link_path / "SKILL.md").exists():
raise RuntimeError(f"broken skill install: {link_path} -> {skill}")
installed += 1
if not quiet:
print(f"✅ Installed {installed} skills → {label}")
return installed
# Agents whose skills directory lives under $HOME. (path-under-home, label,
# subset). All get every skill — pip users have no cloned repo to host
# project-scoped skills, so everything must be globally discoverable.
GLOBAL_AGENT_DIRS: list[tuple[str, str, tuple[str, ...] | None]] = [
(".claude/skills", "~/.claude/skills/ (Claude Code)", None),
(".gemini/skills", "~/.gemini/skills/ (Gemini CLI)", None),
(".gemini/antigravity/skills", "~/.gemini/antigravity/skills/ (Antigravity, legacy)", None),
(".codex/skills", "~/.codex/skills/ (Codex)", None),
(".hermes/skills", "~/.hermes/skills/ (Hermes default)", None),
(".openclaw/skills", "~/.openclaw/skills/ (OpenClaw)", None),
(".copilot/skills", "~/.copilot/skills/ (GitHub Copilot CLI)", None),
(".trae/skills", "~/.trae/skills/ (Trae)", None),
(".trae-cn/skills", "~/.trae-cn/skills/ (Trae CN)", None),
(".kiro/skills", "~/.kiro/skills/ (Kiro CLI)", None),
(".pi/agent/skills", "~/.pi/agent/skills/ (Pi)", None),
(".agents/skills", "~/.agents/skills/ (OpenCode, Aider, Droid, generic)", None),
]
def install_global_skills(mode: str) -> None:
for rel, label, subset in GLOBAL_AGENT_DIRS:
install_skills(HOME / rel, label, subset=subset, mode=mode)
_install_hermes_profiles(mode)
def _install_hermes_profiles(mode: str) -> None:
"""Mirror setup.sh: install into the active and all named Hermes profiles."""
hermes_home = os.environ.get("HERMES_HOME")
handled: set[Path] = set()
if hermes_home:
hp = Path(hermes_home).expanduser()
if hp != HOME / ".hermes":
install_skills(hp / "skills", f"{hp}/skills/ (Hermes active profile)", mode=mode)
handled.add(hp)
profiles = HOME / ".hermes" / "profiles"
if profiles.is_dir():
for prof in sorted(p for p in profiles.iterdir() if p.is_dir()):
if prof in handled:
continue
install_skills(
prof / "skills",
f"~/.hermes/profiles/{prof.name}/skills/ (Hermes profile: {prof.name})",
mode=mode,
)
# ── Project-local install (opt-in) ───────────────────────────────────────────
PROJECT_AGENT_DIRS = [
(".claude/skills", "Claude Code"),
(".cursor/skills", "Cursor"),
(".windsurf/skills", "Windsurf"),
(".agents/skills", "OpenCode / generic"),
(".pi/skills", "Pi"),
(".kiro/skills", "Kiro"),
]
# (bootstrap-relative source path, destination relative to project dir).
# The source path is resolved against bootstrap_dir() for a wheel, or mapped to
# the repo layout for a source checkout (see _resolve_bootstrap_src).
BOOTSTRAP_FILES = [
("AGENTS.md", "AGENTS.md"),
("cursor/rules/obsidian-wiki.mdc", ".cursor/rules/obsidian-wiki.mdc"),
("windsurf/rules/obsidian-wiki.md", ".windsurf/rules/obsidian-wiki.md"),
("kiro/steering/obsidian-wiki.md", ".kiro/steering/obsidian-wiki.md"),
("agent/rules/obsidian-wiki.md", ".agent/rules/obsidian-wiki.md"),
("agent/workflows/obsidian-wiki.md", ".agent/workflows/obsidian-wiki.md"),
("github/copilot-instructions.md", ".github/copilot-instructions.md"),
]
# AGENTS.md aliases created as symlinks within the project (single source).
AGENTS_ALIASES = ("CLAUDE.md", "GEMINI.md", ".hermes.md")
def _resolve_bootstrap_src(boot_root: Path, rel: str) -> Path | None:
"""Resolve a bootstrap source path under a wheel layout or repo layout."""
built = boot_root / rel
if built.exists():
return built
# Source checkout: boot_root is the repo root; files use the repo layout.
repo_rel = {
"AGENTS.md": "AGENTS.md",
"cursor/rules/obsidian-wiki.mdc": ".cursor/rules/obsidian-wiki.mdc",
"windsurf/rules/obsidian-wiki.md": ".windsurf/rules/obsidian-wiki.md",
"kiro/steering/obsidian-wiki.md": ".kiro/steering/obsidian-wiki.md",
"agent/rules/obsidian-wiki.md": ".agent/rules/obsidian-wiki.md",
"agent/workflows/obsidian-wiki.md": ".agent/workflows/obsidian-wiki.md",
"github/copilot-instructions.md": ".github/copilot-instructions.md",
}.get(rel)
if repo_rel and (boot_root / repo_rel).exists():
return boot_root / repo_rel
return None
def install_project(project_dir: Path, mode: str) -> None:
project_dir.mkdir(parents=True, exist_ok=True)
print(f"\n📁 Installing project-local files → {project_dir}")
for rel, _label in PROJECT_AGENT_DIRS:
install_skills(project_dir / rel, f"{rel}/", mode=mode)
boot_root = bootstrap_dir()
if boot_root is None:
print(" ⚠️ Bootstrap files not found in package; skipping context files")
return
for rel, dest in BOOTSTRAP_FILES:
src = _resolve_bootstrap_src(boot_root, rel)
if src is None:
continue
dst = project_dir / dest
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.is_symlink() or dst.exists():
if dst.is_dir() and not dst.is_symlink():
continue
dst.unlink()
shutil.copyfile(src, dst)
print("✅ Installed bootstrap context files (AGENTS.md, rules, workflows)")
# AGENTS.md aliases as relative symlinks (copy fallback for symlink-hostile FS).
for alias in AGENTS_ALIASES:
link = project_dir / alias
if link.is_symlink() or link.exists():
link.unlink()
try:
link.symlink_to("AGENTS.md")
except OSError:
shutil.copyfile(project_dir / "AGENTS.md", link)
print(f"✅ Linked AGENTS.md aliases ({', '.join(AGENTS_ALIASES)})")
# ── Config ───────────────────────────────────────────────────────────────────
def _read_config_value(key: str) -> str:
if not GLOBAL_CONFIG.is_file():
return ""
for line in GLOBAL_CONFIG.read_text().splitlines():
if line.startswith(f"{key}="):
return clean_config_value(line.split("=", 1)[1])
return ""
def resolve_vault_path(cli_vault: str | None) -> str:
if cli_vault:
return os.path.expanduser(clean_config_value(cli_vault))
existing = _read_config_value("OBSIDIAN_VAULT_PATH")
if existing and existing != "/path/to/your/vault":
return existing
if sys.stdin.isatty():
try:
entered = input(" Where is your Obsidian vault? (absolute path): ").strip()
except EOFError:
entered = ""
if entered:
return os.path.expanduser(entered)
return existing
def write_config(vault_path: str) -> None:
GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# OBSIDIAN_WIKI_REPO points at the bundled data root so skills that reference
# framework assets (templates, references) can find them post-install.
repo_root = skills_dir().parent
GLOBAL_CONFIG.write_text(
f'OBSIDIAN_VAULT_PATH="{vault_path}"\n'
f'OBSIDIAN_WIKI_REPO="{repo_root}"\n'
f'OBSIDIAN_WIKI_VERSION="{__version__}"\n'
)
print(f"✅ Global config written to {GLOBAL_CONFIG}")
def _check_stale() -> None:
"""Warn if the installed version doesn't match when setup last ran, or if skills are missing."""
if not GLOBAL_CONFIG.is_file():
print(
f"⚠️ obsidian-wiki {__version__} is installed but setup has never been run.\n"
f" Run: obsidian-wiki setup --vault /path/to/your/vault",
file=sys.stderr,
)
return
setup_version = _read_config_value("OBSIDIAN_WIKI_VERSION")
if setup_version and setup_version != __version__:
print(
f"⚠️ obsidian-wiki upgraded {setup_version} → {__version__} but setup hasn't been re-run.\n"
f" New skills won't be available until you run: obsidian-wiki setup",
file=sys.stderr,
)
return
# Even if the version matches, check that ~/.claude/skills has the full set.
claude_skills_dir = HOME / ".claude" / "skills"
if claude_skills_dir.is_dir():
bundled = set(list_skills())
installed = {p.name for p in claude_skills_dir.iterdir() if p.is_dir()}
missing = bundled - installed
if missing:
print(
f"⚠️ {len(missing)} skill(s) missing from ~/.claude/skills/ "
f"(e.g. {', '.join(sorted(missing)[:3])}{', ...' if len(missing) > 3 else ''}).\n"
f" Run: obsidian-wiki setup",
file=sys.stderr,
)
# ── Commands ─────────────────────────────────────────────────────────────────
def cmd_setup(args: argparse.Namespace) -> int:
mode = "copy" if args.copy else "symlink"
print("\n╔══════════════════════════════════════════════════╗")
print("║ obsidian-wiki — Agent Setup ║")
print("╚══════════════════════════════════════════════════╝\n")
vault_path = resolve_vault_path(args.vault)
write_config(vault_path)
if not vault_path:
print(" → Vault path not set yet. Re-run with `--vault /path/to/vault`")
print(" or edit OBSIDIAN_VAULT_PATH in ~/.obsidian-wiki/config.")
if not args.project_only:
print()
install_global_skills(mode)
if args.project is not None:
project_dir = Path(args.project or os.getcwd()).expanduser().resolve()
install_project(project_dir, mode)
n = len(list_skills())
print("\n───────────────────────────────────────────────────")
print(" Setup complete!\n")
print(f" Skills installed: {n} (mode: {mode})")
if vault_path:
print(f" Vault: {vault_path}")
print("\n Next steps:")
print(" 1. Open a project in your agent")
print(' 2. Say: "set up my wiki"\n')
print(" From any project:")
print(" /wiki-update → sync knowledge into your vault")
print(" /wiki-query → ask questions against your wiki")
print("───────────────────────────────────────────────────\n")
return 0
def cmd_list(args: argparse.Namespace) -> int:
for name in list_skills():
print(name)
return 0
def cmd_info(args: argparse.Namespace) -> int:
bundled = list_skills()
print(f"obsidian-wiki {__version__}")
print(f"skills: {skills_dir()}")
boot = bootstrap_dir()
print(f"bootstrap: {boot if boot else '(not found)'}")
print(f"config: {GLOBAL_CONFIG}{'' if GLOBAL_CONFIG.exists() else ' (not written yet)'}")
if GLOBAL_CONFIG.exists():
vp = _read_config_value("OBSIDIAN_VAULT_PATH")
setup_ver = _read_config_value("OBSIDIAN_WIKI_VERSION")
print(f"vault: {vp or '(unset)'}")
print(f"setup ran: {setup_ver or '(never)'}")
print(f"bundled skills: {len(bundled)}")
print()
print("Agent skill install status:")
bundled_set = set(bundled)
for rel, label, _subset in GLOBAL_AGENT_DIRS:
agent_dir = HOME / rel
if not agent_dir.is_dir():
print(f" {label}: not installed")
continue
installed = {p.name for p in agent_dir.iterdir() if p.is_dir()}
wiki_installed = installed & bundled_set
missing = bundled_set - installed
status = "✅" if not missing else "⚠️ "
print(f" {status} {label}: {len(wiki_installed)}/{len(bundled_set)}", end="")
if missing:
print(f" (run: obsidian-wiki setup)", end="")
print()
_check_stale()
return 0
def _require_vault(args: argparse.Namespace) -> str:
vault_path = resolve_vault_path(getattr(args, "vault", None))
if not vault_path:
raise RuntimeError("vault path is not configured; run `obsidian-wiki setup --vault /path/to/vault`")
return vault_path
def _resolve_file_asset_db(args: argparse.Namespace, vault_path: str) -> Path:
raw = (
getattr(args, "db", None)
or os.environ.get("OBSIDIAN_FILE_ASSET_DB")
or _read_config_value("OBSIDIAN_FILE_ASSET_DB")
)
if not raw:
return db_path_for_vault(vault_path)
db_path = Path(clean_config_value(raw)).expanduser()
if not db_path.is_absolute():
db_path = Path(vault_path).expanduser() / db_path
return db_path
def cmd_file_index_init(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
db_path = _resolve_file_asset_db(args, vault_path)
init_db(db_path)
print(f"✅ File asset index ready: {db_path}")
return 0
def cmd_file_index_rebuild(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
result = rebuild_from_registry(vault_path, db_path=_resolve_file_asset_db(args, vault_path))
print(f"✅ Rebuilt file asset index: {result.indexed} active file(s)")
if result.skipped:
print(f"⚠️ Skipped {result.skipped} malformed registry line(s)")
print(f" DB: {result.db_path}")
print(f" Registry: {result.registry_path}")
return 0
def cmd_file_index_search(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
query = " ".join(args.query).strip()
rows = search_assets(
vault_path,
query,
db_path=_resolve_file_asset_db(args, vault_path),
limit=args.limit,
doc_type=args.doc_type,
source_type=args.source_type,
product_line=args.product_line,
customer=args.customer,
competitor=args.competitor,
status=args.status,
include_staged=not args.active_only,
)
if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=2))
else:
print(format_results_table(rows))
return 0
def cmd_file_index_stage(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
results = stage_files(
vault_path,
args.paths,
db_path=_resolve_file_asset_db(args, vault_path),
project=args.project,
doc_type=args.doc_type,
visibility=args.visibility,
)
for result in results:
print(f"✅ Staged {result.file_id[:19]}… → {result.path}")
if results:
print(f" DB: {results[0].db_path}")
print(" Next: run wiki-ingest to classify/promote the staged source into wiki pages.")
return 0
def cmd_bot_view_list(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
for name in list_profiles(vault_path):
print(name)
return 0
def cmd_bot_view_audit(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
profiles = [args.profile] if args.profile else list_profiles(vault_path)
rows = [audit_profile(vault_path, profile) for profile in profiles]
if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=2))
return 0
print("profile | pages | sources | description")
print("--- | ---: | ---: | ---")
for row in rows:
print(
f"{row['profile']} | {row['pages_allowed']} allowed / {row['pages_blocked']} blocked | "
f"{row['sources_allowed']} allowed / {row['sources_blocked']} blocked | {row['description']}"
)
return 0
def cmd_bot_view_export(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
manifest = export_profile(
vault_path,
args.profile,
args.out,
include_files=args.include_files,
)
print(f"✅ Exported bot view: {args.profile}")
print(f" Pages: {manifest['page_count']}")
print(f" Sources: {manifest['source_file_count']}")
print(f" Out: {Path(args.out).expanduser() / args.profile}")
return 0
# ── Argument parsing ─────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="obsidian-wiki",
description="Install the LLM-Wiki agent skills into your AI coding agents.",
)
p.add_argument("-V", "--version", action="version", version=f"obsidian-wiki {__version__}")
sub = p.add_subparsers(dest="command")
sp = sub.add_parser("setup", help="install skills into your agents and write config (default)")
_add_setup_args(sp)
sp.set_defaults(func=cmd_setup)
lp = sub.add_parser("list", help="list bundled skills")
lp.set_defaults(func=cmd_list)
ip = sub.add_parser("info", help="show install paths, version, and config")
ip.set_defaults(func=cmd_info)
fp = sub.add_parser("file-index", help="manage the SQLite source file asset index")
file_sub = fp.add_subparsers(dest="file_index_command")
fip = file_sub.add_parser("init", help="create the file asset SQLite database")
_add_file_index_storage_args(fip)
fip.set_defaults(func=cmd_file_index_init)
frp = file_sub.add_parser("rebuild", help="rebuild the index from _meta/source-registry.jsonl")
_add_file_index_storage_args(frp)
frp.set_defaults(func=cmd_file_index_rebuild)
fsp = file_sub.add_parser("search", help="search archived or staged source files")
_add_file_index_storage_args(fsp)
fsp.add_argument("query", nargs="*", help="search terms, product code, customer, report number, etc.")
fsp.add_argument("-n", "--limit", type=int, default=10, help="maximum results to show")
fsp.add_argument("--doc-type", help="filter by doc_type, e.g. drawing, test_report, order_data")
fsp.add_argument("--source-type", help="filter by source_type, e.g. pdf, spreadsheet, cad")
fsp.add_argument("--product-line", help="filter by product line text")
fsp.add_argument("--customer", help="filter by customer text")
fsp.add_argument("--competitor", help="filter by competitor text")
fsp.add_argument("--status", help="filter by status: active or staged")
fsp.add_argument("--active-only", action="store_true", help="exclude staged uploads")
fsp.add_argument("--json", action="store_true", help="print full JSON records")
fsp.set_defaults(func=cmd_file_index_search)
ftp = file_sub.add_parser("stage", help="copy files into _sources/inbox and add staged index rows")
_add_file_index_storage_args(ftp)
ftp.add_argument("paths", nargs="+", help="file(s) to stage")
ftp.add_argument("--project", default="misc", help="project namespace for staged files")
ftp.add_argument("--doc-type", default="other", help="initial document type")
ftp.add_argument("--visibility", default="internal", help="initial visibility")
ftp.set_defaults(func=cmd_file_index_stage)
bp = sub.add_parser("bot-view", help="derive isolated chatbot views from the mother vault")
bot_sub = bp.add_subparsers(dest="bot_view_command")
blp = bot_sub.add_parser("list", help="list configured bot profiles")
_add_vault_arg(blp)
blp.set_defaults(func=cmd_bot_view_list)
bap = bot_sub.add_parser("audit", help="show what each bot profile can see")
_add_vault_arg(bap)
bap.add_argument("profile", nargs="?", help="optional profile name; audits all profiles when omitted")
bap.add_argument("--json", action="store_true", help="print machine-readable audit JSON")
bap.set_defaults(func=cmd_bot_view_audit)
bep = bot_sub.add_parser("export", help="export a filtered markdown/source subset for one bot")
_add_vault_arg(bep)
bep.add_argument("profile", help="bot profile name")
bep.add_argument("--out", default="_bot_exports", help="output directory; profile name is appended")
bep.add_argument("--include-files", action="store_true", help="also copy allowed archived source files cited by exported pages")
bep.set_defaults(func=cmd_bot_view_export)
return p
def _add_vault_arg(sp: argparse.ArgumentParser) -> None:
sp.add_argument("--vault", metavar="PATH", help="absolute path to your Obsidian vault")
def _add_file_index_storage_args(sp: argparse.ArgumentParser) -> None:
_add_vault_arg(sp)
sp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to <vault>/_meta/file-assets.sqlite)")
def _add_setup_args(sp: argparse.ArgumentParser) -> None:
_add_vault_arg(sp)
sp.add_argument(
"--project",
nargs="?",
const="",
default=None,
metavar="DIR",
help="also install project-local skills + bootstrap files into DIR "
"(defaults to the current directory if no DIR given)",
)
sp.add_argument(
"--project-only",
action="store_true",
help="skip the global agent install (use with --project)",
)
sp.add_argument(
"--copy",
action="store_true",
help="copy skill files instead of symlinking to the installed package",
)
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
argv = list(sys.argv[1:] if argv is None else argv)
# No subcommand → default to `setup` (the common case).
if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help", "-V", "--version")):
argv = ["setup", *argv]
args = parser.parse_args(argv)
if not getattr(args, "func", None):
parser.print_help()
return 0
# Warn about stale installs on every command except `setup` (which fixes it)
# and `info` (which calls _check_stale itself with richer output).
if getattr(args, "command", None) not in ("setup", "info", "file-index", "bot-view", 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())