project_locator.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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 os
  12. from pathlib import Path
  13. from typing import Iterable, Optional
  14. DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",)
  15. CURRENT_PROJECT_POINTER_REL: Path = Path(".claude") / ".webnovel-current-project"
  16. def _find_git_root(cwd: Path) -> Optional[Path]:
  17. """Return nearest git root for cwd, if any."""
  18. for candidate in (cwd, *cwd.parents):
  19. if (candidate / ".git").exists():
  20. return candidate
  21. return None
  22. def _candidate_roots(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
  23. yield cwd
  24. for name in DEFAULT_PROJECT_DIR_NAMES:
  25. yield cwd / name
  26. for parent in cwd.parents:
  27. yield parent
  28. for name in DEFAULT_PROJECT_DIR_NAMES:
  29. yield parent / name
  30. if stop_at is not None and parent == stop_at:
  31. break
  32. def _is_project_root(path: Path) -> bool:
  33. return (path / ".webnovel" / "state.json").is_file()
  34. def _pointer_candidates(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
  35. """Yield candidate pointer files from cwd up to parents (bounded by stop_at when provided)."""
  36. for candidate in (cwd, *cwd.parents):
  37. yield candidate / CURRENT_PROJECT_POINTER_REL
  38. if stop_at is not None and candidate == stop_at:
  39. break
  40. def _resolve_project_root_from_pointer(cwd: Path, *, stop_at: Optional[Path] = None) -> Optional[Path]:
  41. """
  42. Resolve project root from workspace pointer file.
  43. Pointer file format:
  44. - plain text absolute path, one line.
  45. - relative path is also supported (resolved relative to pointer's `.claude/` dir).
  46. """
  47. for pointer_file in _pointer_candidates(cwd, stop_at=stop_at):
  48. if not pointer_file.is_file():
  49. continue
  50. raw = pointer_file.read_text(encoding="utf-8").strip()
  51. if not raw:
  52. continue
  53. target = Path(raw).expanduser()
  54. if not target.is_absolute():
  55. target = (pointer_file.parent / target).resolve()
  56. if _is_project_root(target):
  57. return target.resolve()
  58. return None
  59. def _find_workspace_root_with_claude(start: Path) -> Optional[Path]:
  60. """Find nearest ancestor containing `.claude/`."""
  61. for candidate in (start, *start.parents):
  62. if (candidate / ".claude").is_dir():
  63. return candidate
  64. return None
  65. def write_current_project_pointer(project_root: Path, *, workspace_root: Optional[Path] = None) -> Optional[Path]:
  66. """
  67. Write workspace-level current project pointer and return pointer file path.
  68. If no workspace root with `.claude/` can be found, returns None (non-fatal).
  69. """
  70. root = Path(project_root).expanduser().resolve()
  71. if not _is_project_root(root):
  72. raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
  73. ws_root = Path(workspace_root).expanduser().resolve() if workspace_root else _find_workspace_root_with_claude(root)
  74. if ws_root is None:
  75. ws_root = _find_workspace_root_with_claude(Path.cwd().resolve())
  76. if ws_root is None:
  77. return None
  78. pointer_file = ws_root / CURRENT_PROJECT_POINTER_REL
  79. pointer_file.parent.mkdir(parents=True, exist_ok=True)
  80. pointer_file.write_text(str(root), encoding="utf-8")
  81. return pointer_file
  82. def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Optional[Path] = None) -> Path:
  83. """
  84. Resolve the webnovel project root directory (the directory containing `.webnovel/state.json`).
  85. Resolution order:
  86. 1) explicit_project_root (if provided)
  87. 2) env var WEBNOVEL_PROJECT_ROOT (if set)
  88. 3) Search from cwd and parents, including common subdir `webnovel-project/`
  89. Search safety:
  90. - If current location is inside a Git repo, parent search stops at the repo root.
  91. This avoids accidentally binding to unrelated parent directories.
  92. Raises:
  93. FileNotFoundError: if no valid project root can be found.
  94. """
  95. if explicit_project_root:
  96. root = Path(explicit_project_root).expanduser().resolve()
  97. if _is_project_root(root):
  98. return root
  99. raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
  100. env_root = os.environ.get("WEBNOVEL_PROJECT_ROOT")
  101. if env_root:
  102. root = Path(env_root).expanduser().resolve()
  103. if _is_project_root(root):
  104. return root
  105. raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
  106. base = (cwd or Path.cwd()).resolve()
  107. git_root = _find_git_root(base)
  108. # Workspace pointer fallback (for layouts where `.claude` is in workspace root and projects are subdirs).
  109. pointer_root = _resolve_project_root_from_pointer(base, stop_at=git_root)
  110. if pointer_root is not None:
  111. return pointer_root
  112. for candidate in _candidate_roots(base, stop_at=git_root):
  113. if _is_project_root(candidate):
  114. return candidate.resolve()
  115. raise FileNotFoundError(
  116. "Unable to locate webnovel project root. Expected `.webnovel/state.json` under the current directory, "
  117. "a parent directory, or `webnovel-project/`. Run /webnovel-init first or pass --project-root / set "
  118. "WEBNOVEL_PROJECT_ROOT."
  119. )
  120. def resolve_state_file(
  121. explicit_state_file: Optional[str] = None,
  122. *,
  123. explicit_project_root: Optional[str] = None,
  124. cwd: Optional[Path] = None,
  125. ) -> Path:
  126. """
  127. Resolve `.webnovel/state.json` path.
  128. If explicit_state_file is provided, returns it as-is (resolved to absolute if relative).
  129. Otherwise derives it from resolve_project_root().
  130. """
  131. base = (cwd or Path.cwd()).resolve()
  132. if explicit_state_file:
  133. p = Path(explicit_state_file).expanduser()
  134. return (base / p).resolve() if not p.is_absolute() else p.resolve()
  135. root = resolve_project_root(explicit_project_root, cwd=base)
  136. return root / ".webnovel" / "state.json"