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.
651 lines
26 KiB
Python
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())
|