"""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.business_facts import ( business_schema, business_db_path_for_vault, compute_incremental_start_modifytime, execute_business_sql, execute_business_template, fetch_kingdee_payload, load_kingdee_payload_from_json, query_business_question, sync_kingdee_orders, ) 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, ) from obsidian_wiki.kingdee_api import ( build_sales_order_filters, ensure_kingdee_config, get_kingdee_access_token, load_kingdee_config, token_cache_path_for_config, ) 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 /_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 _resolve_business_db(args: argparse.Namespace, vault_path: str) -> Path: raw = ( getattr(args, "db", None) or os.environ.get("OBSIDIAN_BUSINESS_FACTS_DB") or _read_config_value("OBSIDIAN_BUSINESS_FACTS_DB") ) if not raw: return business_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_business_sync_kingdee(args: argparse.Namespace) -> int: vault_path = _require_vault(args) db_path = _resolve_business_db(args, vault_path) filters: dict[str, object] = {} cursor_from = args.since if args.input: records, payload = load_kingdee_payload_from_json(args.input) else: config_path = getattr(args, "config", None) created = ensure_kingdee_config(config_path) overrides = _kingdee_cli_overrides(args) config = load_kingdee_config(config_path) config.update(overrides) has_official_token_credentials = all( config.get(key) for key in ("KINGDEE_CLIENT_ID", "KINGDEE_CLIENT_SECRET", "KINGDEE_USERNAME", "KINGDEE_ACCOUNT_ID") ) if created and not (config.get("KINGDEE_ACCESS_TOKEN") or config.get("KINGDEE_TOKEN_BODY_JSON") or has_official_token_credentials): raise RuntimeError(f"Kingdee config template created at {Path(config_path or '.runtime/kingdee.env').expanduser()}; fill it and rerun") if args.dry_run_auth: token = get_kingdee_access_token(config, cache_path=token_cache_path_for_config(config_path)) print(f"✅ Kingdee auth OK: token length {len(token)}") return 0 filters = build_sales_order_filters(args) if getattr(args, "incremental", False) and not filters.get("start_modifytime"): filters["start_modifytime"] = compute_incremental_start_modifytime( db_path, lookback_hours=args.lookback_hours, initial_days=args.initial_days, ) cursor_from = str(filters.get("start_modifytime") or args.since or "") records, payload = fetch_kingdee_payload( api_url=args.api_url, token=args.token or args.access_token, since=args.since, config_path=config_path, config_overrides=overrides, filters=filters, page_size=args.page_size, max_pages=args.max_pages, ) result = sync_kingdee_orders( vault_path, records, source_payload=payload, db_path=db_path, cursor_from=cursor_from, ) result["filters"] = filters if args.json: print(json.dumps(result, ensure_ascii=False, indent=2)) else: print(f"✅ Synced Kingdee orders: {result['records_upserted']} record(s)") print(f" DB: {result['db_path']}") print(f" Last sync: {result['synced_at']}") if filters: print(f" Filters: {json.dumps(filters, ensure_ascii=False, sort_keys=True)}") return 0 def cmd_business_config_kingdee(args: argparse.Namespace) -> int: config_path = Path(args.config or ".runtime/kingdee.env").expanduser() created = ensure_kingdee_config(config_path) state = "created" if created else "exists" print(f"✅ Kingdee config {state}: {config_path}") return 0 def _kingdee_cli_overrides(args: argparse.Namespace) -> dict[str, str]: overrides: dict[str, str] = {} if getattr(args, "api_url", None): overrides["KINGDEE_SALORDER_API_URL"] = args.api_url if getattr(args, "token", None): overrides["KINGDEE_ACCESS_TOKEN"] = args.token if getattr(args, "access_token", None): overrides["KINGDEE_ACCESS_TOKEN"] = args.access_token if getattr(args, "identity", None): overrides["KINGDEE_ACGW_IDENTITY"] = args.identity if getattr(args, "page_size", None): overrides["KINGDEE_PAGE_SIZE"] = str(args.page_size) return overrides def cmd_business_query(args: argparse.Namespace) -> int: vault_path = _require_vault(args) question = " ".join(args.question).strip() result = query_business_question( vault_path, question, db_path=_resolve_business_db(args, vault_path), all_staff=args.all_staff, fallback_sql=args.fallback_sql, ) if args.json: print(json.dumps(result, ensure_ascii=False, indent=2)) else: print(result["answer"]) return 0 def cmd_business_schema(args: argparse.Namespace) -> int: schema = business_schema() if args.json: print(json.dumps(schema, ensure_ascii=False, indent=2)) else: print("Business facts schema") print("Tables:") for table in schema["tables"]: print(f" - {table['name']}: {', '.join(table['fields'])}") print("Templates:") for template in schema["templates"]: required = ", ".join(template["required_parameters"]) or "none" print(f" - {template['name']}: {template['description']} (required: {required})") return 0 def cmd_business_template(args: argparse.Namespace) -> int: vault_path = _require_vault(args) params = _business_template_params(args) result = execute_business_template( _resolve_business_db(args, vault_path), args.template_name, **params, ) if args.json: print(json.dumps(result, ensure_ascii=False, indent=2)) else: print(result["answer"]) for row in result.get("rows", []): print(json.dumps(row, ensure_ascii=False, sort_keys=True)) return 0 def _business_template_params(args: argparse.Namespace) -> dict[str, object]: keys = ( "year", "month", "start_date", "end_date", "end_date_exclusive", "limit", "granularity", "staff_role_scope", "customer", "product", "staff", "org", "department", "project", "warehouse", "billstatus", "orderstatus", "closestatus", "currency", ) return {key: getattr(args, key) for key in keys if getattr(args, key, None) not in (None, "")} def cmd_business_sql(args: argparse.Namespace) -> int: vault_path = _require_vault(args) sql = " ".join(args.sql).strip() result = execute_business_sql( _resolve_business_db(args, vault_path), sql, limit=args.limit, ) if args.json: print(json.dumps(result, ensure_ascii=False, indent=2)) else: print(result["answer"]) for row in result["rows"]: print(json.dumps(row, ensure_ascii=False, sort_keys=True)) 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) bp = sub.add_parser("business", help="sync and query structured business facts") business_sub = bp.add_subparsers(dest="business_command") bcp = business_sub.add_parser("config", help="create local business integration config files") business_config_sub = bcp.add_subparsers(dest="business_config_target") bckp = business_config_sub.add_parser("kingdee", help="create a local Kingdee API config template") bckp.add_argument("--config", metavar="PATH", help="Kingdee config path (defaults to .runtime/kingdee.env)") bckp.set_defaults(func=cmd_business_config_kingdee) bsp = business_sub.add_parser("sync", help="sync business facts from an external system") business_sync_sub = bsp.add_subparsers(dest="business_sync_source") bkp = business_sync_sub.add_parser("kingdee", help="sync Kingdee sales orders") _add_vault_arg(bkp) bkp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to /_meta/business-facts.sqlite)") bkp.add_argument("--input", metavar="PATH", help="JSON payload exported from or fetched from Kingdee") bkp.add_argument("--config", metavar="PATH", help="Kingdee config path (defaults to .runtime/kingdee.env)") bkp.add_argument("--api-url", help="Kingdee sales order API URL; defaults to KINGDEE_SALORDER_API_URL") bkp.add_argument("--token", help="direct access token override; compatibility alias for --access-token") bkp.add_argument("--access-token", help="direct access token override") bkp.add_argument("--identity", help="x-acgw-identity override") bkp.add_argument("--since", help="optional updated_since cursor passed to the API and sync log") bkp.add_argument("--start-bizdate") bkp.add_argument("--end-bizdate") bkp.add_argument("--start-createtime") bkp.add_argument("--end-createtime") bkp.add_argument("--start-modifytime") bkp.add_argument("--end-modifytime") bkp.add_argument("--start-auditdate") bkp.add_argument("--end-auditdate") bkp.add_argument("--id", action="append", help="sales order ID; repeat or comma-separate") bkp.add_argument("--billno", action="append", help="sales order bill number; repeat or comma-separate") bkp.add_argument("--billstatus", action="append", help="A/B/C status; repeat or comma-separate") bkp.add_argument("--org-number", action="append", help="sales org code; repeat or comma-separate") bkp.add_argument("--customer-number", action="append", help="customer code; repeat or comma-separate") bkp.add_argument("--customer-createorg-number", action="append", help="customer create org code; repeat or comma-separate") bkp.add_argument("--page-size", type=int, help="Kingdee pageSize") bkp.add_argument("--max-pages", type=int, default=100, help="maximum pages to fetch") bkp.add_argument("--incremental", action="store_true", help="use saved modifytime cursor with a lookback window") bkp.add_argument("--lookback-hours", type=int, default=48, help="hours to subtract from the saved incremental cursor") bkp.add_argument("--initial-days", type=int, default=7, help="days to fetch on the first incremental sync") bkp.add_argument("--dry-run-auth", action="store_true", help="fetch/refresh access token and exit without syncing orders") bkp.add_argument("--json", action="store_true", help="print machine-readable sync result") bkp.set_defaults(func=cmd_business_sync_kingdee) bqp = business_sub.add_parser("query", help="answer fixed-template business statistics questions") _add_vault_arg(bqp) bqp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to /_meta/business-facts.sqlite)") bqp.add_argument("--all-staff", action="store_true", help="include secondary sales and follow-up roles in staff queries") bqp.add_argument("--fallback-sql", help="guarded read-only SQL to run only when no fixed template matches") bqp.add_argument("--json", action="store_true", help="print machine-readable query result") bqp.add_argument("question", nargs="+", help="question, e.g. '2026-06 order count'") bqp.set_defaults(func=cmd_business_query) bsch = business_sub.add_parser("schema", help="print business fact tables, templates, and SQL guardrails") bsch.add_argument("--json", action="store_true", help="print machine-readable schema") bsch.set_defaults(func=cmd_business_schema) btp = business_sub.add_parser("template", help="run one business query template with explicit parameters") _add_vault_arg(btp) btp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to /_meta/business-facts.sqlite)") btp.add_argument("--year", type=int, help="calendar year, e.g. 2026") btp.add_argument("--month", type=int, help="calendar month, 1-12") btp.add_argument("--start-date", help="inclusive date, YYYY-MM-DD") btp.add_argument("--end-date", help="exclusive date, YYYY-MM-DD") btp.add_argument("--end-date-exclusive", help="explicit exclusive end date, YYYY-MM-DD") btp.add_argument("--limit", type=int, default=10, help="maximum rows for list/ranking templates") btp.add_argument("--granularity", choices=("day", "month"), default="month", help="trend grouping") btp.add_argument( "--staff-role-scope", choices=("primary", "all"), default="primary", help="primary sales only by default; all includes secondary sales and follow-up roles", ) btp.add_argument("--customer", help="customer name or code filter") btp.add_argument("--product", help="product/material name, code, or model filter") btp.add_argument("--staff", help="staff name or code filter") btp.add_argument("--org", help="sales organization name or code filter") btp.add_argument("--department", help="sales department name or code filter") btp.add_argument("--project", help="project name or code filter") btp.add_argument("--warehouse", help="warehouse name or code filter") btp.add_argument("--billstatus", help="Kingdee billstatus filter") btp.add_argument("--orderstatus", help="Kingdee orderstatus filter") btp.add_argument("--closestatus", help="Kingdee closestatus filter") btp.add_argument("--currency", help="currency code filter") btp.add_argument("--json", action="store_true", help="print machine-readable template result") btp.add_argument("template_name", help="template name from `business schema`") btp.set_defaults(func=cmd_business_template) bsp_sql = business_sub.add_parser("sql", help="run guarded read-only SQL against structured business facts") _add_vault_arg(bsp_sql) bsp_sql.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to /_meta/business-facts.sqlite)") bsp_sql.add_argument("--limit", type=int, default=100, help="maximum rows to return, capped at 1000") bsp_sql.add_argument("--json", action="store_true", help="print machine-readable SQL result") bsp_sql.add_argument("sql", nargs="+", help="single SELECT or WITH ... SELECT statement") bsp_sql.set_defaults(func=cmd_business_sql) 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 /_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())