#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ webnovel 统一入口(面向 skills / agents 的稳定 CLI) 设计目标: - 只有一个入口命令,避免到处拼 `python -m data_modules.xxx ...` 导致参数位置/引号/路径炸裂。 - 自动解析正确的 book project_root(包含 `.webnovel/state.json` 的目录)。 - 所有写入类命令在解析到 project_root 后,统一前置 `--project-root` 传给具体模块。 典型用法(推荐,不依赖 PYTHONPATH / 不要求 cd): python "/webnovel.py" preflight python "/webnovel.py" where python "/webnovel.py" use D:\\wk\\xiaoshuo\\凡人资本论 python "/webnovel.py" --project-root D:\\wk\\xiaoshuo index stats python "/webnovel.py" --project-root D:\\wk\\xiaoshuo state process-chapter --chapter 100 --data @payload.json python "/webnovel.py" --project-root D:\\wk\\xiaoshuo extract-context --chapter 100 --format json 也支持(不推荐,容易踩 PYTHONPATH/cd/参数顺序坑): python -m data_modules.webnovel where """ from __future__ import annotations import argparse import importlib import json import subprocess import sys from pathlib import Path from typing import Optional from runtime_compat import normalize_windows_path from project_locator import resolve_project_root, write_current_project_pointer, update_global_registry_current_project def _scripts_dir() -> Path: # data_modules/webnovel.py -> data_modules -> scripts return Path(__file__).resolve().parent.parent def _resolve_root(explicit_project_root: Optional[str]) -> Path: # 允许显式传入工作区根目录或书项目根目录 raw = explicit_project_root if raw: return resolve_project_root(raw) return resolve_project_root() def _strip_project_root_args(argv: list[str]) -> list[str]: """ 下游工具统一由本入口注入 `--project-root`,避免重复传参导致 argparse 报错/歧义。 """ out: list[str] = [] i = 0 while i < len(argv): tok = argv[i] if tok == "--project-root": i += 2 continue if tok.startswith("--project-root="): i += 1 continue out.append(tok) i += 1 return out def _run_data_module(module: str, argv: list[str]) -> int: """ Import `data_modules.` and call its main(), while isolating sys.argv. """ mod = importlib.import_module(f"data_modules.{module}") main = getattr(mod, "main", None) if not callable(main): raise RuntimeError(f"data_modules.{module} 缺少可调用的 main()") old_argv = sys.argv try: sys.argv = [f"data_modules.{module}"] + argv try: main() return 0 except SystemExit as e: return int(e.code or 0) finally: sys.argv = old_argv def _run_script(script_name: str, argv: list[str]) -> int: """ Run a script under `.claude/scripts/` via a subprocess. 用途:兼容没有 main() 的脚本(例如 workflow_manager.py)。 """ script_path = _scripts_dir() / script_name if not script_path.is_file(): raise FileNotFoundError(f"未找到脚本: {script_path}") proc = subprocess.run([sys.executable, str(script_path), *argv]) return int(proc.returncode or 0) def cmd_where(args: argparse.Namespace) -> int: root = _resolve_root(args.project_root) print(str(root)) return 0 def _build_preflight_report(explicit_project_root: Optional[str]) -> dict: scripts_dir = _scripts_dir().resolve() plugin_root = scripts_dir.parent skill_root = plugin_root / "skills" / "webnovel-write" entry_script = scripts_dir / "webnovel.py" extract_script = scripts_dir / "extract_chapter_context.py" checks: list[dict[str, object]] = [ {"name": "scripts_dir", "ok": scripts_dir.is_dir(), "path": str(scripts_dir)}, {"name": "entry_script", "ok": entry_script.is_file(), "path": str(entry_script)}, {"name": "extract_context_script", "ok": extract_script.is_file(), "path": str(extract_script)}, {"name": "skill_root", "ok": skill_root.is_dir(), "path": str(skill_root)}, ] project_root = "" project_root_error = "" try: resolved_root = _resolve_root(explicit_project_root) project_root = str(resolved_root) checks.append({"name": "project_root", "ok": True, "path": project_root}) except Exception as exc: project_root_error = str(exc) checks.append({"name": "project_root", "ok": False, "path": explicit_project_root or "", "error": project_root_error}) return { "ok": all(bool(item["ok"]) for item in checks), "project_root": project_root, "scripts_dir": str(scripts_dir), "skill_root": str(skill_root), "checks": checks, "project_root_error": project_root_error, } def cmd_preflight(args: argparse.Namespace) -> int: report = _build_preflight_report(args.project_root) if args.format == "json": print(json.dumps(report, ensure_ascii=False, indent=2)) else: for item in report["checks"]: status = "OK" if item["ok"] else "ERROR" path = item.get("path") or "" print(f"{status} {item['name']}: {path}") if item.get("error"): print(f" detail: {item['error']}") return 0 if report["ok"] else 1 def cmd_use(args: argparse.Namespace) -> int: project_root = normalize_windows_path(args.project_root).expanduser() try: project_root = project_root.resolve() except Exception: project_root = project_root workspace_root: Optional[Path] = None if args.workspace_root: workspace_root = normalize_windows_path(args.workspace_root).expanduser() try: workspace_root = workspace_root.resolve() except Exception: workspace_root = workspace_root # 1) 写入工作区指针(若工作区内存在 `.claude/`) pointer_file = write_current_project_pointer(project_root, workspace_root=workspace_root) if pointer_file is not None: print(f"workspace pointer: {pointer_file}") else: print("workspace pointer: (skipped)") # 2) 写入用户级 registry(保证全局安装/空上下文可恢复) reg_path = update_global_registry_current_project(workspace_root=workspace_root, project_root=project_root) if reg_path is not None: print(f"global registry: {reg_path}") else: print("global registry: (skipped)") return 0 def main() -> None: parser = argparse.ArgumentParser(description="webnovel unified CLI") parser.add_argument("--project-root", help="书项目根目录或工作区根目录(可选,默认自动检测)") sub = parser.add_subparsers(dest="tool", required=True) p_where = sub.add_parser("where", help="打印解析出的 project_root") p_where.set_defaults(func=cmd_where) p_preflight = sub.add_parser("preflight", help="校验统一 CLI 运行环境与 project_root") p_preflight.add_argument("--format", choices=["text", "json"], default="text", help="输出格式") p_preflight.set_defaults(func=cmd_preflight) p_use = sub.add_parser("use", help="绑定当前工作区使用的书项目(写入指针/registry)") p_use.add_argument("project_root", help="书项目根目录(必须包含 .webnovel/state.json)") p_use.add_argument("--workspace-root", help="工作区根目录(可选;默认由运行环境推断)") p_use.set_defaults(func=cmd_use) # Pass-through to data modules p_index = sub.add_parser("index", help="转发到 index_manager") p_index.add_argument("args", nargs=argparse.REMAINDER) p_state = sub.add_parser("state", help="转发到 state_manager") p_state.add_argument("args", nargs=argparse.REMAINDER) p_rag = sub.add_parser("rag", help="转发到 rag_adapter") p_rag.add_argument("args", nargs=argparse.REMAINDER) p_style = sub.add_parser("style", help="转发到 style_sampler") p_style.add_argument("args", nargs=argparse.REMAINDER) p_entity = sub.add_parser("entity", help="转发到 entity_linker") p_entity.add_argument("args", nargs=argparse.REMAINDER) p_context = sub.add_parser("context", help="转发到 context_manager") p_context.add_argument("args", nargs=argparse.REMAINDER) p_memory = sub.add_parser("memory", help="转发到 memory.store") p_memory.add_argument("args", nargs=argparse.REMAINDER) p_migrate = sub.add_parser("migrate", help="转发到 migrate_state_to_sqlite") p_migrate.add_argument("args", nargs=argparse.REMAINDER) # Pass-through to scripts p_status = sub.add_parser("status", help="转发到 status_reporter.py") p_status.add_argument("args", nargs=argparse.REMAINDER) p_update_state = sub.add_parser("update-state", help="转发到 update_state.py") p_update_state.add_argument("args", nargs=argparse.REMAINDER) p_backup = sub.add_parser("backup", help="转发到 backup_manager.py") p_backup.add_argument("args", nargs=argparse.REMAINDER) p_archive = sub.add_parser("archive", help="转发到 archive_manager.py") p_archive.add_argument("args", nargs=argparse.REMAINDER) p_init = sub.add_parser("init", help="转发到 init_project.py(初始化项目)") p_init.add_argument("args", nargs=argparse.REMAINDER) p_extract_context = sub.add_parser("extract-context", help="转发到 extract_chapter_context.py") p_extract_context.add_argument("--chapter", type=int, required=True, help="目标章节号") p_extract_context.add_argument("--format", choices=["text", "json"], default="text", help="输出格式") p_review_pipeline = sub.add_parser("review-pipeline", help="转发到 review_pipeline.py") p_review_pipeline.add_argument("--chapter", type=int, required=True, help="目标章节号") p_review_pipeline.add_argument("--review-results", required=True, help="reviewer 原始结果 JSON 文件") p_review_pipeline.add_argument("--metrics-out", default="", help="metrics 输出文件") p_review_pipeline.add_argument("--report-file", default="", help="审查报告路径") # 兼容:允许 `--project-root` 出现在任意位置(减少 agents/skills 拼命令的出错率) from .cli_args import normalize_global_project_root argv = normalize_global_project_root(sys.argv[1:]) args = parser.parse_args(argv) # where/use 直接执行 if hasattr(args, "func"): code = int(args.func(args) or 0) raise SystemExit(code) tool = args.tool rest = list(getattr(args, "args", []) or []) # argparse.REMAINDER 可能以 `--` 开头占位,这里去掉 if rest[:1] == ["--"]: rest = rest[1:] rest = _strip_project_root_args(rest) # init 是创建项目,不应该依赖/注入已存在 project_root if tool == "init": raise SystemExit(_run_script("init_project.py", rest)) # 其余工具:统一解析 project_root 后前置给下游 project_root = _resolve_root(args.project_root) forward_args = ["--project-root", str(project_root)] if tool == "index": raise SystemExit(_run_data_module("index_manager", [*forward_args, *rest])) if tool == "state": raise SystemExit(_run_data_module("state_manager", [*forward_args, *rest])) if tool == "rag": raise SystemExit(_run_data_module("rag_adapter", [*forward_args, *rest])) if tool == "style": raise SystemExit(_run_data_module("style_sampler", [*forward_args, *rest])) if tool == "entity": raise SystemExit(_run_data_module("entity_linker", [*forward_args, *rest])) if tool == "context": raise SystemExit(_run_data_module("context_manager", [*forward_args, *rest])) if tool == "memory": raise SystemExit(_run_data_module("memory.store", [*forward_args, *rest])) if tool == "migrate": raise SystemExit(_run_data_module("migrate_state_to_sqlite", [*forward_args, *rest])) if tool == "status": raise SystemExit(_run_script("status_reporter.py", [*forward_args, *rest])) if tool == "update-state": raise SystemExit(_run_script("update_state.py", [*forward_args, *rest])) if tool == "backup": raise SystemExit(_run_script("backup_manager.py", [*forward_args, *rest])) if tool == "archive": raise SystemExit(_run_script("archive_manager.py", [*forward_args, *rest])) if tool == "extract-context": return_args = [*forward_args, "--chapter", str(args.chapter), "--format", str(args.format)] raise SystemExit(_run_script("extract_chapter_context.py", return_args)) if tool == "review-pipeline": return_args = [ *forward_args, "--chapter", str(args.chapter), "--review-results", str(args.review_results), ] if args.metrics_out: return_args.extend(["--metrics-out", str(args.metrics_out)]) if args.report_file: return_args.extend(["--report-file", str(args.report_file)]) raise SystemExit(_run_script("review_pipeline.py", return_args)) raise SystemExit(2) if __name__ == "__main__": main()