|
|
@@ -12,14 +12,27 @@ 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."""
|
|
|
@@ -29,6 +42,200 @@ def _find_git_root(cwd: Path) -> Optional[Path]:
|
|
|
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:
|
|
|
@@ -68,7 +275,7 @@ def _resolve_project_root_from_pointer(cwd: Path, *, stop_at: Optional[Path] = N
|
|
|
raw = pointer_file.read_text(encoding="utf-8").strip()
|
|
|
if not raw:
|
|
|
continue
|
|
|
- target = Path(raw).expanduser()
|
|
|
+ target = normalize_windows_path(raw).expanduser()
|
|
|
if not target.is_absolute():
|
|
|
target = (pointer_file.parent / target).resolve()
|
|
|
if _is_project_root(target):
|
|
|
@@ -90,7 +297,7 @@ def write_current_project_pointer(project_root: Path, *, workspace_root: Optiona
|
|
|
|
|
|
If no workspace root with `.claude/` can be found, returns None (non-fatal).
|
|
|
"""
|
|
|
- root = Path(project_root).expanduser().resolve()
|
|
|
+ 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}")
|
|
|
|
|
|
@@ -98,11 +305,28 @@ def write_current_project_pointer(project_root: Path, *, workspace_root: Optiona
|
|
|
if ws_root is None:
|
|
|
ws_root = _find_workspace_root_with_claude(Path.cwd().resolve())
|
|
|
if ws_root is None:
|
|
|
- return 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
|
|
|
|
|
|
- pointer_file = ws_root / CURRENT_PROJECT_POINTER_REL
|
|
|
- pointer_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
- pointer_file.write_text(str(root), encoding="utf-8")
|
|
|
return pointer_file
|
|
|
|
|
|
|
|
|
@@ -123,14 +347,31 @@ def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Op
|
|
|
FileNotFoundError: if no valid project root can be found.
|
|
|
"""
|
|
|
if explicit_project_root:
|
|
|
- root = Path(explicit_project_root).expanduser().resolve()
|
|
|
+ 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 = Path(env_root).expanduser().resolve()
|
|
|
+ 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}")
|
|
|
@@ -143,6 +384,18 @@ def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Op
|
|
|
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()
|