Expose a business schema catalog and explicit template runner for Kingdee sales-order facts. Update wiki-query guidance so the LLM routes operational questions through templates first and guarded SQL only when needed. Refresh user docs and ops aliases, and add coverage for schema metadata, template execution, and CLI JSON output. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check
1001 lines
42 KiB
Python
1001 lines
42 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.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,
|
|
)
|
|
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 <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("--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("--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())
|