project_locator.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. #!/usr/bin/env python3
  2. """
  3. Project location helpers for webnovel-writer scripts.
  4. Problem this solves:
  5. - Many scripts assumed CWD is the project root and used relative paths like `.webnovel/state.json`.
  6. - In this repo, commands/scripts are often invoked from the repo root, while the actual project lives
  7. in a subdirectory (default: `webnovel-project/`).
  8. These helpers provide a single, consistent way to locate the active project root.
  9. """
  10. from __future__ import annotations
  11. import json
  12. import os
  13. from datetime import datetime
  14. from pathlib import Path
  15. from typing import Iterable, Optional
  16. from runtime_compat import normalize_windows_path
  17. DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",)
  18. CURRENT_PROJECT_POINTER_REL: Path = Path(".claude") / ".webnovel-current-project"
  19. # 用户级全局映射(当 skills/agents 安装在 ~/.claude 时,项目目录可能在任意盘符)
  20. # 该文件用于在“空上下文 + CWD 不在项目内”的情况下仍能定位到正确 project_root。
  21. GLOBAL_REGISTRY_REL: Path = Path("webnovel-writer") / "workspaces.json"
  22. # Claude Code 常见环境变量(存在时优先作为“工作区根目录”提示)
  23. ENV_CLAUDE_PROJECT_DIR = "CLAUDE_PROJECT_DIR"
  24. ENV_CLAUDE_HOME = "CLAUDE_HOME"
  25. ENV_WEBNOVEL_CLAUDE_HOME = "WEBNOVEL_CLAUDE_HOME"
  26. def _find_git_root(cwd: Path) -> Optional[Path]:
  27. """Return nearest git root for cwd, if any."""
  28. for candidate in (cwd, *cwd.parents):
  29. if (candidate / ".git").exists():
  30. return candidate
  31. return None
  32. def _now_iso() -> str:
  33. return datetime.now().isoformat(timespec="seconds")
  34. def _normcase_path_key(p: Path) -> str:
  35. """
  36. 生成稳定的路径 key(Windows 下大小写/分隔符不敏感)。
  37. 注意:key 仅用于映射表索引,实际路径仍以原始绝对路径字符串存储。
  38. """
  39. try:
  40. resolved = p.expanduser().resolve()
  41. except Exception:
  42. resolved = p.expanduser()
  43. return os.path.normcase(str(resolved))
  44. def _get_user_claude_root() -> Path:
  45. raw = os.environ.get(ENV_WEBNOVEL_CLAUDE_HOME) or os.environ.get(ENV_CLAUDE_HOME)
  46. if raw:
  47. try:
  48. return normalize_windows_path(raw).expanduser().resolve()
  49. except Exception:
  50. return normalize_windows_path(raw).expanduser()
  51. return (Path.home() / ".claude").resolve()
  52. def _global_registry_path() -> Path:
  53. return _get_user_claude_root() / GLOBAL_REGISTRY_REL
  54. def _default_registry() -> dict:
  55. return {
  56. "schema_version": 1,
  57. "workspaces": {},
  58. "last_used_project_root": "",
  59. "updated_at": _now_iso(),
  60. }
  61. def _load_global_registry(path: Path) -> dict:
  62. if not path.is_file():
  63. return _default_registry()
  64. try:
  65. data = json.loads(path.read_text(encoding="utf-8") or "{}")
  66. except Exception:
  67. return _default_registry()
  68. if not isinstance(data, dict):
  69. return _default_registry()
  70. if data.get("schema_version") != 1:
  71. data["schema_version"] = 1
  72. if not isinstance(data.get("workspaces"), dict):
  73. data["workspaces"] = {}
  74. if not isinstance(data.get("last_used_project_root"), str):
  75. data["last_used_project_root"] = ""
  76. if not isinstance(data.get("updated_at"), str):
  77. data["updated_at"] = _now_iso()
  78. return data
  79. def _save_global_registry(path: Path, data: dict) -> None:
  80. # 写入是 best-effort:用户目录权限/只读盘符等情况不应阻断主流程。
  81. try:
  82. from security_utils import atomic_write_json
  83. data["updated_at"] = _now_iso()
  84. atomic_write_json(path, data, backup=False)
  85. except Exception:
  86. # 非阻断
  87. return
  88. def _resolve_project_root_from_global_registry(
  89. base: Path,
  90. *,
  91. workspace_hint: Optional[Path] = None,
  92. allow_last_used_fallback: bool = False,
  93. ) -> Optional[Path]:
  94. """
  95. 从用户级 registry 中解析 project_root。
  96. 安全策略:
  97. - 优先使用 workspace_hint / CLAUDE_PROJECT_DIR 提示做匹配。
  98. - 默认不使用 last_used 兜底,避免在“完全无上下文”时误命中错误项目。
  99. """
  100. reg_path = _global_registry_path()
  101. reg = _load_global_registry(reg_path)
  102. workspaces = reg.get("workspaces") or {}
  103. if not isinstance(workspaces, dict) or not workspaces:
  104. return None
  105. hints: list[Path] = []
  106. env_ws = os.environ.get(ENV_CLAUDE_PROJECT_DIR)
  107. if env_ws:
  108. hints.append(normalize_windows_path(env_ws).expanduser())
  109. if workspace_hint is not None:
  110. hints.append(workspace_hint)
  111. hints.append(base)
  112. # 1) 精确匹配
  113. for hint in hints:
  114. key = _normcase_path_key(hint)
  115. entry = workspaces.get(key)
  116. if isinstance(entry, dict):
  117. raw = entry.get("current_project_root")
  118. if isinstance(raw, str) and raw.strip():
  119. target = normalize_windows_path(raw).expanduser()
  120. if not target.is_absolute():
  121. continue
  122. if _is_project_root(target):
  123. return target.resolve()
  124. # 2) 前缀匹配(从 workspace 子目录运行时)
  125. for hint in hints:
  126. hint_key = _normcase_path_key(hint)
  127. best_key: Optional[str] = None
  128. best_len = -1
  129. for ws_key in workspaces.keys():
  130. if not isinstance(ws_key, str) or not ws_key:
  131. continue
  132. ws_key_norm = os.path.normcase(ws_key)
  133. if hint_key == ws_key_norm or hint_key.startswith(ws_key_norm.rstrip("\\") + "\\"):
  134. if len(ws_key_norm) > best_len:
  135. best_key = ws_key
  136. best_len = len(ws_key_norm)
  137. if best_key:
  138. entry = workspaces.get(best_key)
  139. if isinstance(entry, dict):
  140. raw = entry.get("current_project_root")
  141. if isinstance(raw, str) and raw.strip():
  142. target = normalize_windows_path(raw).expanduser()
  143. if target.is_absolute() and _is_project_root(target):
  144. return target.resolve()
  145. # 3) last_used(可选,默认关闭)
  146. if allow_last_used_fallback:
  147. raw = reg.get("last_used_project_root")
  148. if isinstance(raw, str) and raw.strip():
  149. target = normalize_windows_path(raw).expanduser()
  150. if target.is_absolute() and _is_project_root(target):
  151. return target.resolve()
  152. return None
  153. def update_global_registry_current_project(
  154. *,
  155. workspace_root: Optional[Path],
  156. project_root: Path,
  157. ) -> Optional[Path]:
  158. """
  159. 更新用户级 registry:workspace -> current_project_root 映射。
  160. 返回:registry 文件路径(写入失败则返回 None)。
  161. """
  162. root = normalize_windows_path(project_root).expanduser()
  163. try:
  164. root = root.resolve()
  165. except Exception:
  166. root = root
  167. if not _is_project_root(root):
  168. raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
  169. ws = workspace_root
  170. if ws is None:
  171. env_ws = os.environ.get(ENV_CLAUDE_PROJECT_DIR)
  172. if env_ws:
  173. ws = normalize_windows_path(env_ws).expanduser()
  174. if ws is None:
  175. return None
  176. try:
  177. ws = ws.expanduser().resolve()
  178. except Exception:
  179. ws = ws.expanduser()
  180. reg_path = _global_registry_path()
  181. reg = _load_global_registry(reg_path)
  182. workspaces = reg.get("workspaces")
  183. if not isinstance(workspaces, dict):
  184. workspaces = {}
  185. reg["workspaces"] = workspaces
  186. workspaces[_normcase_path_key(ws)] = {
  187. "workspace_root": str(ws),
  188. "current_project_root": str(root),
  189. "updated_at": _now_iso(),
  190. }
  191. reg["last_used_project_root"] = str(root)
  192. _save_global_registry(reg_path, reg)
  193. return reg_path
  194. def _candidate_roots(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
  195. yield cwd
  196. for name in DEFAULT_PROJECT_DIR_NAMES:
  197. yield cwd / name
  198. for parent in cwd.parents:
  199. yield parent
  200. for name in DEFAULT_PROJECT_DIR_NAMES:
  201. yield parent / name
  202. if stop_at is not None and parent == stop_at:
  203. break
  204. def _is_project_root(path: Path) -> bool:
  205. return (path / ".webnovel" / "state.json").is_file()
  206. def _pointer_candidates(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
  207. """Yield candidate pointer files from cwd up to parents (bounded by stop_at when provided)."""
  208. for candidate in (cwd, *cwd.parents):
  209. yield candidate / CURRENT_PROJECT_POINTER_REL
  210. if stop_at is not None and candidate == stop_at:
  211. break
  212. def _resolve_project_root_from_pointer(cwd: Path, *, stop_at: Optional[Path] = None) -> Optional[Path]:
  213. """
  214. Resolve project root from workspace pointer file.
  215. Pointer file format:
  216. - plain text absolute path, one line.
  217. - relative path is also supported (resolved relative to pointer's `.claude/` dir).
  218. """
  219. for pointer_file in _pointer_candidates(cwd, stop_at=stop_at):
  220. if not pointer_file.is_file():
  221. continue
  222. raw = pointer_file.read_text(encoding="utf-8").strip()
  223. if not raw:
  224. continue
  225. target = normalize_windows_path(raw).expanduser()
  226. if not target.is_absolute():
  227. target = (pointer_file.parent / target).resolve()
  228. if _is_project_root(target):
  229. return target.resolve()
  230. return None
  231. def _find_workspace_root_with_claude(start: Path) -> Optional[Path]:
  232. """Find nearest ancestor containing `.claude/`."""
  233. for candidate in (start, *start.parents):
  234. if (candidate / ".claude").is_dir():
  235. return candidate
  236. return None
  237. def write_current_project_pointer(project_root: Path, *, workspace_root: Optional[Path] = None) -> Optional[Path]:
  238. """
  239. Write workspace-level current project pointer and return pointer file path.
  240. If no workspace root with `.claude/` can be found, returns None (non-fatal).
  241. """
  242. root = normalize_windows_path(project_root).expanduser().resolve()
  243. if not _is_project_root(root):
  244. raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
  245. ws_root = Path(workspace_root).expanduser().resolve() if workspace_root else _find_workspace_root_with_claude(root)
  246. if ws_root is None:
  247. ws_root = _find_workspace_root_with_claude(Path.cwd().resolve())
  248. if ws_root is None:
  249. # 兜底:若无法找到 `.claude/`,将项目父目录视为“工作区”候选,
  250. # 仅用于写入用户级 registry(不创建 `.claude/` 目录,不写 pointer 文件)。
  251. ws_root = root.parent if root.parent != root else None
  252. # 注意:ws_root 可能为 None(例如全局安装的 skills/agents,工作区内没有 `.claude/`)。
  253. # 这类情况仍然需要写入用户级 registry,以支持后续“空上下文”定位。
  254. pointer_file: Optional[Path] = None
  255. if ws_root is not None:
  256. # 仅当工作区内已经存在 `.claude/` 时才写入指针,避免在任意目录下“凭空创建 .claude/”。
  257. if (ws_root / ".claude").is_dir():
  258. try:
  259. pointer_file = ws_root / CURRENT_PROJECT_POINTER_REL
  260. pointer_file.write_text(str(root), encoding="utf-8")
  261. except Exception:
  262. pointer_file = None
  263. # best-effort 更新用户级 registry(不阻断)
  264. try:
  265. update_global_registry_current_project(workspace_root=ws_root, project_root=root)
  266. except Exception:
  267. pass
  268. return pointer_file
  269. def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Optional[Path] = None) -> Path:
  270. """
  271. Resolve the webnovel project root directory (the directory containing `.webnovel/state.json`).
  272. Resolution order:
  273. 1) explicit_project_root (if provided)
  274. 2) env var WEBNOVEL_PROJECT_ROOT (if set)
  275. 3) Search from cwd and parents, including common subdir `webnovel-project/`
  276. Search safety:
  277. - If current location is inside a Git repo, parent search stops at the repo root.
  278. This avoids accidentally binding to unrelated parent directories.
  279. Raises:
  280. FileNotFoundError: if no valid project root can be found.
  281. """
  282. if explicit_project_root:
  283. root = normalize_windows_path(explicit_project_root).expanduser().resolve()
  284. if _is_project_root(root):
  285. return root
  286. # 兼容:显式传入“工作区根目录”(含 `.claude/.webnovel-current-project` 指针)
  287. # 例如:D:\wk\xiaoshuo 不是项目根,但其指针指向 D:\wk\xiaoshuo\<书名>
  288. pointer_root = _resolve_project_root_from_pointer(root, stop_at=_find_git_root(root))
  289. if pointer_root is not None:
  290. return pointer_root
  291. # 兼容:显式传入“工作区根目录”但其 `.claude/` 在用户目录(全局安装)时,
  292. # workspace 内部可能没有指针文件。此时从用户级 registry 查找。
  293. reg_root = _resolve_project_root_from_global_registry(
  294. root,
  295. workspace_hint=root,
  296. allow_last_used_fallback=False,
  297. )
  298. if reg_root is not None:
  299. return reg_root
  300. raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
  301. env_root = os.environ.get("WEBNOVEL_PROJECT_ROOT")
  302. if env_root:
  303. root = normalize_windows_path(env_root).expanduser().resolve()
  304. if _is_project_root(root):
  305. return root
  306. raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
  307. base = (cwd or Path.cwd()).resolve()
  308. git_root = _find_git_root(base)
  309. # Workspace pointer fallback (for layouts where `.claude` is in workspace root and projects are subdirs).
  310. pointer_root = _resolve_project_root_from_pointer(base, stop_at=git_root)
  311. if pointer_root is not None:
  312. return pointer_root
  313. # 用户级 registry fallback(仅在“有上下文提示”时启用,避免误命中)
  314. # - 若 CLAUDE_PROJECT_DIR 存在:认为 Claude Code 提供了工作区上下文
  315. # - 否则仅在 base 位于某个已记录 workspace 内时启用(前缀匹配)
  316. allow_last_used = bool(os.environ.get(ENV_CLAUDE_PROJECT_DIR))
  317. reg_root = _resolve_project_root_from_global_registry(
  318. base,
  319. workspace_hint=None,
  320. allow_last_used_fallback=allow_last_used,
  321. )
  322. if reg_root is not None:
  323. return reg_root
  324. for candidate in _candidate_roots(base, stop_at=git_root):
  325. if _is_project_root(candidate):
  326. return candidate.resolve()
  327. raise FileNotFoundError(
  328. "Unable to locate webnovel project root. Expected `.webnovel/state.json` under the current directory, "
  329. "a parent directory, or `webnovel-project/`. Run /webnovel-init first or pass --project-root / set "
  330. "WEBNOVEL_PROJECT_ROOT."
  331. )
  332. def resolve_state_file(
  333. explicit_state_file: Optional[str] = None,
  334. *,
  335. explicit_project_root: Optional[str] = None,
  336. cwd: Optional[Path] = None,
  337. ) -> Path:
  338. """
  339. Resolve `.webnovel/state.json` path.
  340. If explicit_state_file is provided, returns it as-is (resolved to absolute if relative).
  341. Otherwise derives it from resolve_project_root().
  342. """
  343. base = (cwd or Path.cwd()).resolve()
  344. if explicit_state_file:
  345. p = Path(explicit_state_file).expanduser()
  346. return (base / p).resolve() if not p.is_absolute() else p.resolve()
  347. root = resolve_project_root(explicit_project_root, cwd=base)
  348. return root / ".webnovel" / "state.json"