From b85c26e0bad8d5642936f120d6ea95b32381e797 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 21:21:14 +0000 Subject: [PATCH] feat(cli): add unified user operations entrypoint Add scripts/obsidian-wiki-ops.py as the repo-local command surface for common wiki, Kingdee, timer, daily update, and MCP operations. Route the existing business and MCP shell wrappers through the unified entrypoint for compatibility, and update docs to recommend the single command path. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check. --- README.md | 77 ++++++++------- SETUP.md | 24 ++--- docs/business-facts.md | 36 +++---- scripts/business_sync_service.sh | 2 +- scripts/manage_business_sync.py | 5 +- scripts/manage_mcp_service.py | 5 +- scripts/mcp_service.sh | 2 +- scripts/obsidian-wiki-ops.py | 144 ++++++++++++++++++++++++++++ tests/test_business_sync_service.py | 8 ++ tests/test_mcp_service.py | 6 +- tests/test_user_ops.py | 68 +++++++++++++ 11 files changed, 306 insertions(+), 71 deletions(-) create mode 100755 scripts/obsidian-wiki-ops.py create mode 100644 tests/test_user_ops.py diff --git a/README.md b/README.md index 95cc4b0..3506175 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,15 @@ obsidian-wiki setup --project . # also drop project-local skills + AGENTS.md int obsidian-wiki setup --copy # copy skill files instead of symlinking ``` +When working from a cloned repo, `scripts/obsidian-wiki-ops.py` is the single repo-local operations entrypoint. It wraps common `obsidian-wiki` commands, Kingdee business sync/query, the business sync timer, daily update, and the managed MCP service: + +```bash +scripts/obsidian-wiki-ops.py help +scripts/obsidian-wiki-ops.py kingdee sync --incremental +scripts/obsidian-wiki-ops.py timer install --vault /path/to/vault +scripts/obsidian-wiki-ops.py mcp start +``` + `OBSIDIAN_VAULT_PATH` is just any directory where you want your wiki documents to live — a new empty folder or an existing Obsidian vault. Omit `--vault` to be prompted (or set it later in `~/.obsidian-wiki/config`). ### Install via Skills CLI @@ -320,29 +329,29 @@ Operational counts and amounts belong in a structured fact database, not in mark ```bash # Sync from an exported or API-fetched Kingdee JSON payload. -obsidian-wiki business sync kingdee --input ./kingdee-orders.json +scripts/obsidian-wiki-ops.py kingdee sync --input ./kingdee-orders.json # Or create the private local config for the real Kingdee API/token flow. -obsidian-wiki business config kingdee +scripts/obsidian-wiki-ops.py kingdee config vim .runtime/kingdee.env -obsidian-wiki business sync kingdee --dry-run-auth +scripts/obsidian-wiki-ops.py kingdee auth-test # Then fetch reviewed June orders from Kingdee. -obsidian-wiki business sync kingdee \ +scripts/obsidian-wiki-ops.py kingdee sync \ --start-bizdate 2026-06-01 \ --end-bizdate 2026-06-30 \ --billstatus C # Or fetch incrementally by Kingdee modify time. -obsidian-wiki business sync kingdee --incremental +scripts/obsidian-wiki-ops.py kingdee sync --incremental # Ask deterministic statistics questions. -obsidian-wiki business query "2026年6月接了多少单" -obsidian-wiki business query "2026-06 sales amount" -obsidian-wiki business query "2026-06 top customer" -obsidian-wiki business query "徐丽最近一周订单" -obsidian-wiki business query "徐丽最近一周销售额" --all-staff -obsidian-wiki business sql "SELECT COUNT(*) AS order_count FROM orders" +scripts/obsidian-wiki-ops.py kingdee query "2026年6月接了多少单" +scripts/obsidian-wiki-ops.py kingdee query "2026-06 sales amount" +scripts/obsidian-wiki-ops.py kingdee query "2026-06 top customer" +scripts/obsidian-wiki-ops.py kingdee query "徐丽最近一周订单" +scripts/obsidian-wiki-ops.py kingdee query "徐丽最近一周销售额" --all-staff +scripts/obsidian-wiki-ops.py kingdee sql "SELECT COUNT(*) AS order_count FROM orders" ``` V1 uses fixed templates for monthly order count, monthly sales amount, monthly top customers, customer trend, and salesperson order list/count/amount queries. Salesperson queries default to the main Kingdee salesperson and can include secondary/follow-up roles with `--all-staff`. If no template fits, `business sql` and `business query --fallback-sql` allow guarded read-only SQL against structured tables only; raw JSON and write statements are blocked. Detailed order rows stay in SQLite; the wiki remains for business context, customer/project knowledge, and later summary pages when you choose to generate them. Real Kingdee credentials live in `.runtime/kingdee.env`, and fetched tokens are cached in `.runtime/kingdee-token.json`; both files are local-only and ignored by git. @@ -350,9 +359,9 @@ V1 uses fixed templates for monthly order count, monthly sales amount, monthly t For daily unattended sync on Linux: ```bash -bash scripts/business_sync_service.sh start --vault /path/to/vault -bash scripts/business_sync_service.sh run-now --vault /path/to/vault -bash scripts/business_sync_service.sh status +scripts/obsidian-wiki-ops.py timer install --vault /path/to/vault +scripts/obsidian-wiki-ops.py timer run-now --vault /path/to/vault +scripts/obsidian-wiki-ops.py timer status ``` The timer runs as a user-level systemd timer, defaults to `07:10` daily, and uses `--incremental` with a 48-hour lookback window. @@ -409,15 +418,15 @@ Client configuration example: For long-running access from another machine, use the managed user service: ```bash -bash scripts/mcp_service.sh start +scripts/obsidian-wiki-ops.py mcp start ``` -`mcp_service.sh start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, enabled tool groups, config file path, and client JSON to copy into another agent. When `ingest` is enabled, it also prints the `/mcp-ingest/uploads` file upload endpoint. It serves the configured `OBSIDIAN_VAULT_PATH` by default; pass `--vault /path/to/vault` to override. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet. +`scripts/obsidian-wiki-ops.py mcp start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, enabled tool groups, config file path, and client JSON to copy into another agent. When `ingest` is enabled, it also prints the `/mcp-ingest/uploads` file upload endpoint. It serves the configured `OBSIDIAN_VAULT_PATH` by default; pass `--vault /path/to/vault` to override. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet. Customize the first startup if needed: ```bash -bash scripts/mcp_service.sh start --vault /path/to/vault --host 0.0.0.0 --port 9876 --path /wiki-mcp +scripts/obsidian-wiki-ops.py mcp start --vault /path/to/vault --host 0.0.0.0 --port 9876 --path /wiki-mcp ``` Tool groups are controlled by the same config file: @@ -429,20 +438,20 @@ OBSIDIAN_MCP_ENABLED_TOOLS=file-assets Edit that line directly, or use the service command: ```bash -bash scripts/mcp_service.sh tools list -bash scripts/mcp_service.sh tools enable ingest -bash scripts/mcp_service.sh tools disable ingest -bash scripts/mcp_service.sh tools enable query -bash scripts/mcp_service.sh tools disable query +scripts/obsidian-wiki-ops.py mcp tools list +scripts/obsidian-wiki-ops.py mcp tools enable ingest +scripts/obsidian-wiki-ops.py mcp tools disable ingest +scripts/obsidian-wiki-ops.py mcp tools enable query +scripts/obsidian-wiki-ops.py mcp tools disable query ``` To let a remote MCP client upload files and ask this machine's local OpenCode to run the normal `wiki-ingest` workflow, enable the `ingest` tool group and restart/start the same service: ```bash -bash scripts/mcp_service.sh start --enable-ingest +scripts/obsidian-wiki-ops.py mcp start --enable-ingest ``` -After `ingest` is enabled once, later `bash scripts/mcp_service.sh start` preserves the config file setting. Use `bash scripts/mcp_service.sh start --disable-ingest` or `bash scripts/mcp_service.sh tools disable ingest` to turn it off. +After `ingest` is enabled once, later `scripts/obsidian-wiki-ops.py mcp start` preserves the config file setting. Use `scripts/obsidian-wiki-ops.py mcp start --disable-ingest` or `scripts/obsidian-wiki-ops.py mcp tools disable ingest` to turn it off. This adds write-capable MCP tools to the same `wiki-file-assets` server and bearer token. The server is tool-first: some MCP clients may show empty `resources` or `prompts` lists until a concrete `asset://source/sha256/...` URI is returned by `get_file_asset_metadata`, so use the tool list as the source of truth. @@ -474,10 +483,10 @@ Uploads land under `.runtime/mcp-ingest/uploads/`, outside the Obsidian vault, a To let a remote MCP client ask synthesized questions against the compiled wiki, enable the `query` tool group: ```bash -bash scripts/mcp_service.sh start --enable-query +scripts/obsidian-wiki-ops.py mcp start --enable-query ``` -After `query` is enabled once, later `bash scripts/mcp_service.sh start` preserves it. Use `bash scripts/mcp_service.sh start --disable-query` or `bash scripts/mcp_service.sh tools disable query` to turn it off. +After `query` is enabled once, later `scripts/obsidian-wiki-ops.py mcp start` preserves it. Use `scripts/obsidian-wiki-ops.py mcp start --disable-query` or `scripts/obsidian-wiki-ops.py mcp tools disable query` to turn it off. This adds read-oriented OpenCode-backed query tools to the same `wiki-file-assets` server and bearer token: @@ -492,19 +501,19 @@ The managed service config sets query to `OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=open Rotate the token later with: ```bash -bash scripts/mcp_service.sh new-token +scripts/obsidian-wiki-ops.py mcp new-token ``` Useful service commands: ```bash -bash scripts/mcp_service.sh show-config -bash scripts/mcp_service.sh tools list -bash scripts/mcp_service.sh tools enable ingest -bash scripts/mcp_service.sh tools disable ingest -bash scripts/mcp_service.sh stop -bash scripts/mcp_service.sh uninstall -bash scripts/mcp_service.sh status +scripts/obsidian-wiki-ops.py mcp show-config +scripts/obsidian-wiki-ops.py mcp tools list +scripts/obsidian-wiki-ops.py mcp tools enable ingest +scripts/obsidian-wiki-ops.py mcp tools disable ingest +scripts/obsidian-wiki-ops.py mcp stop +scripts/obsidian-wiki-ops.py mcp uninstall +scripts/obsidian-wiki-ops.py mcp status ``` ### `_raw/` Staging Directory diff --git a/SETUP.md b/SETUP.md index 1dc0204..c8d9bbd 100644 --- a/SETUP.md +++ b/SETUP.md @@ -127,7 +127,7 @@ Knowledge that's project-specific goes under `projects//`. Knowledge that' |---|---|---| | `OBSIDIAN_SOURCES_DIR` | Directories with docs to ingest (comma-separated) | *(empty — point agent at specific files)* | | `OBSIDIAN_BUSINESS_FACTS_DB` | Optional path for structured business facts such as Kingdee orders | `/_meta/business-facts.sqlite` | -| `.runtime/kingdee.env` | Private local Kingdee sales-order API config created by `obsidian-wiki business config kingdee` | official sales-order URL, blank credentials | +| `.runtime/kingdee.env` | Private local Kingdee sales-order API config created by `scripts/obsidian-wiki-ops.py kingdee config` | official sales-order URL, blank credentials | | `OBSIDIAN_CATEGORIES` | Wiki page categories | `concepts,entities,skills,references,synthesis,journal` | | `OBSIDIAN_MAX_PAGES_PER_INGEST` | Max pages updated per ingest | `15` | | `CLAUDE_HISTORY_PATH` | Where to find Claude data | *auto-discovers from `~/.claude`* | @@ -159,25 +159,25 @@ Knowledge that's project-specific goes under `projects//`. Knowledge that' Use the business fact database for exact operational questions, for example monthly order counts or sales amounts. Do not ingest every order line into the wiki. ```bash -obsidian-wiki business sync kingdee --input ./kingdee-orders.json +scripts/obsidian-wiki-ops.py kingdee sync --input ./kingdee-orders.json -obsidian-wiki business config kingdee +scripts/obsidian-wiki-ops.py kingdee config # Fill .runtime/kingdee.env, then test login/refresh without syncing orders: -obsidian-wiki business sync kingdee --dry-run-auth +scripts/obsidian-wiki-ops.py kingdee auth-test -obsidian-wiki business sync kingdee \ +scripts/obsidian-wiki-ops.py kingdee sync \ --start-bizdate 2026-06-01 \ --end-bizdate 2026-06-30 \ --billstatus C -obsidian-wiki business sync kingdee --incremental +scripts/obsidian-wiki-ops.py kingdee sync --incremental -obsidian-wiki business query "2026年6月接了多少单" -obsidian-wiki business query "2026-06 sales amount" -obsidian-wiki business query "徐丽最近一周订单" -obsidian-wiki business query "徐丽最近一周销售额" --all-staff -obsidian-wiki business sql "SELECT COUNT(*) AS order_count FROM orders" +scripts/obsidian-wiki-ops.py kingdee query "2026年6月接了多少单" +scripts/obsidian-wiki-ops.py kingdee query "2026-06 sales amount" +scripts/obsidian-wiki-ops.py kingdee query "徐丽最近一周订单" +scripts/obsidian-wiki-ops.py kingdee query "徐丽最近一周销售额" --all-staff +scripts/obsidian-wiki-ops.py kingdee sql "SELECT COUNT(*) AS order_count FROM orders" -bash scripts/business_sync_service.sh start --vault /path/to/vault +scripts/obsidian-wiki-ops.py timer install --vault /path/to/vault ``` Without `--input`, the Kingdee sync command creates/reads `.runtime/kingdee.env`, obtains or refreshes an access token, calls the official sales order query API, then stores the fetched JSON response in `/_meta/business-facts.sqlite`. The private config supports configurable login/token URL, request body, response field paths, refresh URL/body, and required `x-acgw-identity`; fetched tokens are cached in `.runtime/kingdee-token.json`. `.runtime/` is ignored by git. diff --git a/docs/business-facts.md b/docs/business-facts.md index b335758..f4fab7d 100644 --- a/docs/business-facts.md +++ b/docs/business-facts.md @@ -17,27 +17,27 @@ The wiki remains the place for context: customer background, project history, sa ## Commands ```bash -obsidian-wiki business sync kingdee --input ./kingdee-orders.json +scripts/obsidian-wiki-ops.py kingdee sync --input ./kingdee-orders.json -obsidian-wiki business config kingdee +scripts/obsidian-wiki-ops.py kingdee config # Fill .runtime/kingdee.env, then test the login/refresh flow: -obsidian-wiki business sync kingdee --dry-run-auth +scripts/obsidian-wiki-ops.py kingdee auth-test -obsidian-wiki business sync kingdee \ +scripts/obsidian-wiki-ops.py kingdee sync \ --start-bizdate 2026-06-01 \ --end-bizdate 2026-06-30 \ --billstatus C -obsidian-wiki business sync kingdee --start-modifytime "2026-07-01 00:00:00" -obsidian-wiki business sync kingdee --incremental -obsidian-wiki business query "2026年6月接了多少单" -obsidian-wiki business query "2026-06 sales amount" -obsidian-wiki business query "2026-06 top customer" -obsidian-wiki business query "徐丽最近一周订单" -obsidian-wiki business query "徐丽最近一周接了多少单" -obsidian-wiki business query "徐丽最近一周销售额" --all-staff -obsidian-wiki business query "按组织列出订单" --fallback-sql "SELECT org_name, COUNT(*) AS order_count FROM orders GROUP BY org_name" -obsidian-wiki business sql "SELECT COUNT(*) AS order_count FROM orders" +scripts/obsidian-wiki-ops.py kingdee sync --start-modifytime "2026-07-01 00:00:00" +scripts/obsidian-wiki-ops.py kingdee sync --incremental +scripts/obsidian-wiki-ops.py kingdee query "2026年6月接了多少单" +scripts/obsidian-wiki-ops.py kingdee query "2026-06 sales amount" +scripts/obsidian-wiki-ops.py kingdee query "2026-06 top customer" +scripts/obsidian-wiki-ops.py kingdee query "徐丽最近一周订单" +scripts/obsidian-wiki-ops.py kingdee query "徐丽最近一周接了多少单" +scripts/obsidian-wiki-ops.py kingdee query "徐丽最近一周销售额" --all-staff +scripts/obsidian-wiki-ops.py kingdee query "按组织列出订单" --fallback-sql "SELECT org_name, COUNT(*) AS order_count FROM orders GROUP BY org_name" +scripts/obsidian-wiki-ops.py kingdee sql "SELECT COUNT(*) AS order_count FROM orders" ``` When `--input` is omitted, sync reads `.runtime/kingdee.env`, gets or refreshes an access token, calls the official Kingdee sales order API, and stores the returned rows. If the file does not exist, `business sync kingdee` creates the template and asks you to fill it before retrying. @@ -45,9 +45,9 @@ When `--input` is omitted, sync reads `.runtime/kingdee.env`, gets or refreshes For unattended sync, install the user-level systemd timer: ```bash -bash scripts/business_sync_service.sh start --vault /path/to/vault -bash scripts/business_sync_service.sh run-now --vault /path/to/vault -bash scripts/business_sync_service.sh status +scripts/obsidian-wiki-ops.py timer install --vault /path/to/vault +scripts/obsidian-wiki-ops.py timer run-now --vault /path/to/vault +scripts/obsidian-wiki-ops.py timer status ``` The timer defaults to daily `07:10` with a 15-minute randomized delay. It runs incremental Kingdee sync with a 48-hour lookback window. @@ -115,7 +115,7 @@ If no fixed template matches, callers may provide guarded SQL through `business ## Incremental Sync -`obsidian-wiki business sync kingdee --incremental` uses Kingdee `start_modifytime`. +`scripts/obsidian-wiki-ops.py kingdee sync --incremental` uses Kingdee `start_modifytime`. - First run without a cursor: fetches the last 7 days. - Later runs: starts from the saved cursor minus 48 hours. diff --git a/scripts/business_sync_service.sh b/scripts/business_sync_service.sh index ec1dccf..a148b17 100755 --- a/scripts/business_sync_service.sh +++ b/scripts/business_sync_service.sh @@ -2,4 +2,4 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec python3 "$SCRIPT_DIR/manage_business_sync.py" "$@" +exec python3 "$SCRIPT_DIR/obsidian-wiki-ops.py" timer "$@" diff --git a/scripts/manage_business_sync.py b/scripts/manage_business_sync.py index 7d61320..79c0e58 100644 --- a/scripts/manage_business_sync.py +++ b/scripts/manage_business_sync.py @@ -170,7 +170,10 @@ def start_timer( ) -> str: paths = resolve_service_paths(project_root) if not paths.config_path.exists(): - raise RuntimeError(f"Missing Kingdee config file: {paths.config_path}. Run `obsidian-wiki business config kingdee` first.") + raise RuntimeError( + f"Missing Kingdee config file: {paths.config_path}. " + "Run `scripts/obsidian-wiki-ops.py kingdee config` first." + ) resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path) write_units( paths, diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index 2ff0695..8409a25 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -289,7 +289,10 @@ def write_token_env_file( def read_service_env_file(env_path: Path) -> dict[str, str]: if not env_path.exists(): - raise RuntimeError(f"Missing MCP service env file: {env_path}. Run scripts/mcp_service.sh start to create it.") + raise RuntimeError( + f"Missing MCP service env file: {env_path}. " + "Run `scripts/obsidian-wiki-ops.py mcp start` to create it." + ) values: dict[str, str] = {} for raw_line in env_path.read_text(encoding="utf-8").splitlines(): diff --git a/scripts/mcp_service.sh b/scripts/mcp_service.sh index 8792009..8852962 100755 --- a/scripts/mcp_service.sh +++ b/scripts/mcp_service.sh @@ -2,4 +2,4 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec python3 "$SCRIPT_DIR/manage_mcp_service.py" "$@" +exec python3 "$SCRIPT_DIR/obsidian-wiki-ops.py" mcp "$@" diff --git a/scripts/obsidian-wiki-ops.py b/scripts/obsidian-wiki-ops.py new file mode 100755 index 0000000..80a700b --- /dev/null +++ b/scripts/obsidian-wiki-ops.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Single user-facing operations entrypoint for this repository.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import Sequence + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent + +for path in (REPO_ROOT, SCRIPT_DIR): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from obsidian_wiki.cli import main as obsidian_cli_main + +import manage_business_sync +import manage_mcp_service + + +HELP_TEXT = """obsidian-wiki user operations + +Usage: + scripts/obsidian-wiki-ops.py help + + scripts/obsidian-wiki-ops.py setup [args...] + scripts/obsidian-wiki-ops.py info + scripts/obsidian-wiki-ops.py file-index rebuild [args...] + + scripts/obsidian-wiki-ops.py kingdee config [args...] + scripts/obsidian-wiki-ops.py kingdee auth-test [args...] + scripts/obsidian-wiki-ops.py kingdee sync [args...] + scripts/obsidian-wiki-ops.py kingdee query "2026年6月接了多少单" [args...] + scripts/obsidian-wiki-ops.py kingdee sql "SELECT COUNT(*) FROM orders" [args...] + + scripts/obsidian-wiki-ops.py timer install [args...] + scripts/obsidian-wiki-ops.py timer run-now [args...] + scripts/obsidian-wiki-ops.py timer status + scripts/obsidian-wiki-ops.py timer stop + scripts/obsidian-wiki-ops.py timer uninstall [args...] + + scripts/obsidian-wiki-ops.py mcp start [args...] + scripts/obsidian-wiki-ops.py mcp tools list + scripts/obsidian-wiki-ops.py mcp status + scripts/obsidian-wiki-ops.py mcp stop + scripts/obsidian-wiki-ops.py mcp uninstall + + scripts/obsidian-wiki-ops.py daily-update [args...] + +Notes: + timer install is an alias for installing and starting the Kingdee business sync timer. + Use --help after any forwarded command to see that command's detailed options. +""" + + +CLI_COMMANDS = { + "setup", + "list", + "info", + "file-index", + "business", + "mcp-server", + "bot-view", +} + + +def print_help() -> int: + print(HELP_TEXT, end="") + return 0 + + +def run_script(script_name: str, args: Sequence[str]) -> int: + script = SCRIPT_DIR / script_name + completed = subprocess.run([str(script), *args], check=False) + return int(completed.returncode) + + +def route_kingdee(args: Sequence[str]) -> int: + if not args: + return print_help() + action, *rest = args + if action == "config": + return obsidian_cli_main(["business", "config", "kingdee", *rest]) + if action == "auth-test": + return obsidian_cli_main(["business", "sync", "kingdee", "--dry-run-auth", *rest]) + if action == "sync": + return obsidian_cli_main(["business", "sync", "kingdee", *rest]) + if action == "query": + return obsidian_cli_main(["business", "query", *rest]) + if action == "sql": + return obsidian_cli_main(["business", "sql", *rest]) + print(f"Unknown kingdee action: {action}", file=sys.stderr) + return 2 + + +def route_timer(args: Sequence[str]) -> int: + if not args: + return print_help() + action, *rest = args + action_aliases = { + "install": "start", + "start": "start", + "run": "run-now", + "now": "run-now", + "run-now": "run-now", + "status": "status", + "stop": "stop", + "uninstall": "uninstall", + } + mapped = action_aliases.get(action) + if mapped is None: + print(f"Unknown timer action: {action}", file=sys.stderr) + return 2 + return manage_business_sync.main([mapped, *rest]) + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args or args[0] in {"help", "-h", "--help"}: + return print_help() + + command, *rest = args + if command in CLI_COMMANDS: + return obsidian_cli_main([command, *rest]) + if command == "kingdee": + return route_kingdee(rest) + if command == "timer": + return route_timer(rest) + if command == "mcp": + return manage_mcp_service.main(rest) + if command == "daily-update": + return run_script("daily-update.sh", rest) + + print(f"Unknown command: {command}", file=sys.stderr) + print("Run `scripts/obsidian-wiki-ops.py help` for available operations.", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_business_sync_service.py b/tests/test_business_sync_service.py index f75528b..e25f938 100644 --- a/tests/test_business_sync_service.py +++ b/tests/test_business_sync_service.py @@ -14,6 +14,14 @@ import manage_business_sync class BusinessSyncServiceTests(unittest.TestCase): + def test_shell_entrypoint_delegates_to_unified_user_ops_entrypoint(self): + entrypoint = SCRIPTS_DIR / "business_sync_service.sh" + + self.assertTrue(entrypoint.is_file()) + self.assertTrue(os.access(entrypoint, os.X_OK)) + content = entrypoint.read_text(encoding="utf-8") + self.assertIn('exec python3 "$SCRIPT_DIR/obsidian-wiki-ops.py" timer "$@"', content) + def test_render_service_and_timer_units_for_incremental_sync(self): service = manage_business_sync.render_service_unit( project_root=Path("/opt/obsidian-wiki"), diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index f7fbf04..2f9bd17 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -15,7 +15,7 @@ import manage_mcp_service class McpServiceConfigTests(unittest.TestCase): - def test_service_commands_use_one_shell_entrypoint(self): + def test_service_commands_delegate_to_unified_user_ops_entrypoint(self): entrypoint = SCRIPTS_DIR / "mcp_service.sh" legacy_wrappers = [ "new_mcp_service_token.sh", @@ -28,9 +28,9 @@ class McpServiceConfigTests(unittest.TestCase): self.assertTrue(entrypoint.is_file()) self.assertTrue(os.access(entrypoint, os.X_OK)) content = entrypoint.read_text(encoding="utf-8") - self.assertIn('exec python3 "$SCRIPT_DIR/manage_mcp_service.py" "$@"', content) + self.assertIn('exec python3 "$SCRIPT_DIR/obsidian-wiki-ops.py" mcp "$@"', content) for wrapper in legacy_wrappers: - self.assertFalse((SCRIPTS_DIR / wrapper).exists(), f"{wrapper} should be folded into mcp_service.sh") + self.assertFalse((SCRIPTS_DIR / wrapper).exists(), f"{wrapper} should be folded into obsidian-wiki-ops.py") def test_render_service_unit_uses_http_transport_without_embedding_token(self): unit = manage_mcp_service.render_service_unit( diff --git a/tests/test_user_ops.py b/tests/test_user_ops.py new file mode 100644 index 0000000..fb471e2 --- /dev/null +++ b/tests/test_user_ops.py @@ -0,0 +1,68 @@ +import importlib.util +import io +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "obsidian-wiki-ops.py" + + +def load_user_ops(): + spec = importlib.util.spec_from_file_location("obsidian_wiki_user_ops", SCRIPT_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"cannot import {SCRIPT_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class UserOpsTests(unittest.TestCase): + def test_kingdee_sync_forwards_to_business_cli(self): + user_ops = load_user_ops() + + with patch.object(user_ops, "obsidian_cli_main", return_value=0) as cli_main: + status = user_ops.main(["kingdee", "sync", "--vault", "/data/vault", "--incremental"]) + + self.assertEqual(status, 0) + cli_main.assert_called_once_with( + ["business", "sync", "kingdee", "--vault", "/data/vault", "--incremental"] + ) + + def test_timer_install_alias_installs_and_starts_business_timer(self): + user_ops = load_user_ops() + + with patch.object(user_ops.manage_business_sync, "main", return_value=0) as timer_main: + status = user_ops.main(["timer", "install", "--vault", "/data/vault"]) + + self.assertEqual(status, 0) + timer_main.assert_called_once_with(["start", "--vault", "/data/vault"]) + + def test_mcp_commands_forward_to_mcp_service_manager(self): + user_ops = load_user_ops() + + with patch.object(user_ops.manage_mcp_service, "main", return_value=0) as mcp_main: + status = user_ops.main(["mcp", "start", "--enable-query"]) + + self.assertEqual(status, 0) + mcp_main.assert_called_once_with(["start", "--enable-query"]) + + def test_help_lists_common_user_operations(self): + user_ops = load_user_ops() + output = io.StringIO() + + with redirect_stdout(output): + status = user_ops.main(["help"]) + + self.assertEqual(status, 0) + text = output.getvalue() + self.assertIn("kingdee sync", text) + self.assertIn("timer install", text) + self.assertIn("mcp start", text) + + +if __name__ == "__main__": + unittest.main()