| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429 |
- #!/usr/bin/env python3
- """
- Project location helpers for webnovel-writer scripts.
- Problem this solves:
- - Many scripts assumed CWD is the project root and used relative paths like `.webnovel/state.json`.
- - In this repo, commands/scripts are often invoked from the repo root, while the actual project lives
- in a subdirectory (default: `webnovel-project/`).
- These helpers provide a single, consistent way to locate the active project root.
- """
- from __future__ import annotations
- import json
- import os
- from datetime import datetime
- from pathlib import Path
- from typing import Iterable, Optional
- from runtime_compat import normalize_windows_path
- DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",)
- CURRENT_PROJECT_POINTER_REL: Path = Path(".claude") / ".webnovel-current-project"
- # 用户级全局映射(当 skills/agents 安装在 ~/.claude 时,项目目录可能在任意盘符)
- # 该文件用于在“空上下文 + CWD 不在项目内”的情况下仍能定位到正确 project_root。
- GLOBAL_REGISTRY_REL: Path = Path("webnovel-writer") / "workspaces.json"
- # Claude Code 常见环境变量(存在时优先作为“工作区根目录”提示)
- ENV_CLAUDE_PROJECT_DIR = "CLAUDE_PROJECT_DIR"
- ENV_CLAUDE_HOME = "CLAUDE_HOME"
- ENV_WEBNOVEL_CLAUDE_HOME = "WEBNOVEL_CLAUDE_HOME"
- def _find_git_root(cwd: Path) -> Optional[Path]:
- """Return nearest git root for cwd, if any."""
- for candidate in (cwd, *cwd.parents):
- if (candidate / ".git").exists():
- return candidate
- return None
- def _now_iso() -> str:
- return datetime.now().isoformat(timespec="seconds")
- def _normcase_path_key(p: Path) -> str:
- """
- 生成稳定的路径 key(Windows 下大小写/分隔符不敏感)。
- 注意:key 仅用于映射表索引,实际路径仍以原始绝对路径字符串存储。
- """
- try:
- resolved = p.expanduser().resolve()
- except Exception:
- resolved = p.expanduser()
- return os.path.normcase(str(resolved))
- def _get_user_claude_root() -> Path:
- raw = os.environ.get(ENV_WEBNOVEL_CLAUDE_HOME) or os.environ.get(ENV_CLAUDE_HOME)
- if raw:
- try:
- return normalize_windows_path(raw).expanduser().resolve()
- except Exception:
- return normalize_windows_path(raw).expanduser()
- return (Path.home() / ".claude").resolve()
- def _global_registry_path() -> Path:
- return _get_user_claude_root() / GLOBAL_REGISTRY_REL
- def _default_registry() -> dict:
- return {
- "schema_version": 1,
- "workspaces": {},
- "last_used_project_root": "",
- "updated_at": _now_iso(),
- }
- def _load_global_registry(path: Path) -> dict:
- if not path.is_file():
- return _default_registry()
- try:
- data = json.loads(path.read_text(encoding="utf-8") or "{}")
- except Exception:
- return _default_registry()
- if not isinstance(data, dict):
- return _default_registry()
- if data.get("schema_version") != 1:
- data["schema_version"] = 1
- if not isinstance(data.get("workspaces"), dict):
- data["workspaces"] = {}
- if not isinstance(data.get("last_used_project_root"), str):
- data["last_used_project_root"] = ""
- if not isinstance(data.get("updated_at"), str):
- data["updated_at"] = _now_iso()
- return data
- def _save_global_registry(path: Path, data: dict) -> None:
- # 写入是 best-effort:用户目录权限/只读盘符等情况不应阻断主流程。
- try:
- from security_utils import atomic_write_json
- data["updated_at"] = _now_iso()
- atomic_write_json(path, data, backup=False)
- except Exception:
- # 非阻断
- return
- def _resolve_project_root_from_global_registry(
- base: Path,
- *,
- workspace_hint: Optional[Path] = None,
- allow_last_used_fallback: bool = False,
- ) -> Optional[Path]:
- """
- 从用户级 registry 中解析 project_root。
- 安全策略:
- - 优先使用 workspace_hint / CLAUDE_PROJECT_DIR 提示做匹配。
- - 默认不使用 last_used 兜底,避免在“完全无上下文”时误命中错误项目。
- """
- reg_path = _global_registry_path()
- reg = _load_global_registry(reg_path)
- workspaces = reg.get("workspaces") or {}
- if not isinstance(workspaces, dict) or not workspaces:
- return None
- hints: list[Path] = []
- env_ws = os.environ.get(ENV_CLAUDE_PROJECT_DIR)
- if env_ws:
- hints.append(normalize_windows_path(env_ws).expanduser())
- if workspace_hint is not None:
- hints.append(workspace_hint)
- hints.append(base)
- # 1) 精确匹配
- for hint in hints:
- key = _normcase_path_key(hint)
- entry = workspaces.get(key)
- if isinstance(entry, dict):
- raw = entry.get("current_project_root")
- if isinstance(raw, str) and raw.strip():
- target = normalize_windows_path(raw).expanduser()
- if not target.is_absolute():
- continue
- if _is_project_root(target):
- return target.resolve()
- # 2) 前缀匹配(从 workspace 子目录运行时)
- for hint in hints:
- hint_key = _normcase_path_key(hint)
- best_key: Optional[str] = None
- best_len = -1
- for ws_key in workspaces.keys():
- if not isinstance(ws_key, str) or not ws_key:
- continue
- ws_key_norm = os.path.normcase(ws_key)
- if hint_key == ws_key_norm or hint_key.startswith(ws_key_norm.rstrip("\\") + "\\"):
- if len(ws_key_norm) > best_len:
- best_key = ws_key
- best_len = len(ws_key_norm)
- if best_key:
- entry = workspaces.get(best_key)
- if isinstance(entry, dict):
- raw = entry.get("current_project_root")
- if isinstance(raw, str) and raw.strip():
- target = normalize_windows_path(raw).expanduser()
- if target.is_absolute() and _is_project_root(target):
- return target.resolve()
- # 3) last_used(可选,默认关闭)
- if allow_last_used_fallback:
- raw = reg.get("last_used_project_root")
- if isinstance(raw, str) and raw.strip():
- target = normalize_windows_path(raw).expanduser()
- if target.is_absolute() and _is_project_root(target):
- return target.resolve()
- return None
- def update_global_registry_current_project(
- *,
- workspace_root: Optional[Path],
- project_root: Path,
- ) -> Optional[Path]:
- """
- 更新用户级 registry:workspace -> current_project_root 映射。
- 返回:registry 文件路径(写入失败则返回 None)。
- """
- root = normalize_windows_path(project_root).expanduser()
- try:
- root = root.resolve()
- except Exception:
- root = root
- if not _is_project_root(root):
- raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
- ws = workspace_root
- if ws is None:
- env_ws = os.environ.get(ENV_CLAUDE_PROJECT_DIR)
- if env_ws:
- ws = normalize_windows_path(env_ws).expanduser()
- if ws is None:
- return None
- try:
- ws = ws.expanduser().resolve()
- except Exception:
- ws = ws.expanduser()
- reg_path = _global_registry_path()
- reg = _load_global_registry(reg_path)
- workspaces = reg.get("workspaces")
- if not isinstance(workspaces, dict):
- workspaces = {}
- reg["workspaces"] = workspaces
- workspaces[_normcase_path_key(ws)] = {
- "workspace_root": str(ws),
- "current_project_root": str(root),
- "updated_at": _now_iso(),
- }
- reg["last_used_project_root"] = str(root)
- _save_global_registry(reg_path, reg)
- return reg_path
- def _candidate_roots(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
- yield cwd
- for name in DEFAULT_PROJECT_DIR_NAMES:
- yield cwd / name
- for parent in cwd.parents:
- yield parent
- for name in DEFAULT_PROJECT_DIR_NAMES:
- yield parent / name
- if stop_at is not None and parent == stop_at:
- break
- def _is_project_root(path: Path) -> bool:
- return (path / ".webnovel" / "state.json").is_file()
- def _pointer_candidates(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
- """Yield candidate pointer files from cwd up to parents (bounded by stop_at when provided)."""
- for candidate in (cwd, *cwd.parents):
- yield candidate / CURRENT_PROJECT_POINTER_REL
- if stop_at is not None and candidate == stop_at:
- break
- def _resolve_project_root_from_pointer(cwd: Path, *, stop_at: Optional[Path] = None) -> Optional[Path]:
- """
- Resolve project root from workspace pointer file.
- Pointer file format:
- - plain text absolute path, one line.
- - relative path is also supported (resolved relative to pointer's `.claude/` dir).
- """
- for pointer_file in _pointer_candidates(cwd, stop_at=stop_at):
- if not pointer_file.is_file():
- continue
- raw = pointer_file.read_text(encoding="utf-8").strip()
- if not raw:
- continue
- target = normalize_windows_path(raw).expanduser()
- if not target.is_absolute():
- target = (pointer_file.parent / target).resolve()
- if _is_project_root(target):
- return target.resolve()
- return None
- def _find_workspace_root_with_claude(start: Path) -> Optional[Path]:
- """Find nearest ancestor containing `.claude/`."""
- for candidate in (start, *start.parents):
- if (candidate / ".claude").is_dir():
- return candidate
- return None
- def write_current_project_pointer(project_root: Path, *, workspace_root: Optional[Path] = None) -> Optional[Path]:
- """
- Write workspace-level current project pointer and return pointer file path.
- If no workspace root with `.claude/` can be found, returns None (non-fatal).
- """
- root = normalize_windows_path(project_root).expanduser().resolve()
- if not _is_project_root(root):
- raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
- ws_root = Path(workspace_root).expanduser().resolve() if workspace_root else _find_workspace_root_with_claude(root)
- if ws_root is None:
- ws_root = _find_workspace_root_with_claude(Path.cwd().resolve())
- if ws_root is None:
- # 兜底:若无法找到 `.claude/`,将项目父目录视为“工作区”候选,
- # 仅用于写入用户级 registry(不创建 `.claude/` 目录,不写 pointer 文件)。
- ws_root = root.parent if root.parent != root else None
- # 注意:ws_root 可能为 None(例如全局安装的 skills/agents,工作区内没有 `.claude/`)。
- # 这类情况仍然需要写入用户级 registry,以支持后续“空上下文”定位。
- pointer_file: Optional[Path] = None
- if ws_root is not None:
- # 仅当工作区内已经存在 `.claude/` 时才写入指针,避免在任意目录下“凭空创建 .claude/”。
- if (ws_root / ".claude").is_dir():
- try:
- pointer_file = ws_root / CURRENT_PROJECT_POINTER_REL
- pointer_file.write_text(str(root), encoding="utf-8")
- except Exception:
- pointer_file = None
- # best-effort 更新用户级 registry(不阻断)
- try:
- update_global_registry_current_project(workspace_root=ws_root, project_root=root)
- except Exception:
- pass
- return pointer_file
- def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Optional[Path] = None) -> Path:
- """
- Resolve the webnovel project root directory (the directory containing `.webnovel/state.json`).
- Resolution order:
- 1) explicit_project_root (if provided)
- 2) env var WEBNOVEL_PROJECT_ROOT (if set)
- 3) Search from cwd and parents, including common subdir `webnovel-project/`
- Search safety:
- - If current location is inside a Git repo, parent search stops at the repo root.
- This avoids accidentally binding to unrelated parent directories.
- Raises:
- FileNotFoundError: if no valid project root can be found.
- """
- if explicit_project_root:
- root = normalize_windows_path(explicit_project_root).expanduser().resolve()
- if _is_project_root(root):
- return root
- # 兼容:显式传入“工作区根目录”(含 `.claude/.webnovel-current-project` 指针)
- # 例如:D:\wk\xiaoshuo 不是项目根,但其指针指向 D:\wk\xiaoshuo\<书名>
- pointer_root = _resolve_project_root_from_pointer(root, stop_at=_find_git_root(root))
- if pointer_root is not None:
- return pointer_root
- # 兼容:显式传入“工作区根目录”但其 `.claude/` 在用户目录(全局安装)时,
- # workspace 内部可能没有指针文件。此时从用户级 registry 查找。
- reg_root = _resolve_project_root_from_global_registry(
- root,
- workspace_hint=root,
- allow_last_used_fallback=False,
- )
- if reg_root is not None:
- return reg_root
- raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
- env_root = os.environ.get("WEBNOVEL_PROJECT_ROOT")
- if env_root:
- root = normalize_windows_path(env_root).expanduser().resolve()
- if _is_project_root(root):
- return root
- raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
- base = (cwd or Path.cwd()).resolve()
- git_root = _find_git_root(base)
- # Workspace pointer fallback (for layouts where `.claude` is in workspace root and projects are subdirs).
- pointer_root = _resolve_project_root_from_pointer(base, stop_at=git_root)
- if pointer_root is not None:
- return pointer_root
- # 用户级 registry fallback(仅在“有上下文提示”时启用,避免误命中)
- # - 若 CLAUDE_PROJECT_DIR 存在:认为 Claude Code 提供了工作区上下文
- # - 否则仅在 base 位于某个已记录 workspace 内时启用(前缀匹配)
- allow_last_used = bool(os.environ.get(ENV_CLAUDE_PROJECT_DIR))
- reg_root = _resolve_project_root_from_global_registry(
- base,
- workspace_hint=None,
- allow_last_used_fallback=allow_last_used,
- )
- if reg_root is not None:
- return reg_root
- for candidate in _candidate_roots(base, stop_at=git_root):
- if _is_project_root(candidate):
- return candidate.resolve()
- raise FileNotFoundError(
- "Unable to locate webnovel project root. Expected `.webnovel/state.json` under the current directory, "
- "a parent directory, or `webnovel-project/`. Run /webnovel-init first or pass --project-root / set "
- "WEBNOVEL_PROJECT_ROOT."
- )
- def resolve_state_file(
- explicit_state_file: Optional[str] = None,
- *,
- explicit_project_root: Optional[str] = None,
- cwd: Optional[Path] = None,
- ) -> Path:
- """
- Resolve `.webnovel/state.json` path.
- If explicit_state_file is provided, returns it as-is (resolved to absolute if relative).
- Otherwise derives it from resolve_project_root().
- """
- base = (cwd or Path.cwd()).resolve()
- if explicit_state_file:
- p = Path(explicit_state_file).expanduser()
- return (base / p).resolve() if not p.is_absolute() else p.resolve()
- root = resolve_project_root(explicit_project_root, cwd=base)
- return root / ".webnovel" / "state.json"
|