webnovel.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. webnovel 统一入口(面向 skills / agents 的稳定 CLI)
  5. 设计目标:
  6. - 只有一个入口命令,避免到处拼 `python -m data_modules.xxx ...` 导致参数位置/引号/路径炸裂。
  7. - 自动解析正确的 book project_root(包含 `.webnovel/state.json` 的目录)。
  8. - 所有写入类命令在解析到 project_root 后,统一前置 `--project-root` 传给具体模块。
  9. 典型用法(推荐,不依赖 PYTHONPATH / 不要求 cd):
  10. python "<SCRIPTS_DIR>/webnovel.py" preflight
  11. python "<SCRIPTS_DIR>/webnovel.py" where
  12. python "<SCRIPTS_DIR>/webnovel.py" use D:\\wk\\xiaoshuo\\凡人资本论
  13. python "<SCRIPTS_DIR>/webnovel.py" --project-root D:\\wk\\xiaoshuo index stats
  14. python "<SCRIPTS_DIR>/webnovel.py" --project-root D:\\wk\\xiaoshuo state process-chapter --chapter 100 --data @payload.json
  15. python "<SCRIPTS_DIR>/webnovel.py" --project-root D:\\wk\\xiaoshuo extract-context --chapter 100 --format json
  16. 也支持(不推荐,容易踩 PYTHONPATH/cd/参数顺序坑):
  17. python -m data_modules.webnovel where
  18. """
  19. from __future__ import annotations
  20. import argparse
  21. import importlib
  22. import json
  23. import subprocess
  24. import sys
  25. from pathlib import Path
  26. from typing import Optional
  27. from runtime_compat import normalize_windows_path
  28. from project_locator import resolve_project_root, write_current_project_pointer, update_global_registry_current_project
  29. def _scripts_dir() -> Path:
  30. # data_modules/webnovel.py -> data_modules -> scripts
  31. return Path(__file__).resolve().parent.parent
  32. def _resolve_root(explicit_project_root: Optional[str]) -> Path:
  33. # 允许显式传入工作区根目录或书项目根目录
  34. raw = explicit_project_root
  35. if raw:
  36. return resolve_project_root(raw)
  37. return resolve_project_root()
  38. def _strip_project_root_args(argv: list[str]) -> list[str]:
  39. """
  40. 下游工具统一由本入口注入 `--project-root`,避免重复传参导致 argparse 报错/歧义。
  41. """
  42. out: list[str] = []
  43. i = 0
  44. while i < len(argv):
  45. tok = argv[i]
  46. if tok == "--project-root":
  47. i += 2
  48. continue
  49. if tok.startswith("--project-root="):
  50. i += 1
  51. continue
  52. out.append(tok)
  53. i += 1
  54. return out
  55. def _run_data_module(module: str, argv: list[str]) -> int:
  56. """
  57. Import `data_modules.<module>` and call its main(), while isolating sys.argv.
  58. """
  59. mod = importlib.import_module(f"data_modules.{module}")
  60. main = getattr(mod, "main", None)
  61. if not callable(main):
  62. raise RuntimeError(f"data_modules.{module} 缺少可调用的 main()")
  63. old_argv = sys.argv
  64. try:
  65. sys.argv = [f"data_modules.{module}"] + argv
  66. try:
  67. main()
  68. return 0
  69. except SystemExit as e:
  70. return int(e.code or 0)
  71. finally:
  72. sys.argv = old_argv
  73. def _run_script(script_name: str, argv: list[str]) -> int:
  74. """
  75. Run a script under `.claude/scripts/` via a subprocess.
  76. 用途:兼容没有 main() 的脚本(例如 workflow_manager.py)。
  77. """
  78. script_path = _scripts_dir() / script_name
  79. if not script_path.is_file():
  80. raise FileNotFoundError(f"未找到脚本: {script_path}")
  81. proc = subprocess.run([sys.executable, str(script_path), *argv])
  82. return int(proc.returncode or 0)
  83. def cmd_where(args: argparse.Namespace) -> int:
  84. root = _resolve_root(args.project_root)
  85. print(str(root))
  86. return 0
  87. def _build_preflight_report(explicit_project_root: Optional[str]) -> dict:
  88. scripts_dir = _scripts_dir().resolve()
  89. plugin_root = scripts_dir.parent
  90. skill_root = plugin_root / "skills" / "webnovel-write"
  91. entry_script = scripts_dir / "webnovel.py"
  92. extract_script = scripts_dir / "extract_chapter_context.py"
  93. checks: list[dict[str, object]] = [
  94. {"name": "scripts_dir", "ok": scripts_dir.is_dir(), "path": str(scripts_dir)},
  95. {"name": "entry_script", "ok": entry_script.is_file(), "path": str(entry_script)},
  96. {"name": "extract_context_script", "ok": extract_script.is_file(), "path": str(extract_script)},
  97. {"name": "skill_root", "ok": skill_root.is_dir(), "path": str(skill_root)},
  98. ]
  99. project_root = ""
  100. project_root_error = ""
  101. try:
  102. resolved_root = _resolve_root(explicit_project_root)
  103. project_root = str(resolved_root)
  104. checks.append({"name": "project_root", "ok": True, "path": project_root})
  105. except Exception as exc:
  106. project_root_error = str(exc)
  107. checks.append({"name": "project_root", "ok": False, "path": explicit_project_root or "", "error": project_root_error})
  108. return {
  109. "ok": all(bool(item["ok"]) for item in checks),
  110. "project_root": project_root,
  111. "scripts_dir": str(scripts_dir),
  112. "skill_root": str(skill_root),
  113. "checks": checks,
  114. "project_root_error": project_root_error,
  115. }
  116. def cmd_preflight(args: argparse.Namespace) -> int:
  117. report = _build_preflight_report(args.project_root)
  118. if args.format == "json":
  119. print(json.dumps(report, ensure_ascii=False, indent=2))
  120. else:
  121. for item in report["checks"]:
  122. status = "OK" if item["ok"] else "ERROR"
  123. path = item.get("path") or ""
  124. print(f"{status} {item['name']}: {path}")
  125. if item.get("error"):
  126. print(f" detail: {item['error']}")
  127. return 0 if report["ok"] else 1
  128. def cmd_use(args: argparse.Namespace) -> int:
  129. project_root = normalize_windows_path(args.project_root).expanduser()
  130. try:
  131. project_root = project_root.resolve()
  132. except Exception:
  133. project_root = project_root
  134. workspace_root: Optional[Path] = None
  135. if args.workspace_root:
  136. workspace_root = normalize_windows_path(args.workspace_root).expanduser()
  137. try:
  138. workspace_root = workspace_root.resolve()
  139. except Exception:
  140. workspace_root = workspace_root
  141. # 1) 写入工作区指针(若工作区内存在 `.claude/`)
  142. pointer_file = write_current_project_pointer(project_root, workspace_root=workspace_root)
  143. if pointer_file is not None:
  144. print(f"workspace pointer: {pointer_file}")
  145. else:
  146. print("workspace pointer: (skipped)")
  147. # 2) 写入用户级 registry(保证全局安装/空上下文可恢复)
  148. reg_path = update_global_registry_current_project(workspace_root=workspace_root, project_root=project_root)
  149. if reg_path is not None:
  150. print(f"global registry: {reg_path}")
  151. else:
  152. print("global registry: (skipped)")
  153. return 0
  154. def main() -> None:
  155. parser = argparse.ArgumentParser(description="webnovel unified CLI")
  156. parser.add_argument("--project-root", help="书项目根目录或工作区根目录(可选,默认自动检测)")
  157. sub = parser.add_subparsers(dest="tool", required=True)
  158. p_where = sub.add_parser("where", help="打印解析出的 project_root")
  159. p_where.set_defaults(func=cmd_where)
  160. p_preflight = sub.add_parser("preflight", help="校验统一 CLI 运行环境与 project_root")
  161. p_preflight.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
  162. p_preflight.set_defaults(func=cmd_preflight)
  163. p_use = sub.add_parser("use", help="绑定当前工作区使用的书项目(写入指针/registry)")
  164. p_use.add_argument("project_root", help="书项目根目录(必须包含 .webnovel/state.json)")
  165. p_use.add_argument("--workspace-root", help="工作区根目录(可选;默认由运行环境推断)")
  166. p_use.set_defaults(func=cmd_use)
  167. # Pass-through to data modules
  168. p_index = sub.add_parser("index", help="转发到 index_manager")
  169. p_index.add_argument("args", nargs=argparse.REMAINDER)
  170. p_state = sub.add_parser("state", help="转发到 state_manager")
  171. p_state.add_argument("args", nargs=argparse.REMAINDER)
  172. p_rag = sub.add_parser("rag", help="转发到 rag_adapter")
  173. p_rag.add_argument("args", nargs=argparse.REMAINDER)
  174. p_style = sub.add_parser("style", help="转发到 style_sampler")
  175. p_style.add_argument("args", nargs=argparse.REMAINDER)
  176. p_entity = sub.add_parser("entity", help="转发到 entity_linker")
  177. p_entity.add_argument("args", nargs=argparse.REMAINDER)
  178. p_context = sub.add_parser("context", help="转发到 context_manager")
  179. p_context.add_argument("args", nargs=argparse.REMAINDER)
  180. p_memory = sub.add_parser("memory", help="转发到 memory.store")
  181. p_memory.add_argument("args", nargs=argparse.REMAINDER)
  182. p_migrate = sub.add_parser("migrate", help="转发到 migrate_state_to_sqlite")
  183. p_migrate.add_argument("args", nargs=argparse.REMAINDER)
  184. # Pass-through to scripts
  185. p_workflow = sub.add_parser("workflow", help="转发到 workflow_manager.py")
  186. p_workflow.add_argument("args", nargs=argparse.REMAINDER)
  187. p_status = sub.add_parser("status", help="转发到 status_reporter.py")
  188. p_status.add_argument("args", nargs=argparse.REMAINDER)
  189. p_update_state = sub.add_parser("update-state", help="转发到 update_state.py")
  190. p_update_state.add_argument("args", nargs=argparse.REMAINDER)
  191. p_backup = sub.add_parser("backup", help="转发到 backup_manager.py")
  192. p_backup.add_argument("args", nargs=argparse.REMAINDER)
  193. p_archive = sub.add_parser("archive", help="转发到 archive_manager.py")
  194. p_archive.add_argument("args", nargs=argparse.REMAINDER)
  195. p_init = sub.add_parser("init", help="转发到 init_project.py(初始化项目)")
  196. p_init.add_argument("args", nargs=argparse.REMAINDER)
  197. p_extract_context = sub.add_parser("extract-context", help="转发到 extract_chapter_context.py")
  198. p_extract_context.add_argument("--chapter", type=int, required=True, help="目标章节号")
  199. p_extract_context.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
  200. # 兼容:允许 `--project-root` 出现在任意位置(减少 agents/skills 拼命令的出错率)
  201. from .cli_args import normalize_global_project_root
  202. argv = normalize_global_project_root(sys.argv[1:])
  203. args = parser.parse_args(argv)
  204. # where/use 直接执行
  205. if hasattr(args, "func"):
  206. code = int(args.func(args) or 0)
  207. raise SystemExit(code)
  208. tool = args.tool
  209. rest = list(getattr(args, "args", []) or [])
  210. # argparse.REMAINDER 可能以 `--` 开头占位,这里去掉
  211. if rest[:1] == ["--"]:
  212. rest = rest[1:]
  213. rest = _strip_project_root_args(rest)
  214. # init 是创建项目,不应该依赖/注入已存在 project_root
  215. if tool == "init":
  216. raise SystemExit(_run_script("init_project.py", rest))
  217. # 其余工具:统一解析 project_root 后前置给下游
  218. project_root = _resolve_root(args.project_root)
  219. forward_args = ["--project-root", str(project_root)]
  220. if tool == "index":
  221. raise SystemExit(_run_data_module("index_manager", [*forward_args, *rest]))
  222. if tool == "state":
  223. raise SystemExit(_run_data_module("state_manager", [*forward_args, *rest]))
  224. if tool == "rag":
  225. raise SystemExit(_run_data_module("rag_adapter", [*forward_args, *rest]))
  226. if tool == "style":
  227. raise SystemExit(_run_data_module("style_sampler", [*forward_args, *rest]))
  228. if tool == "entity":
  229. raise SystemExit(_run_data_module("entity_linker", [*forward_args, *rest]))
  230. if tool == "context":
  231. raise SystemExit(_run_data_module("context_manager", [*forward_args, *rest]))
  232. if tool == "memory":
  233. raise SystemExit(_run_data_module("memory.store", [*forward_args, *rest]))
  234. if tool == "migrate":
  235. raise SystemExit(_run_data_module("migrate_state_to_sqlite", [*forward_args, *rest]))
  236. if tool == "workflow":
  237. raise SystemExit(_run_script("workflow_manager.py", [*forward_args, *rest]))
  238. if tool == "status":
  239. raise SystemExit(_run_script("status_reporter.py", [*forward_args, *rest]))
  240. if tool == "update-state":
  241. raise SystemExit(_run_script("update_state.py", [*forward_args, *rest]))
  242. if tool == "backup":
  243. raise SystemExit(_run_script("backup_manager.py", [*forward_args, *rest]))
  244. if tool == "archive":
  245. raise SystemExit(_run_script("archive_manager.py", [*forward_args, *rest]))
  246. if tool == "extract-context":
  247. return_args = [*forward_args, "--chapter", str(args.chapter), "--format", str(args.format)]
  248. raise SystemExit(_run_script("extract_chapter_context.py", return_args))
  249. raise SystemExit(2)
  250. if __name__ == "__main__":
  251. main()