Allow operators to configure internal company names and identifiers, then apply the filter consistently across business templates and query routing. Preserve direct external-customer orders and provide explicit CLI and environment overrides for internal-flow audits. Validation: python -m unittest tests.test_business_facts tests.test_wiki_skills tests.test_user_ops -v (25 tests)
1054 lines
45 KiB
Python
1054 lines
45 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.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.config import load_config
|
|
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 <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 _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,
|
|
business_params=_business_internal_params(args),
|
|
)
|
|
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",
|
|
)
|
|
params = {key: getattr(args, key) for key in keys if getattr(args, key, None) not in (None, "", [])}
|
|
params.update({key: value for key, value in _business_internal_params(args).items() if key not in params})
|
|
return params
|
|
|
|
|
|
def _business_internal_params(args: argparse.Namespace) -> dict[str, object]:
|
|
params: dict[str, object] = {}
|
|
if getattr(args, "internal_parties", None):
|
|
params["internal_parties"] = getattr(args, "internal_parties")
|
|
if getattr(args, "internal_party_ids", None):
|
|
params["internal_party_ids"] = getattr(args, "internal_party_ids")
|
|
if getattr(args, "include_internal_orders", False):
|
|
params["include_internal_orders"] = True
|
|
config = load_config(Path.cwd())
|
|
if not params.get("internal_parties"):
|
|
parties = os.environ.get("OBSIDIAN_BUSINESS_INTERNAL_PARTIES") or config.get("OBSIDIAN_BUSINESS_INTERNAL_PARTIES")
|
|
if parties:
|
|
params["internal_parties"] = parties
|
|
if not params.get("internal_party_ids"):
|
|
party_ids = os.environ.get("OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS") or config.get("OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS")
|
|
if party_ids:
|
|
params["internal_party_ids"] = party_ids
|
|
include_internal = os.environ.get("OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS") or config.get("OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS")
|
|
if include_internal and not params.get("include_internal_orders"):
|
|
params["include_internal_orders"] = include_internal
|
|
return params
|
|
|
|
|
|
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 <vault>/_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 <vault>/_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(
|
|
"--internal-party",
|
|
dest="internal_parties",
|
|
action="append",
|
|
help="internal company name substring to exclude intercompany transfer orders; repeat or comma-separate",
|
|
)
|
|
bqp.add_argument(
|
|
"--internal-party-id",
|
|
dest="internal_party_ids",
|
|
action="append",
|
|
help="internal org/customer code to exclude intercompany transfer orders; repeat or comma-separate",
|
|
)
|
|
bqp.add_argument("--include-internal-orders", action="store_true", help="include configured internal transfer orders")
|
|
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 <vault>/_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(
|
|
"--internal-party",
|
|
dest="internal_parties",
|
|
action="append",
|
|
help="internal company name substring to exclude intercompany transfer orders; repeat or comma-separate",
|
|
)
|
|
btp.add_argument(
|
|
"--internal-party-id",
|
|
dest="internal_party_ids",
|
|
action="append",
|
|
help="internal org/customer code to exclude intercompany transfer orders; repeat or comma-separate",
|
|
)
|
|
btp.add_argument("--include-internal-orders", action="store_true", help="include configured internal transfer orders")
|
|
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 <vault>/_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 <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())
|