Files
obsidian-wiki/obsidian_wiki/cli.py
T
Codex 4c5454da90 feat(business): exclude intercompany transfer orders
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)
2026-07-10 05:40:54 +00:00

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())