#!/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 os from pathlib import Path from typing import Iterable, Optional DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",) CURRENT_PROJECT_POINTER_REL: Path = Path(".claude") / ".webnovel-current-project" 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 _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 = 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 = 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: return None 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 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 = Path(explicit_project_root).expanduser().resolve() if _is_project_root(root): return 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() 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 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"