webnovel.py 9.9 KB

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