multi-forge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,1037 @@
1
+ """Core installer logic.
2
+
3
+ Provides plan(), init(), update(), and uninstall() operations for
4
+ managing Claude Code extensions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import shutil
11
+ import subprocess
12
+ from copy import deepcopy
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ # Import for CLAUDE_HOME support
18
+ from forge.session.claude.paths import get_claude_home
19
+
20
+ from .exceptions import (
21
+ NoClaudeDirectoryError,
22
+ NoForgeInstallationError,
23
+ NotInstalledError,
24
+ PathBoundaryViolationError,
25
+ )
26
+ from .models import (
27
+ MODULE_DEPENDENCIES,
28
+ PROFILE_MODULES,
29
+ PROFILE_RANK,
30
+ SETTINGS_ONLY_MODULES,
31
+ SKILL_PROFILE_REQUIREMENTS,
32
+ FilePlan,
33
+ Installation,
34
+ InstalledFile,
35
+ InstallMode,
36
+ InstallModule,
37
+ InstallPlan,
38
+ InstallProfile,
39
+ InstallScope,
40
+ SettingsPlan,
41
+ now_iso,
42
+ )
43
+ from .settings_merge import (
44
+ backup_settings,
45
+ cleanup_empty_settings,
46
+ entries_to_added_structure,
47
+ find_added_files,
48
+ find_backup_files,
49
+ get_settings_path,
50
+ hooks_already_present,
51
+ load_added_settings,
52
+ merge,
53
+ permissions_already_present,
54
+ read_settings,
55
+ save_added_settings,
56
+ scalar_already_set,
57
+ settings_equal,
58
+ smart_unmerge,
59
+ unmerge,
60
+ write_settings,
61
+ )
62
+ from .tracking import TrackingStore, compute_checksum
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ _EXTENSION_MODULE_NAMES = ("skills", "agents", "commands")
68
+
69
+
70
+ def get_forge_source_root() -> Path:
71
+ """Get the forge repo source root (for git-tracked file filtering).
72
+
73
+ Returns the repo root when running from a checkout; returns a
74
+ best-effort path otherwise (git operations will gracefully fail).
75
+ """
76
+ return Path(__file__).parent.parent.parent.parent
77
+
78
+
79
+ def _is_repo_checkout(forge_source: Path) -> bool:
80
+ """Return True if forge_source looks like the Forge dev repo.
81
+
82
+ Requires both the Python package (src/forge/) AND at least one extension
83
+ directory to be present. The two-signal check rules out false positives
84
+ like a user project that happens to have src/skills/ but isn't a Forge
85
+ checkout.
86
+ """
87
+ src = forge_source / "src"
88
+ if not (src / "forge").is_dir():
89
+ return False
90
+ return any((src / name).is_dir() for name in _EXTENSION_MODULE_NAMES)
91
+
92
+
93
+ def _get_bundled_extensions_path() -> Path:
94
+ """Return the path to bundled extensions inside the installed package.
95
+
96
+ Uses importlib.resources to locate package data — robust against
97
+ zip imports and namespace package layouts. Extracted as a separate
98
+ function so tests can mock it cleanly.
99
+ """
100
+ return Path(str(files("forge") / "_extensions"))
101
+
102
+
103
+ def get_extensions_root() -> Path:
104
+ """Get the directory containing extension modules (skills, agents, commands).
105
+
106
+ Tries repo checkout first (editable/dev install), then falls back
107
+ to bundled extensions inside the wheel (forge/_extensions/).
108
+ """
109
+ forge_source = get_forge_source_root()
110
+ if _is_repo_checkout(forge_source):
111
+ return forge_source / "src"
112
+
113
+ bundled = _get_bundled_extensions_path()
114
+ if bundled.is_dir():
115
+ return bundled
116
+
117
+ raise FileNotFoundError("Extension source files not found. Reinstall Forge or run from a repo checkout.")
118
+
119
+
120
+ _EXCLUDED_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
121
+ _EXCLUDED_EXTENSIONS = {".pyc", ".pyo"}
122
+
123
+
124
+ def _is_installable(path: Path) -> bool:
125
+ """Return False for build artifacts that should never be installed."""
126
+ if path.name.startswith("."):
127
+ return False
128
+ if path.suffix in _EXCLUDED_EXTENSIONS:
129
+ return False
130
+ if _EXCLUDED_DIR_NAMES & set(path.parts):
131
+ return False
132
+ return True
133
+
134
+
135
+ def _get_git_tracked_files(repo_root: Path) -> set[Path] | None:
136
+ """Return the set of git-tracked files under repo_root, or None if unavailable."""
137
+ try:
138
+ result = subprocess.run(
139
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
140
+ cwd=repo_root,
141
+ capture_output=True,
142
+ text=True,
143
+ timeout=5,
144
+ )
145
+ if result.returncode != 0:
146
+ return None
147
+ return {repo_root / line for line in result.stdout.splitlines() if line}
148
+ except (OSError, subprocess.TimeoutExpired):
149
+ return None
150
+
151
+
152
+ def get_target_root(scope: InstallScope, project_root: Path | None = None) -> Path:
153
+ """Get target directory for extensions.
154
+
155
+ Args:
156
+ scope: Installation scope.
157
+ project_root: Project root (required for PROJECT/LOCAL).
158
+
159
+ Returns:
160
+ Path to target .claude directory.
161
+
162
+ Raises:
163
+ ValueError: If project_root required but not provided.
164
+ NestedClaudeDirectoryError: If project_root is inside a .claude directory.
165
+ """
166
+ if scope == InstallScope.USER:
167
+ return get_claude_home()
168
+ else:
169
+ if project_root is None:
170
+ raise ValueError("project_root required for PROJECT/LOCAL scope")
171
+
172
+ # Guard against nested .claude directories (e.g., running from .claude/)
173
+ resolved = project_root.resolve()
174
+ if ".claude" in resolved.parts:
175
+ from .exceptions import NestedClaudeDirectoryError
176
+
177
+ raise NestedClaudeDirectoryError(str(project_root))
178
+
179
+ return project_root / ".claude"
180
+
181
+
182
+ def validate_path_within_boundary(
183
+ path: Path,
184
+ boundary: Path,
185
+ operation: str = "delete",
186
+ ) -> None:
187
+ """Validate that a path is within the expected boundary.
188
+
189
+ Security check to prevent malicious tracking file modifications
190
+ from causing deletion of arbitrary system files.
191
+
192
+ Args:
193
+ path: The path to validate.
194
+ boundary: The expected parent directory.
195
+ operation: Description of the operation (for error messages).
196
+
197
+ Raises:
198
+ PathBoundaryViolationError: If path is not within boundary.
199
+ """
200
+ # Always use parent.resolve() / name to get the absolute path of the entry
201
+ # itself, without following symlinks on the final component. This:
202
+ # 1. Handles symlinks correctly (checks location, not target)
203
+ # 2. Handles non-existent paths consistently (is_symlink() returns False
204
+ # for non-existent paths, so we'd otherwise fall back to resolve())
205
+ # 3. Still canonicalizes any symlink directories in the parent chain
206
+ resolved_path = path.parent.resolve() / path.name
207
+ resolved_boundary = boundary.resolve()
208
+
209
+ if not resolved_path.is_relative_to(resolved_boundary):
210
+ raise PathBoundaryViolationError(str(path), str(boundary), operation)
211
+
212
+
213
+ def find_claude_root(
214
+ start: Path | None = None,
215
+ *,
216
+ max_depth: int = 100,
217
+ ) -> tuple[InstallScope, Path | None]:
218
+ """Find the nearest .claude directory walking up from start.
219
+
220
+ Used by `forge init` to auto-detect scope. Walks up from start directory
221
+ looking for a .claude/ directory. If found, returns LOCAL scope at that
222
+ project. If reaching home directory (~), returns USER scope.
223
+
224
+ Args:
225
+ start: Starting directory. Defaults to cwd.
226
+ max_depth: Maximum directory levels to traverse (safety limit).
227
+
228
+ Returns:
229
+ Tuple of (scope, project_root). For USER scope, project_root is None.
230
+
231
+ Raises:
232
+ NoClaudeDirectoryError: If no .claude found and didn't reach home,
233
+ or if max_depth is exceeded.
234
+ """
235
+ if start is None:
236
+ start = Path.cwd()
237
+
238
+ current = start.resolve()
239
+ home = Path.home().resolve()
240
+
241
+ for _ in range(max_depth):
242
+ claude_dir = current / ".claude"
243
+ if claude_dir.is_dir():
244
+ if current == home:
245
+ return (InstallScope.USER, None)
246
+ return (InstallScope.LOCAL, current)
247
+
248
+ if current == home:
249
+ # Special case: at home, use USER scope
250
+ return (InstallScope.USER, None)
251
+
252
+ parent = current.parent
253
+ if parent == current:
254
+ raise NoClaudeDirectoryError(str(start))
255
+
256
+ current = parent
257
+
258
+ # Safety limit exceeded (symlink loop, permission issues, etc.)
259
+ raise NoClaudeDirectoryError(f"{start} (exceeded max traversal depth of {max_depth})")
260
+
261
+
262
+ def find_forge_installation(
263
+ start: Path | None = None,
264
+ tracking: "TrackingStore | None" = None,
265
+ ) -> tuple[InstallScope, Path | None]:
266
+ """Find the nearest Forge installation walking up from start.
267
+
268
+ Used by `forge uninstall`, `forge update`, etc. to auto-detect scope.
269
+ Walks up from start directory, checking LOCAL then PROJECT at each level,
270
+ then USER at home.
271
+
272
+ Detection is based on file evidence (.settings.*.json.forge.* files)
273
+ which works across multiple projects, not just tracking store state.
274
+
275
+ Args:
276
+ start: Starting directory. Defaults to cwd.
277
+ tracking: TrackingStore instance. Created if not provided.
278
+
279
+ Returns:
280
+ Tuple of (scope, project_root). For USER scope, project_root is None.
281
+
282
+ Raises:
283
+ NoForgeInstallationError: If no installation found.
284
+ """
285
+ if start is None:
286
+ start = Path.cwd()
287
+ if tracking is None:
288
+ tracking = TrackingStore()
289
+
290
+ current = start.resolve()
291
+ home = Path.home().resolve()
292
+
293
+ while True:
294
+ claude_dir = current / ".claude"
295
+ if claude_dir.is_dir():
296
+ # Check LOCAL installation first (most specific) - file-based detection
297
+ local_settings = claude_dir / "settings.local.json"
298
+ local_backups = find_backup_files(local_settings)
299
+ local_added = find_added_files(local_settings)
300
+ if local_backups or local_added:
301
+ return (InstallScope.LOCAL, current)
302
+
303
+ project_settings = claude_dir / "settings.json"
304
+ project_backups = find_backup_files(project_settings)
305
+ project_added = find_added_files(project_settings)
306
+ # Only check project at non-home locations (home uses USER scope)
307
+ if current != home and (project_backups or project_added):
308
+ return (InstallScope.PROJECT, current)
309
+
310
+ if current == home:
311
+ user_settings = home / ".claude" / "settings.json"
312
+ user_backups = find_backup_files(user_settings)
313
+ user_added = find_added_files(user_settings)
314
+ if user_backups or user_added:
315
+ return (InstallScope.USER, None)
316
+ # Fallback to tracking store for USER (no project_path for user scope)
317
+ if tracking.get_installation(InstallScope.USER.value, None):
318
+ return (InstallScope.USER, None)
319
+ break
320
+
321
+ parent = current.parent
322
+ if parent == current:
323
+ break
324
+
325
+ current = parent
326
+
327
+ # No installation found
328
+ raise NoForgeInstallationError(str(start))
329
+
330
+
331
+ def resolve_modules(
332
+ profile: InstallProfile,
333
+ with_modules: set[InstallModule] | None = None,
334
+ without_modules: set[InstallModule] | None = None,
335
+ ) -> set[InstallModule]:
336
+ """Resolve final module set from profile and toggles.
337
+
338
+ Args:
339
+ profile: Base profile.
340
+ with_modules: Modules to add.
341
+ without_modules: Modules to remove.
342
+
343
+ Returns:
344
+ Final set of modules to install.
345
+ """
346
+ modules = PROFILE_MODULES[profile].copy()
347
+
348
+ if with_modules:
349
+ modules |= with_modules
350
+
351
+ if without_modules:
352
+ modules -= without_modules
353
+
354
+ for module in list(modules):
355
+ if deps := MODULE_DEPENDENCIES.get(module):
356
+ modules |= deps
357
+
358
+ return modules
359
+
360
+
361
+ def get_module_source_dir(module: InstallModule) -> str:
362
+ """Get source directory name for a module.
363
+
364
+ Args:
365
+ module: The module.
366
+
367
+ Returns:
368
+ Directory name (e.g., "commands", "agents").
369
+ """
370
+ return module.value
371
+
372
+
373
+ class Installer:
374
+ """Main installer for Forge extensions.
375
+
376
+ Handles plan, init, update, and uninstall operations.
377
+ """
378
+
379
+ def __init__(
380
+ self,
381
+ scope: InstallScope = InstallScope.USER,
382
+ project_root: Path | None = None,
383
+ tracking_store: TrackingStore | None = None,
384
+ ) -> None:
385
+ """Initialize installer.
386
+
387
+ Args:
388
+ scope: Installation scope.
389
+ project_root: Project root (required for PROJECT/LOCAL).
390
+ tracking_store: Override tracking store (for testing).
391
+ """
392
+ self._scope = scope
393
+ self._project_root = project_root
394
+ self._tracking = tracking_store or TrackingStore()
395
+
396
+ @property
397
+ def _project_path_str(self) -> str | None:
398
+ """Get project path as string for tracking (None for user scope)."""
399
+ if self._scope == InstallScope.USER:
400
+ return None
401
+ return str(self._project_root) if self._project_root else None
402
+
403
+ def plan(
404
+ self,
405
+ profile: InstallProfile = InstallProfile.STANDARD,
406
+ mode: InstallMode = InstallMode.COPY,
407
+ with_modules: set[InstallModule] | None = None,
408
+ without_modules: set[InstallModule] | None = None,
409
+ force: bool = False,
410
+ *,
411
+ _modules_override: set[InstallModule] | None = None,
412
+ ) -> InstallPlan:
413
+ """Compute installation plan without making changes.
414
+
415
+ Args:
416
+ profile: Installation profile.
417
+ mode: Installation mode.
418
+ with_modules: Modules to add.
419
+ without_modules: Modules to remove.
420
+ force: If True, override conflicts.
421
+ _modules_override: Internal. If provided, use exactly these modules
422
+ instead of resolving from profile. Used by update() to ensure
423
+ only tracked modules are touched.
424
+
425
+ Returns:
426
+ InstallPlan describing what would be done.
427
+ """
428
+ if _modules_override is not None:
429
+ modules = _modules_override
430
+ else:
431
+ modules = resolve_modules(profile, with_modules, without_modules)
432
+
433
+ # Sort modules for deterministic output
434
+ sorted_modules = sorted(m.value for m in modules)
435
+
436
+ plan = InstallPlan(
437
+ scope=self._scope.value,
438
+ mode=mode.value,
439
+ profile=profile.value,
440
+ modules=sorted_modules,
441
+ )
442
+
443
+ source_root = get_extensions_root()
444
+ target_root = get_target_root(self._scope, self._project_root)
445
+ existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
446
+
447
+ # Only filter by git when extensions come from a repo checkout. When
448
+ # running from a wheel install, source_root is forge/_extensions/ inside
449
+ # site-packages — typically gitignored, so a git-tracked filter would
450
+ # exclude every file. _is_installable() handles the wheel-install case.
451
+ forge_source = get_forge_source_root()
452
+ git_tracked = _get_git_tracked_files(forge_source) if _is_repo_checkout(forge_source) else None
453
+
454
+ # Precompute installed skill names from manifest (skill-level, not file-level)
455
+ # so that update keeps the entire skill coherent when new files are added
456
+ installed_skills: set[str] = set()
457
+ if existing:
458
+ skills_prefix = str(target_root / "skills") + "/"
459
+ for f in existing.files:
460
+ if f.target_path.startswith(skills_prefix):
461
+ suffix = f.target_path[len(skills_prefix) :]
462
+ if "/" in suffix:
463
+ installed_skills.add(suffix.split("/", 1)[0])
464
+
465
+ for module in sorted(modules, key=lambda m: m.value):
466
+ if module in SETTINGS_ONLY_MODULES:
467
+ continue
468
+
469
+ source_dir = source_root / get_module_source_dir(module)
470
+ if not source_dir.is_dir():
471
+ # Source not yet in allowlist - silently skip
472
+ continue
473
+
474
+ target_dir = target_root / get_module_source_dir(module)
475
+
476
+ # Find installable source files (sorted for determinism)
477
+ # _is_installable excludes __pycache__/.pyc unconditionally (works in Docker
478
+ # where .git/ is absent and _get_git_tracked_files returns None).
479
+ source_files = sorted(
480
+ f
481
+ for f in source_dir.rglob("*")
482
+ if f.is_file() and _is_installable(f) and (git_tracked is None or f in git_tracked)
483
+ )
484
+
485
+ for source_file in source_files:
486
+ rel_path = source_file.relative_to(source_dir)
487
+ target_file = target_dir / rel_path
488
+
489
+ # Per-skill profile gating: skip skills that require a higher profile,
490
+ # unless the skill is already installed (update keeps entire skill coherent)
491
+ if module == InstallModule.SKILLS and rel_path.parts:
492
+ skill_name = rel_path.parts[0]
493
+ required = SKILL_PROFILE_REQUIREMENTS.get(skill_name)
494
+ if required and PROFILE_RANK[profile] < PROFILE_RANK[required]:
495
+ if skill_name not in installed_skills:
496
+ continue
497
+
498
+ file_plan = self._plan_file(source_file, target_file, mode, existing, force)
499
+ plan.files.append(file_plan)
500
+ if file_plan.action == "conflict":
501
+ plan.has_conflicts = True
502
+ plan.conflicts.append(f"File: {file_plan.target_path} - {file_plan.reason}")
503
+
504
+ # Sort files for deterministic output
505
+ plan.files.sort(key=lambda f: f.target_path)
506
+
507
+ settings_plans = self._plan_settings(modules, force)
508
+ plan.settings.extend(settings_plans)
509
+ for sp in settings_plans:
510
+ if sp.action == "conflict":
511
+ plan.has_conflicts = True
512
+ plan.conflicts.append(f"Setting: {sp.key_path} - {sp.reason}")
513
+
514
+ # Sort settings for determinism
515
+ plan.settings.sort(key=lambda s: (s.key_path, str(s.value)))
516
+
517
+ return plan
518
+
519
+ def _plan_file(
520
+ self,
521
+ source: Path,
522
+ target: Path,
523
+ mode: InstallMode,
524
+ existing: Installation | None,
525
+ force: bool,
526
+ ) -> FilePlan:
527
+ """Plan a single file operation.
528
+
529
+ Args:
530
+ source: Source file path.
531
+ target: Target file path.
532
+ mode: Installation mode.
533
+ existing: Existing installation (if any).
534
+ force: If True, override conflicts.
535
+
536
+ Returns:
537
+ FilePlan for this file.
538
+ """
539
+ if not target.exists() and not target.is_symlink():
540
+ return FilePlan(
541
+ action="install",
542
+ target_path=str(target),
543
+ source_path=str(source),
544
+ )
545
+
546
+ is_managed = existing is not None and any(
547
+ Path(f.target_path).resolve() == target.resolve() for f in existing.files
548
+ )
549
+
550
+ if is_managed:
551
+ if mode == InstallMode.SYMLINK:
552
+ if target.is_symlink() and target.resolve() == source.resolve():
553
+ return FilePlan(
554
+ action="skip",
555
+ target_path=str(target),
556
+ source_path=str(source),
557
+ reason="symlink already correct",
558
+ )
559
+ else:
560
+ if target.is_file():
561
+ source_checksum = compute_checksum(source)
562
+ target_checksum = compute_checksum(target)
563
+ if source_checksum == target_checksum:
564
+ return FilePlan(
565
+ action="skip",
566
+ target_path=str(target),
567
+ source_path=str(source),
568
+ reason="file unchanged",
569
+ )
570
+
571
+ return FilePlan(
572
+ action="update",
573
+ target_path=str(target),
574
+ source_path=str(source),
575
+ )
576
+
577
+ if force:
578
+ return FilePlan(
579
+ action="install",
580
+ target_path=str(target),
581
+ source_path=str(source),
582
+ reason="force overwrite",
583
+ )
584
+
585
+ return FilePlan(
586
+ action="conflict",
587
+ target_path=str(target),
588
+ source_path=str(source),
589
+ reason="file exists and is not Forge-managed",
590
+ )
591
+
592
+ def _plan_settings(
593
+ self,
594
+ modules: set[InstallModule],
595
+ force: bool,
596
+ ) -> list[SettingsPlan]:
597
+ """Plan settings merge operations.
598
+
599
+ Args:
600
+ modules: Modules being installed.
601
+ force: If True, override scalar conflicts.
602
+
603
+ Returns:
604
+ List of SettingsPlan.
605
+ """
606
+ plans: list[SettingsPlan] = []
607
+
608
+ settings_path = get_settings_path(self._scope, self._project_root)
609
+ current_settings = read_settings(settings_path)
610
+
611
+ forge_settings = self._load_forge_settings()
612
+
613
+ include_statusline = InstallModule.STATUSLINE in modules
614
+ if include_statusline and "statusLine" in forge_settings:
615
+ current = current_settings.get("statusLine")
616
+ forge_value = forge_settings["statusLine"]
617
+ if scalar_already_set(current_settings, "statusLine", forge_value):
618
+ plans.append(
619
+ SettingsPlan(
620
+ action="skip",
621
+ key_path="statusLine",
622
+ value=forge_value,
623
+ reason="already set",
624
+ )
625
+ )
626
+ elif current is not None and current != forge_value and not force:
627
+ plans.append(
628
+ SettingsPlan(
629
+ action="conflict",
630
+ key_path="statusLine",
631
+ value=forge_value,
632
+ current_value=current,
633
+ reason="statusLine already set to different value",
634
+ )
635
+ )
636
+ else:
637
+ plans.append(
638
+ SettingsPlan(
639
+ action="merge",
640
+ key_path="statusLine",
641
+ value=forge_value,
642
+ )
643
+ )
644
+
645
+ # Hooks and permissions don't conflict (append/union)
646
+ if InstallModule.HOOKS in modules:
647
+ forge_hooks = forge_settings.get("hooks", {})
648
+ for hook_type in sorted(forge_hooks):
649
+ # Skip empty arrays (no entries to add)
650
+ if not forge_hooks[hook_type]:
651
+ continue
652
+ if hooks_already_present(current_settings, hook_type, forge_hooks[hook_type]):
653
+ plans.append(
654
+ SettingsPlan(
655
+ action="skip",
656
+ key_path=f"hooks.{hook_type}",
657
+ value="(already present)",
658
+ reason="hooks already installed",
659
+ )
660
+ )
661
+ else:
662
+ plans.append(
663
+ SettingsPlan(
664
+ action="merge",
665
+ key_path=f"hooks.{hook_type}",
666
+ value="(append + dedupe)",
667
+ )
668
+ )
669
+
670
+ if InstallModule.PERMISSIONS in modules:
671
+ for perm_type in ["allow", "deny"]:
672
+ forge_perms = forge_settings.get("permissions", {}).get(perm_type)
673
+ if forge_perms:
674
+ if permissions_already_present(current_settings, perm_type, forge_perms):
675
+ plans.append(
676
+ SettingsPlan(
677
+ action="skip",
678
+ key_path=f"permissions.{perm_type}",
679
+ value="(already present)",
680
+ reason="permissions already installed",
681
+ )
682
+ )
683
+ else:
684
+ plans.append(
685
+ SettingsPlan(
686
+ action="merge",
687
+ key_path=f"permissions.{perm_type}",
688
+ value="(union unique)",
689
+ )
690
+ )
691
+
692
+ # Env vars (dict merge - Forge overrides)
693
+ if forge_env := forge_settings.get("env"):
694
+ for key in sorted(forge_env):
695
+ if scalar_already_set(current_settings.get("env", {}), key, forge_env[key]):
696
+ plans.append(
697
+ SettingsPlan(
698
+ action="skip",
699
+ key_path=f"env.{key}",
700
+ value=forge_env[key],
701
+ reason="already set",
702
+ )
703
+ )
704
+ else:
705
+ plans.append(
706
+ SettingsPlan(
707
+ action="merge",
708
+ key_path=f"env.{key}",
709
+ value=forge_env[key],
710
+ )
711
+ )
712
+
713
+ return plans
714
+
715
+ def _load_forge_settings(self) -> dict[str, Any]:
716
+ """Load settings from the user-editable preset.
717
+
718
+ Reads ~/.forge/claude.preset.json (auto-created from built-in defaults
719
+ on first access). Users customize via ``forge claude preset edit``.
720
+
721
+ Hooks are Forge-managed infrastructure, so they always come from the
722
+ built-in preset regardless of preset file content. This ensures
723
+ upgraded installs pick up new hooks even when the user's preset file
724
+ predates them. Infrastructure permissions (Write/Edit) are also
725
+ backfilled from the built-in preset. User-added permissions and env
726
+ vars are preserved.
727
+ """
728
+ from forge.install.preset import get_builtin_preset, load_preset
729
+
730
+ settings = load_preset()
731
+ builtin = get_builtin_preset()
732
+
733
+ # Hooks are Forge-managed infrastructure, not user-customizable preset state.
734
+ settings["hooks"] = deepcopy(builtin.get("hooks", {}))
735
+
736
+ # Backfill infrastructure permissions from builtin (upgrade path)
737
+ builtin_allow = builtin.get("permissions", {}).get("allow", [])
738
+ if builtin_allow:
739
+ current_allow = settings.setdefault("permissions", {}).setdefault("allow", [])
740
+ for perm in builtin_allow:
741
+ if perm not in current_allow:
742
+ current_allow.append(perm)
743
+ return settings
744
+
745
+ def init(
746
+ self,
747
+ profile: InstallProfile = InstallProfile.STANDARD,
748
+ mode: InstallMode = InstallMode.COPY,
749
+ with_modules: set[InstallModule] | None = None,
750
+ without_modules: set[InstallModule] | None = None,
751
+ force: bool = False,
752
+ *,
753
+ _modules_override: set[InstallModule] | None = None,
754
+ ) -> InstallPlan:
755
+ """Install extensions.
756
+
757
+ Args:
758
+ profile: Installation profile.
759
+ mode: Installation mode.
760
+ with_modules: Modules to add.
761
+ without_modules: Modules to remove.
762
+ force: If True, override conflicts.
763
+ _modules_override: Internal. If provided, use exactly these modules.
764
+
765
+ Returns:
766
+ The executed plan.
767
+ """
768
+ plan = self.plan(
769
+ profile,
770
+ mode,
771
+ with_modules,
772
+ without_modules,
773
+ force,
774
+ _modules_override=_modules_override,
775
+ )
776
+
777
+ if plan.has_conflicts and not force:
778
+ return plan # Don't execute if conflicts
779
+
780
+ settings_path = get_settings_path(self._scope, self._project_root)
781
+ backup_path = backup_settings(settings_path)
782
+
783
+ installed_files: list[InstalledFile] = []
784
+ for file_plan in plan.files:
785
+ if file_plan.action in ("install", "update"):
786
+ installed_file = self._execute_file(file_plan, mode)
787
+ installed_files.append(installed_file)
788
+
789
+ if _modules_override is not None:
790
+ modules = _modules_override
791
+ else:
792
+ modules = resolve_modules(profile, with_modules, without_modules)
793
+ settings = read_settings(settings_path)
794
+ forge_settings = self._load_forge_settings()
795
+ entries = merge(
796
+ settings,
797
+ forge_settings,
798
+ force=force,
799
+ include_statusline=InstallModule.STATUSLINE in modules,
800
+ )
801
+ write_settings(settings_path, settings)
802
+
803
+ # Save what we added for smart uninstall
804
+ added_structure = entries_to_added_structure(entries)
805
+ save_added_settings(settings_path, added_structure)
806
+
807
+ # Merge newly installed files with existing tracked files (for idempotent re-runs)
808
+ now = now_iso()
809
+ existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
810
+
811
+ # All targets the current source scan knows about (installed, skipped, or conflicted)
812
+ planned_targets = {f.target_path for f in plan.files}
813
+
814
+ # Remove stale tracked files whose source no longer exists (e.g., after renames).
815
+ # A file is stale if it was tracked in the previous installation but isn't in the
816
+ # current plan's target set — meaning no source file maps to that target anymore.
817
+ # Only auto-delete if ownership is verified (symlink target or checksum matches);
818
+ # otherwise drop from manifest silently — the user may have repurposed the path.
819
+ base_dir = get_target_root(self._scope, self._project_root)
820
+ dirs_to_clean: set[Path] = set()
821
+ if existing:
822
+ for existing_file in existing.files:
823
+ if existing_file.target_path not in planned_targets:
824
+ target = Path(existing_file.target_path)
825
+ try:
826
+ validate_path_within_boundary(target, base_dir, "remove stale file")
827
+ except PathBoundaryViolationError:
828
+ continue
829
+ if not self._is_forge_owned(target, existing_file):
830
+ logger.debug("Stale target not Forge-owned, dropping from manifest: %s", target)
831
+ continue
832
+ try:
833
+ target.unlink(missing_ok=True)
834
+ logger.debug("Removed stale tracked file: %s", target)
835
+ except OSError:
836
+ logger.debug("Could not remove stale target: %s", target)
837
+ continue
838
+ # Collect parent dirs for empty-directory cleanup
839
+ parent = target.parent
840
+ while parent != base_dir and parent.is_relative_to(base_dir):
841
+ dirs_to_clean.add(parent)
842
+ parent = parent.parent
843
+
844
+ # Clean up empty directories left by stale file removal (deepest first)
845
+ for dir_path in sorted(dirs_to_clean, key=lambda p: len(p.parts), reverse=True):
846
+ try:
847
+ dir_path.rmdir()
848
+ except OSError:
849
+ pass # Not empty or doesn't exist
850
+
851
+ # Build final files list: start with newly installed, add existing tracked files
852
+ # that were skipped (not re-installed this run) AND still in the plan
853
+ installed_paths = {f.target_path for f in installed_files}
854
+ final_files = list(installed_files)
855
+ if existing:
856
+ for existing_file in existing.files:
857
+ if existing_file.target_path not in installed_paths:
858
+ if existing_file.target_path in planned_targets:
859
+ # Keep existing tracked file that was skipped (source still exists)
860
+ final_files.append(existing_file)
861
+
862
+ entry_ids = {(e.key_path, e.stable_id) for e in entries}
863
+ final_entries = list(entries)
864
+ if existing:
865
+ for existing_entry in existing.settings_entries:
866
+ if (existing_entry.key_path, existing_entry.stable_id) not in entry_ids:
867
+ final_entries.append(existing_entry)
868
+
869
+ installation = Installation(
870
+ scope=self._scope.value,
871
+ mode=mode.value,
872
+ profile=profile.value,
873
+ modules_enabled=[m.value for m in sorted(modules, key=lambda m: m.value)],
874
+ files=final_files,
875
+ settings_entries=final_entries,
876
+ settings_backup_path=str(backup_path) if backup_path else None,
877
+ installed_at=existing.installed_at if existing else now,
878
+ updated_at=now,
879
+ )
880
+ self._tracking.set_installation(self._scope.value, installation, self._project_path_str)
881
+
882
+ return plan
883
+
884
+ def _execute_file(self, file_plan: FilePlan, mode: InstallMode) -> InstalledFile:
885
+ """Execute a file operation.
886
+
887
+ Args:
888
+ file_plan: Plan for the file.
889
+ mode: Installation mode.
890
+
891
+ Returns:
892
+ InstalledFile record.
893
+ """
894
+ source = Path(file_plan.source_path) # type: ignore[arg-type] # source_path is always non-None in execute context
895
+ target = Path(file_plan.target_path)
896
+
897
+ target.parent.mkdir(parents=True, exist_ok=True)
898
+
899
+ if target.exists() or target.is_symlink():
900
+ target.unlink()
901
+
902
+ if mode == InstallMode.SYMLINK:
903
+ target.symlink_to(source)
904
+ else:
905
+ shutil.copy2(source, target)
906
+
907
+ return InstalledFile(
908
+ target_path=str(target),
909
+ source_path=str(source),
910
+ checksum=compute_checksum(source),
911
+ mode=mode.value,
912
+ installed_at=now_iso(),
913
+ )
914
+
915
+ @staticmethod
916
+ def _is_forge_owned(target: Path, record: InstalledFile) -> bool:
917
+ """Check if a stale target still matches Forge ownership expectations.
918
+
919
+ Returns True only if the on-disk object was clearly installed by Forge
920
+ (symlink pointing to the recorded source, or copy with matching checksum).
921
+ Returns False if the target was replaced by the user or doesn't exist.
922
+ """
923
+ if not target.exists() and not target.is_symlink():
924
+ return False
925
+ if record.mode == "symlink":
926
+ if not target.is_symlink():
927
+ return False
928
+ try:
929
+ return target.resolve() == Path(record.source_path).resolve()
930
+ except OSError:
931
+ return False
932
+ else:
933
+ # Copy mode: checksum must match what Forge installed
934
+ if not target.is_file() or target.is_symlink():
935
+ return False
936
+ try:
937
+ return compute_checksum(target) == record.checksum
938
+ except OSError:
939
+ return False
940
+
941
+ def update(self, force: bool = False) -> InstallPlan:
942
+ """Update existing installation.
943
+
944
+ Uses the exact modules from the existing installation (not re-resolved
945
+ from profile) to ensure only tracked items are touched.
946
+
947
+ Args:
948
+ force: If True, override conflicts.
949
+
950
+ Returns:
951
+ The executed plan.
952
+
953
+ Raises:
954
+ NotInstalledError: If no existing installation.
955
+ """
956
+ existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
957
+ if existing is None:
958
+ raise NotInstalledError(self._scope.value)
959
+
960
+ # Use exact modules from existing installation
961
+ existing_modules = {InstallModule(m) for m in existing.modules_enabled}
962
+
963
+ return self.init(
964
+ profile=InstallProfile(existing.profile),
965
+ mode=InstallMode(existing.mode),
966
+ force=force,
967
+ _modules_override=existing_modules,
968
+ )
969
+
970
+ def uninstall(self) -> None:
971
+ """Remove Forge installation.
972
+
973
+ Raises:
974
+ NotInstalledError: If no existing installation.
975
+ """
976
+ existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
977
+ if existing is None:
978
+ raise NotInstalledError(self._scope.value)
979
+
980
+ dirs_to_clean: set[Path] = set()
981
+ base_dir = get_target_root(self._scope, self._project_root)
982
+
983
+ for file_record in existing.files:
984
+ target = Path(file_record.target_path)
985
+ # Security: validate path is within expected boundary
986
+ validate_path_within_boundary(target, base_dir, "delete file")
987
+ if target.exists() or target.is_symlink():
988
+ target.unlink()
989
+ parent = target.parent
990
+ while parent != base_dir and parent.is_relative_to(base_dir):
991
+ dirs_to_clean.add(parent)
992
+ parent = parent.parent
993
+
994
+ # Clean up empty directories (deepest first)
995
+ for dir_path in sorted(dirs_to_clean, key=lambda p: len(p.parts), reverse=True):
996
+ try:
997
+ dir_path.rmdir()
998
+ except OSError:
999
+ pass # Directory not empty or doesn't exist
1000
+
1001
+ settings_path = get_settings_path(self._scope, self._project_root)
1002
+ backup_files = find_backup_files(settings_path)
1003
+ added_files = find_added_files(settings_path)
1004
+
1005
+ current = read_settings(settings_path)
1006
+ backup = read_settings(backup_files[0]) if backup_files else {}
1007
+ added = load_added_settings(settings_path) # Already finds most recent
1008
+
1009
+ if added:
1010
+ # Use smart unmerge: removes our additions, preserves user changes
1011
+ result = smart_unmerge(current, backup, added)
1012
+ result = cleanup_empty_settings(result)
1013
+
1014
+ backup_cleaned = cleanup_empty_settings(backup)
1015
+ if settings_equal(result, backup_cleaned):
1016
+ if backup_files and backup_cleaned:
1017
+ # Had content before, restore it (use cleaned for consistency)
1018
+ write_settings(settings_path, backup_cleaned)
1019
+ elif settings_path.is_file():
1020
+ # Was empty/non-existent before, delete
1021
+ # Security: validate settings path is within expected boundary
1022
+ validate_path_within_boundary(settings_path, base_dir, "delete settings")
1023
+ settings_path.unlink()
1024
+ else:
1025
+ write_settings(settings_path, result)
1026
+ else:
1027
+ # Fallback to old unmerge if no .forge-added file
1028
+ unmerge(current, existing.settings_entries)
1029
+ write_settings(settings_path, current)
1030
+
1031
+ # Clean up .forge.added files only (keep .forge.backup files for history)
1032
+ for added_file in added_files:
1033
+ # Security: validate added file path is within expected boundary
1034
+ validate_path_within_boundary(added_file, base_dir, "delete added file")
1035
+ added_file.unlink()
1036
+
1037
+ self._tracking.remove_installation(self._scope.value, self._project_path_str)