319 lines
12 KiB
Python
319 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
noma 统一入口(面向 skills / agents 的稳定 CLI)
|
||
|
||
设计目标:
|
||
- 只有一个入口命令,避免到处拼 `python -m data_modules.xxx ...` 导致参数位置/引号/路径炸裂。
|
||
- 自动解析正确的 book project_root(包含 `.noma/state.json` 的目录)。
|
||
- 所有写入类命令在解析到 project_root 后,统一前置 `--project-root` 传给具体模块。
|
||
|
||
典型用法(推荐,不依赖 PYTHONPATH / 不要求 cd):
|
||
python "<SCRIPTS_DIR>/noma.py" preflight
|
||
python "<SCRIPTS_DIR>/noma.py" where
|
||
python "<SCRIPTS_DIR>/noma.py" use D:\\wk\\xiaoshuo\\凡人资本论
|
||
python "<SCRIPTS_DIR>/noma.py" --project-root D:\\wk\\xiaoshuo index stats
|
||
python "<SCRIPTS_DIR>/noma.py" --project-root D:\\wk\\xiaoshuo state process-chapter --chapter 100 --data @payload.json
|
||
python "<SCRIPTS_DIR>/noma.py" --project-root D:\\wk\\xiaoshuo extract-context --chapter 100 --format json
|
||
|
||
也支持(不推荐,容易踩 PYTHONPATH/cd/参数顺序坑):
|
||
python -m data_modules.noma 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/noma.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.<module>` 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" / "noma-write"
|
||
entry_script = scripts_dir / "noma.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="noma 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="书项目根目录(必须包含 .noma/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_migrate = sub.add_parser("migrate", help="转发到 migrate_state_to_sqlite")
|
||
p_migrate.add_argument("args", nargs=argparse.REMAINDER)
|
||
|
||
p_wiki = sub.add_parser("wiki", help="转发到 wiki_manager")
|
||
p_wiki.add_argument("args", nargs=argparse.REMAINDER)
|
||
|
||
# Pass-through to scripts
|
||
p_workflow = sub.add_parser("workflow", help="转发到 workflow_manager.py")
|
||
p_workflow.add_argument("args", nargs=argparse.REMAINDER)
|
||
|
||
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="输出格式")
|
||
|
||
# 兼容:允许 `--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 == "migrate":
|
||
raise SystemExit(_run_data_module("migrate_state_to_sqlite", [*forward_args, *rest]))
|
||
if tool == "wiki":
|
||
raise SystemExit(_run_data_module("wiki_manager", [*forward_args, *rest]))
|
||
|
||
if tool == "workflow":
|
||
raise SystemExit(_run_script("workflow_manager.py", [*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":
|
||
from runtime_compat import normalize_windows_path
|
||
chapter_path = normalize_windows_path(f"正文/第{args.chapter:04d}章.md")
|
||
return_args = [*forward_args, "--chapter", str(chapter_path)]
|
||
raise SystemExit(_run_script("extract_chapter_context.py", return_args))
|
||
|
||
raise SystemExit(2)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|