Add Streamable HTTP transport for the source-file MCP server with bearer-token auth, configurable HTTP runtime options, and user-level systemd service helpers. Document remote client setup and cover auth/service behavior with unit tests.
697 lines
28 KiB
Python
697 lines
28 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,
|
|
read_only=True,
|
|
)
|
|
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_mcp_server(args: argparse.Namespace) -> int:
|
|
from obsidian_wiki.mcp_server import main as mcp_main
|
|
|
|
argv: list[str] = []
|
|
if args.vault:
|
|
argv.extend(["--vault", args.vault])
|
|
if args.db:
|
|
argv.extend(["--db", args.db])
|
|
if args.profile:
|
|
argv.extend(["--profile", args.profile])
|
|
if args.max_file_bytes is not None:
|
|
argv.extend(["--max-file-bytes", str(args.max_file_bytes)])
|
|
if args.expose_paths:
|
|
argv.append("--expose-paths")
|
|
if args.transport:
|
|
argv.extend(["--transport", args.transport])
|
|
if args.host:
|
|
argv.extend(["--host", args.host])
|
|
if args.port is not None:
|
|
argv.extend(["--port", str(args.port)])
|
|
if args.path:
|
|
argv.extend(["--path", args.path])
|
|
if args.token_env:
|
|
argv.extend(["--token-env", args.token_env])
|
|
for allowed_host in args.allowed_hosts or []:
|
|
argv.extend(["--allowed-host", allowed_host])
|
|
for allowed_origin in args.allowed_origins or []:
|
|
argv.extend(["--allowed-origin", allowed_origin])
|
|
return mcp_main(argv)
|
|
|
|
|
|
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)
|
|
|
|
mp = sub.add_parser("mcp-server", help="run the MCP server for safe source-file lookup")
|
|
_add_file_index_storage_args(mp)
|
|
mp.add_argument("--profile", help="bot profile name for ACL checks")
|
|
mp.add_argument("--max-file-bytes", type=int, help="maximum resource size, 0 disables the limit")
|
|
mp.add_argument("--expose-paths", action="store_true", help="include archive/absolute paths in tool output")
|
|
mp.add_argument("--transport", choices=["stdio", "streamable-http"], help="MCP transport")
|
|
mp.add_argument("--host", help="HTTP bind host. Used only with --transport streamable-http")
|
|
mp.add_argument("--port", type=int, help="HTTP port. Used only with --transport streamable-http")
|
|
mp.add_argument("--path", help="Streamable HTTP MCP path")
|
|
mp.add_argument("--token-env", help="Environment variable containing the HTTP bearer token")
|
|
mp.add_argument("--allowed-host", action="append", dest="allowed_hosts", help="allowed HTTP Host pattern")
|
|
mp.add_argument("--allowed-origin", action="append", dest="allowed_origins", help="allowed Origin pattern")
|
|
mp.set_defaults(func=cmd_mcp_server)
|
|
|
|
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())
|