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,1001 @@
1
+ """Forge extensions commands (extensions lifecycle).
2
+
3
+ Commands:
4
+ - forge extension enable - Enable Forge extensions
5
+ - forge extension sync - Sync existing extensions
6
+ - forge extension disable - Disable extensions
7
+ - forge extension status - Show extensions status
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from forge.core.paths import display_path
21
+ from forge.install.exceptions import (
22
+ ForgeInstallError,
23
+ NoClaudeDirectoryError,
24
+ NoForgeInstallationError,
25
+ NotInstalledError,
26
+ SettingsConflictError,
27
+ TrackingCorruptedError,
28
+ )
29
+ from forge.install.installer import Installer, find_claude_root, find_forge_installation
30
+ from forge.install.models import (
31
+ FILE_MODULES,
32
+ InstallMode,
33
+ InstallModule,
34
+ InstallPlan,
35
+ InstallProfile,
36
+ InstallScope,
37
+ get_gated_skills,
38
+ )
39
+ from forge.install.tracking import TrackingStore
40
+
41
+ console = Console()
42
+ _log = logging.getLogger(__name__)
43
+
44
+
45
+ def _find_git_root(start: Path) -> Path | None:
46
+ """Walk up from *start* looking for ``.git``.
47
+
48
+ Returns the directory containing ``.git``, or None if not in a git repo.
49
+ Pure detector -- no side effects.
50
+ """
51
+ current = start.resolve()
52
+ while current != current.parent:
53
+ if (current / ".git").exists():
54
+ return current
55
+ current = current.parent
56
+ if (current / ".git").exists():
57
+ return current
58
+ return None
59
+
60
+
61
+ def _detect_git_project_root(start: Path | None = None) -> Path | None:
62
+ """Find the git root suitable for auto-creating ``.claude/`` (Rule 4).
63
+
64
+ Returns the resolved git root, or None if not in a git repo or the
65
+ git root is the user's home directory. Pure detector -- no side effects.
66
+ """
67
+ cwd = (start or Path.cwd()).resolve()
68
+ git_root = _find_git_root(cwd)
69
+ if git_root is None:
70
+ return None
71
+
72
+ home = Path.home().resolve()
73
+ if git_root == home:
74
+ return None
75
+
76
+ return git_root.resolve()
77
+
78
+
79
+ def _create_claude_dir(root: Path) -> None:
80
+ """Create ``.claude/`` at *root* and log the action."""
81
+ claude_dir = root / ".claude"
82
+ claude_dir.mkdir(exist_ok=True)
83
+ _log.info("Created %s for Forge project", claude_dir)
84
+ console.print(f"[dim]Created {display_path(claude_dir)}[/dim]")
85
+
86
+
87
+ def _parse_modules(modules_str: str | None) -> set[InstallModule] | None:
88
+ """Parse comma-separated module names.
89
+
90
+ Args:
91
+ modules_str: Comma-separated module names.
92
+
93
+ Returns:
94
+ Set of InstallModule, or None if input is None/empty.
95
+ """
96
+ if not modules_str:
97
+ return None
98
+ return {InstallModule(m.strip()) for m in modules_str.split(",")}
99
+
100
+
101
+ def _count_actions(plan: InstallPlan) -> tuple[int, int]:
102
+ """Count non-skip actions in a plan.
103
+
104
+ Returns:
105
+ Tuple of (file_actions, settings_actions) that are not skips.
106
+ """
107
+ file_actions = sum(1 for f in plan.files if f.action != "skip")
108
+ settings_actions = sum(1 for s in plan.settings if s.action != "skip")
109
+ return file_actions, settings_actions
110
+
111
+
112
+ # Modules that are intentionally empty in the source tree (only .gitkeep).
113
+ # Checked by allowlist so a broken wheel that omits skills/ still warns.
114
+ _INTENTIONALLY_EMPTY_MODULES: set[InstallModule] = {
115
+ InstallModule.AGENTS,
116
+ InstallModule.COMMANDS,
117
+ }
118
+
119
+
120
+ def _warn_if_modules_have_no_files(
121
+ plan: InstallPlan,
122
+ scope: InstallScope,
123
+ project_root: Path | None,
124
+ tracking: TrackingStore,
125
+ ) -> None:
126
+ """Warn when a file-bearing module has no files anywhere (plan or tracking).
127
+
128
+ A clean install with 0 files in the plan is normal IF the existing
129
+ tracked install already has files for the module. But if neither plan
130
+ nor tracking has files for an enabled file-bearing module, the install
131
+ is broken — typically a wheel missing bundled extensions.
132
+ """
133
+ enabled = {InstallModule(m) for m in plan.modules if InstallModule(m) in FILE_MODULES}
134
+ enabled -= _INTENTIONALLY_EMPTY_MODULES
135
+ if not enabled:
136
+ return
137
+
138
+ project_str = None if scope == InstallScope.USER else (str(project_root) if project_root else None)
139
+ existing = tracking.get_installation(scope.value, project_str)
140
+
141
+ def _module_has_files(module: InstallModule, paths: list[str]) -> bool:
142
+ sep = f"/{module.value}/"
143
+ return any(sep in p for p in paths)
144
+
145
+ plan_paths = [f.target_path for f in plan.files]
146
+ existing_paths = [f.target_path for f in existing.files] if existing else []
147
+
148
+ missing = {m for m in enabled if not _module_has_files(m, plan_paths) and not _module_has_files(m, existing_paths)}
149
+ if not missing:
150
+ return
151
+
152
+ names = ", ".join(sorted(m.value for m in missing))
153
+ console.print(
154
+ f"\n[yellow]Warning:[/yellow] No files found for enabled module(s): {names}. "
155
+ "Your Forge installation may be missing bundled extensions. "
156
+ "Try reinstalling: 'pip install --force-reinstall <wheel>'."
157
+ )
158
+
159
+
160
+ def _print_completion_message(
161
+ plan: InstallPlan,
162
+ scope: InstallScope,
163
+ project_root: Path | None,
164
+ tracking: TrackingStore,
165
+ ) -> None:
166
+ """Print appropriate completion message based on what was done."""
167
+ file_actions, settings_actions = _count_actions(plan)
168
+ total_actions = file_actions + settings_actions
169
+
170
+ _warn_if_modules_have_no_files(plan, scope, project_root, tracking)
171
+
172
+ if total_actions == 0:
173
+ console.print("\n[dim]Already up to date.[/dim]")
174
+ else:
175
+ parts = []
176
+ if file_actions > 0:
177
+ parts.append(f"{file_actions} file{'s' if file_actions != 1 else ''}")
178
+ if settings_actions > 0:
179
+ parts.append(f"{settings_actions} setting{'s' if settings_actions != 1 else ''}")
180
+ console.print(f"\n[green]Extensions enabled.[/green] ({', '.join(parts)} updated)")
181
+
182
+ console.print("[dim]Tip: Customize permissions and env vars with 'forge claude preset edit'.[/dim]")
183
+
184
+ if InstallModule.SKILLS.value in plan.modules:
185
+ console.print(
186
+ "[dim]Tip: Multi-model skills require proxy credentials. " "Run 'forge auth status' to check.[/dim]"
187
+ )
188
+
189
+ profile = InstallProfile(plan.profile)
190
+ gated = get_gated_skills(profile)
191
+ if gated:
192
+ skill_list = ", ".join(f"/forge:{name}" for name, _ in gated)
193
+ required = gated[0][1].value
194
+ console.print(f"\n[dim]Tip: Additional skills available with --profile {required}: {skill_list}[/dim]")
195
+
196
+
197
+ def _validate_anchor(anchor: Path) -> None:
198
+ """Reject anchors that point inside a ``.claude/`` directory.
199
+
200
+ The ``.claude/`` creation in ``enable_cmd`` runs before the installer's
201
+ ``get_target_root()`` guard, so an anchor like ``/repo/.claude`` would
202
+ create ``/repo/.claude/.claude/`` before the guard fires.
203
+ """
204
+ resolved = anchor.expanduser().resolve()
205
+ if ".claude" in resolved.parts:
206
+ raise click.UsageError(
207
+ f"--root points inside a .claude directory: {anchor}\n"
208
+ "Provide the project root instead (the parent of .claude/)."
209
+ )
210
+
211
+
212
+ def _resolve_project_root(
213
+ scope: InstallScope,
214
+ *,
215
+ anchor: Path | None = None,
216
+ auto_create: bool = False,
217
+ ) -> Path | None:
218
+ """Resolve canonical project root for a given scope.
219
+
220
+ For user scope, returns None.
221
+ For project/local scope, finds the .claude directory and returns
222
+ the canonicalized project root. When *auto_create* is True and no
223
+ ``.claude/`` exists, creates it at the git root (Rule 4).
224
+
225
+ When *anchor* is provided, skips the walk-up and uses that path directly.
226
+
227
+ Args:
228
+ scope: The installation scope.
229
+ anchor: Explicit target directory (skips walk-up when set).
230
+ auto_create: Whether to create ``.claude/`` if missing (Rule 4).
231
+
232
+ Returns:
233
+ Canonicalized project root path, or None for user scope.
234
+
235
+ Raises:
236
+ NoClaudeDirectoryError: If no .claude directory found and auto-create
237
+ is disabled or not in a git repo.
238
+ """
239
+ if scope == InstallScope.USER:
240
+ return None
241
+
242
+ if anchor is not None:
243
+ resolved = anchor.expanduser().resolve()
244
+ if auto_create and not (resolved / ".claude").is_dir():
245
+ _create_claude_dir(resolved)
246
+ return resolved
247
+
248
+ try:
249
+ _detected_scope, project_root = find_claude_root()
250
+ except NoClaudeDirectoryError:
251
+ # find_claude_root raises when walk reaches FS root without home;
252
+ # treat the same as "no .claude/ found" for auto-create purposes.
253
+ project_root = None
254
+
255
+ if project_root is None:
256
+ # Rule 4: auto-create .claude/ at git root for project/local enable
257
+ git_root = _detect_git_project_root()
258
+ if git_root is not None:
259
+ if auto_create:
260
+ _create_claude_dir(git_root)
261
+ return git_root
262
+ raise NoClaudeDirectoryError(
263
+ "No .claude directory found. Use '--scope user' for global install, "
264
+ "or run from within a Claude Code project."
265
+ )
266
+
267
+ # Canonicalize to handle symlinks and ensure consistent keys
268
+ return project_root.resolve()
269
+
270
+
271
+ def _print_plan(plan: InstallPlan, dry_run: bool = False) -> None:
272
+ """Print installation plan using Rich.
273
+
274
+ Args:
275
+ plan: The plan to display.
276
+ dry_run: If True, prefix output with "(dry-run)".
277
+ """
278
+ prefix = "[dim](dry-run)[/dim] " if dry_run else ""
279
+
280
+ console.print(f"\n{prefix}[bold]Installation Plan[/bold]")
281
+ console.print(f" Scope: {plan.scope}")
282
+ console.print(f" Mode: {plan.mode}")
283
+ console.print(f" Profile: {plan.profile}")
284
+ console.print(f" Modules: {', '.join(plan.modules)}")
285
+
286
+ if plan.files:
287
+ console.print(f"\n{prefix}[bold]Files:[/bold]")
288
+ table = Table(show_header=True, header_style="bold", box=None)
289
+ table.add_column("ACTION", style="dim")
290
+ table.add_column("PATH")
291
+ table.add_column("REASON", style="dim")
292
+
293
+ for f in plan.files:
294
+ style = {
295
+ "install": "green",
296
+ "update": "yellow",
297
+ "skip": "dim",
298
+ "conflict": "red",
299
+ }.get(f.action, "")
300
+ table.add_row(f.action, display_path(f.target_path), f.reason or "", style=style)
301
+
302
+ console.print(table)
303
+
304
+ if plan.settings:
305
+ console.print(f"\n{prefix}[bold]Settings:[/bold]")
306
+ table = Table(show_header=True, header_style="bold", box=None)
307
+ table.add_column("ACTION", style="dim")
308
+ table.add_column("KEY")
309
+ table.add_column("VALUE", style="dim")
310
+
311
+ for s in plan.settings:
312
+ style = "red" if s.action == "conflict" else ""
313
+ value_str = str(s.value) if s.value else ""
314
+ if s.action == "conflict":
315
+ value_str = f"current={s.current_value!r}, forge={s.value!r}"
316
+ table.add_row(s.action, s.key_path, value_str, style=style)
317
+
318
+ console.print(table)
319
+
320
+ if plan.has_conflicts:
321
+ console.print(f"\n{prefix}[bold red]Conflicts detected:[/bold red]")
322
+ for c in plan.conflicts:
323
+ console.print(f" [red]- {c}[/red]")
324
+ console.print("\n[dim]Tip: Use --force to override, or resolve conflicts manually.[/dim]")
325
+
326
+
327
+ def _uninstall_all_installations(tracking: TrackingStore, yes: bool) -> None:
328
+ """Uninstall all tracked installations.
329
+
330
+ Args:
331
+ tracking: TrackingStore instance.
332
+ yes: If True, skip confirmation prompt.
333
+ """
334
+ installations = tracking.list_installations()
335
+
336
+ if not installations:
337
+ console.print("[dim]No Forge installations found.[/dim]")
338
+ return
339
+
340
+ console.print(f"[bold]Found {len(installations)} Forge installation(s):[/bold]\n")
341
+
342
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
343
+ table.add_column("SCOPE", style="cyan")
344
+ table.add_column("PROJECT PATH")
345
+ table.add_column("PROFILE")
346
+ table.add_column("FILES")
347
+
348
+ for scope, project_path, installation in installations:
349
+ scope_display = scope
350
+ path_display = project_path or "(global)"
351
+ if len(path_display) > 40:
352
+ path_display = "…" + path_display[-37:]
353
+ table.add_row(
354
+ scope_display,
355
+ path_display,
356
+ installation.profile,
357
+ str(len(installation.files)),
358
+ )
359
+
360
+ console.print(table)
361
+ console.print()
362
+
363
+ if not yes:
364
+ if not click.confirm("Disable ALL of these?"):
365
+ console.print("[dim]Cancelled.[/dim]")
366
+ return
367
+
368
+ errors = []
369
+ for scope, project_path, _installation in installations:
370
+ try:
371
+ console.print(f"\n[bold]Disabling {scope}[/bold]", end="")
372
+ if project_path:
373
+ console.print(f" [dim]({display_path(project_path)})[/dim]")
374
+ else:
375
+ console.print()
376
+
377
+ install_scope = InstallScope(scope)
378
+ project_root = Path(project_path) if project_path else None
379
+
380
+ installer = Installer(scope=install_scope, project_root=project_root)
381
+ installer.uninstall()
382
+ console.print(" [green]✓ Done[/green]")
383
+
384
+ except ForgeInstallError as e:
385
+ console.print(f" [red]✗ Failed: {e}[/red]")
386
+ errors.append((scope, project_path, str(e)))
387
+
388
+ console.print()
389
+ if errors:
390
+ console.print(f"[yellow]Completed with {len(errors)} error(s).[/yellow]")
391
+ for scope, path, err in errors:
392
+ console.print(f" [red]- {scope} ({display_path(path) if path else 'global'}): {err}[/red]")
393
+ else:
394
+ console.print(f"[green]All {len(installations)} installation(s) disabled.[/green]")
395
+
396
+
397
+ def _can_resolve_project_root(scope: InstallScope, *, anchor: Path | None = None) -> bool:
398
+ """Check if project root can be resolved without raising."""
399
+ try:
400
+ _resolve_project_root(scope, anchor=anchor)
401
+ return True
402
+ except NoClaudeDirectoryError:
403
+ return False
404
+
405
+
406
+ # --- Commands ---
407
+
408
+
409
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
410
+ def extensions() -> None:
411
+ """Manage Forge extensions lifecycle.
412
+
413
+ \b
414
+ Examples:
415
+ forge extension enable # Auto-detect scope, enable
416
+ forge extension status # Show installation status
417
+ forge extension sync # Sync to latest version
418
+ """
419
+ pass
420
+
421
+
422
+ @extensions.command("enable")
423
+ @click.option(
424
+ "--scope",
425
+ "-S",
426
+ type=click.Choice(["local", "project", "user"]),
427
+ default=None,
428
+ help="Installation scope: local (gitignored), project (committed), user (global)",
429
+ )
430
+ @click.option(
431
+ "--root",
432
+ "path",
433
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
434
+ default=None,
435
+ help="Target directory (default: walk up from cwd to find .claude/)",
436
+ )
437
+ @click.option(
438
+ "--profile",
439
+ "-p",
440
+ type=click.Choice(["minimal", "standard", "full"]),
441
+ default="standard",
442
+ help="Installation profile",
443
+ )
444
+ @click.option(
445
+ "--copy",
446
+ "-c",
447
+ "mode",
448
+ flag_value="copy",
449
+ default=True,
450
+ help="Copy files (default)",
451
+ )
452
+ @click.option(
453
+ "--symlink",
454
+ "-s",
455
+ "mode",
456
+ flag_value="symlink",
457
+ help="Symlink files (dev mode)",
458
+ )
459
+ @click.option(
460
+ "--with",
461
+ "-w",
462
+ "with_modules",
463
+ help="Add modules (comma-separated: commands,agents,skills,hooks,status-line,permissions)",
464
+ )
465
+ @click.option(
466
+ "--without",
467
+ "-W",
468
+ "without_modules",
469
+ help="Remove modules (comma-separated)",
470
+ )
471
+ @click.option("--force", "-f", is_flag=True, help="Override conflicts")
472
+ @click.option("--dry-run", "-n", is_flag=True, help="Show plan without executing")
473
+ def enable_cmd(
474
+ scope: str | None,
475
+ path: str | None,
476
+ profile: str,
477
+ mode: str,
478
+ with_modules: str | None,
479
+ without_modules: str | None,
480
+ force: bool,
481
+ dry_run: bool,
482
+ ) -> None:
483
+ """Enable Forge extensions.
484
+
485
+ \b
486
+ Scope Detection (when no --scope specified):
487
+ Walks up from current directory looking for a .claude/ directory.
488
+ - If found: enables local in that project's .claude/settings.local.json
489
+ - If in a git repo: enables local at the git root
490
+ - If reached ~: enables user in ~/.claude/settings.json
491
+ - If not found: fails (use --scope user outside a project)
492
+
493
+ \b
494
+ Examples:
495
+ forge extension enable # Auto-detect scope
496
+ forge extension enable --scope local # Local at nearest .claude/
497
+ forge extension enable --scope local --root /repo/api # Local at specific path
498
+ forge extension enable --root /repo/api # Same (defaults to local)
499
+ forge extension enable --scope user # Global ~/.claude
500
+ forge extension enable --profile minimal # Commands only
501
+ forge extension enable --dry-run # Preview changes
502
+ """
503
+ try:
504
+ # Check Claude Code minimum version (hard-block: reject over warn)
505
+ from forge.install.version import check_minimum_version
506
+
507
+ version_check = check_minimum_version()
508
+ if not version_check.ok:
509
+ console.print(f"[red]Error:[/red] {version_check.reason}")
510
+ console.print("\n[dim]Tip: Run 'claude update' to upgrade.[/dim]")
511
+ sys.exit(1)
512
+
513
+ anchor = Path(path) if path else None
514
+
515
+ # Validate: --scope user + --root is contradictory
516
+ if scope == "user" and anchor is not None:
517
+ raise click.UsageError("--scope user is global; --root is not applicable.")
518
+
519
+ # Validate: anchor must not point inside .claude/
520
+ if anchor is not None:
521
+ _validate_anchor(anchor)
522
+
523
+ # Default: --root without --scope implies local
524
+ if anchor is not None and scope is None:
525
+ scope = "local"
526
+
527
+ # --- Scope resolution (Rule 4: auto-create .claude/ in git repos) ---
528
+ needs_create = False
529
+
530
+ if scope is None:
531
+ install_scope, project_root = find_claude_root()
532
+ # P1 fix: auto-detect in a git repo should prefer LOCAL over USER
533
+ if install_scope == InstallScope.USER:
534
+ git_root = _detect_git_project_root()
535
+ if git_root is not None:
536
+ install_scope = InstallScope.LOCAL
537
+ project_root = git_root
538
+ needs_create = not (git_root / ".claude").is_dir()
539
+ console.print(f"[dim]Auto-detected scope: {install_scope.value}[/dim]")
540
+ else:
541
+ install_scope = InstallScope(scope)
542
+ project_root = _resolve_project_root(install_scope, anchor=anchor, auto_create=False)
543
+ if project_root is not None:
544
+ needs_create = not (project_root / ".claude").is_dir()
545
+
546
+ # Create .claude/ only when not dry-run
547
+ if needs_create and project_root is not None:
548
+ if dry_run:
549
+ console.print(f"[dim]Would create {display_path(project_root / '.claude')}[/dim]")
550
+ else:
551
+ _create_claude_dir(project_root)
552
+
553
+ # Rule 1 anchor: .forge/ is required for session start.
554
+ # Preview in dry-run; actual creation deferred until installer succeeds.
555
+ needs_forge = project_root is not None and not (project_root / ".forge").is_dir()
556
+ if needs_forge and dry_run and project_root is not None:
557
+ console.print(f"[dim]Would create {display_path(project_root / '.forge')}[/dim]")
558
+
559
+ install_profile = InstallProfile(profile)
560
+ install_mode = InstallMode(mode)
561
+
562
+ installer = Installer(scope=install_scope, project_root=project_root)
563
+
564
+ if dry_run:
565
+ plan = installer.plan(
566
+ profile=install_profile,
567
+ mode=install_mode,
568
+ with_modules=_parse_modules(with_modules),
569
+ without_modules=_parse_modules(without_modules),
570
+ force=force,
571
+ )
572
+ _print_plan(plan, dry_run=True)
573
+ if plan.has_conflicts:
574
+ sys.exit(1)
575
+ else:
576
+ plan = installer.init(
577
+ profile=install_profile,
578
+ mode=install_mode,
579
+ with_modules=_parse_modules(with_modules),
580
+ without_modules=_parse_modules(without_modules),
581
+ force=force,
582
+ )
583
+ _print_plan(plan)
584
+ if plan.has_conflicts:
585
+ console.print("\n[red]Enable failed due to conflicts.[/red]")
586
+ sys.exit(1)
587
+ else:
588
+ # Create .forge/ only after installer succeeds (avoids orphaned
589
+ # directories if enable fails due to conflicts).
590
+ if needs_forge and project_root is not None:
591
+ (project_root / ".forge").mkdir(exist_ok=True)
592
+ _log.info("Created %s for session state", project_root / ".forge")
593
+
594
+ _print_completion_message(plan, install_scope, project_root, TrackingStore())
595
+
596
+ except click.UsageError:
597
+ raise
598
+ except NoClaudeDirectoryError as e:
599
+ console.print(f"[red]Error:[/red] {e}")
600
+ console.print(
601
+ "\n[dim]Tip: Use '--scope user' to enable globally, "
602
+ "or '--root <dir>' to target a specific directory.[/dim]"
603
+ )
604
+ sys.exit(1)
605
+ except SettingsConflictError as e:
606
+ console.print(f"[red]Settings conflict:[/red] {e}")
607
+ console.print("\n[dim]Tip: Use --force to override.[/dim]")
608
+ sys.exit(1)
609
+ except ForgeInstallError as e:
610
+ console.print(f"[red]Error:[/red] {e}")
611
+ sys.exit(1)
612
+
613
+
614
+ @extensions.command("sync")
615
+ @click.option(
616
+ "--scope",
617
+ "-S",
618
+ type=click.Choice(["local", "project", "user"]),
619
+ default=None,
620
+ help="Installation scope",
621
+ )
622
+ @click.option("--force", "-f", is_flag=True, help="Override conflicts")
623
+ def sync_cmd(scope: str | None, force: bool) -> None:
624
+ """Sync existing Forge extensions.
625
+
626
+ Re-runs the enable with the same profile and mode as originally
627
+ configured, refreshing all files and settings from the current Forge
628
+ source.
629
+
630
+ \b
631
+ Scope Detection (when no --scope specified):
632
+ Walks up from current directory looking for existing Forge extensions
633
+ (detected by .settings.*.json.forge.* files in .claude/).
634
+ - Checks LOCAL first, then PROJECT, then USER
635
+ - Fails if no extensions found
636
+
637
+ \b
638
+ Examples:
639
+ forge extension sync # Sync Forge extensions
640
+ forge extension sync --scope local # Sync local scope
641
+ forge extension sync --force # Force re-sync
642
+ """
643
+ try:
644
+ # Check Claude Code minimum version (same gate as enable)
645
+ from forge.install.version import check_minimum_version
646
+
647
+ version_check = check_minimum_version()
648
+ if not version_check.ok:
649
+ console.print(f"[red]Error:[/red] {version_check.reason}")
650
+ console.print("\n[dim]Tip: Run 'claude update' to upgrade.[/dim]")
651
+ sys.exit(1)
652
+
653
+ if scope is None:
654
+ install_scope, project_root = find_forge_installation()
655
+ console.print(f"[dim]Auto-detected scope: {install_scope.value}[/dim]")
656
+ else:
657
+ install_scope = InstallScope(scope)
658
+ # Use canonical project root (finds .claude/ and resolves symlinks)
659
+ project_root = _resolve_project_root(install_scope)
660
+
661
+ installer = Installer(scope=install_scope, project_root=project_root)
662
+ plan = installer.update(force=force)
663
+
664
+ _print_plan(plan)
665
+ if plan.has_conflicts:
666
+ console.print("\n[red]Sync failed due to conflicts.[/red]")
667
+ sys.exit(1)
668
+ else:
669
+ file_actions, settings_actions = _count_actions(plan)
670
+ total_actions = file_actions + settings_actions
671
+ if total_actions == 0:
672
+ console.print("\n[dim]Already up to date.[/dim]")
673
+ else:
674
+ parts = []
675
+ if file_actions > 0:
676
+ parts.append(f"{file_actions} file{'s' if file_actions != 1 else ''}")
677
+ if settings_actions > 0:
678
+ parts.append(f"{settings_actions} setting{'s' if settings_actions != 1 else ''}")
679
+ console.print(f"\n[green]Sync complete.[/green] ({', '.join(parts)} updated)")
680
+
681
+ except NoForgeInstallationError as e:
682
+ console.print(f"[red]Error:[/red] {e}")
683
+ sys.exit(1)
684
+ except NotInstalledError as e:
685
+ console.print(f"[red]Error:[/red] {e}")
686
+ console.print("\n[dim]Tip: Run 'forge extension enable' first.[/dim]")
687
+ sys.exit(1)
688
+ except ForgeInstallError as e:
689
+ console.print(f"[red]Error:[/red] {e}")
690
+ sys.exit(1)
691
+
692
+
693
+ @extensions.command("disable")
694
+ @click.option(
695
+ "--scope",
696
+ "-S",
697
+ type=click.Choice(["local", "project", "user"]),
698
+ default=None,
699
+ help="Installation scope",
700
+ )
701
+ @click.option(
702
+ "--all",
703
+ "-a",
704
+ "uninstall_all",
705
+ is_flag=True,
706
+ help="Disable ALL tracked installations",
707
+ )
708
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
709
+ @click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
710
+ def disable_cmd(scope: str | None, uninstall_all: bool, yes: bool, force: bool) -> None:
711
+ """Disable Forge extensions.
712
+
713
+ Removes only files and settings entries that were added by Forge.
714
+ User modifications are preserved.
715
+
716
+ \b
717
+ Scope Detection (when no --scope/--all specified):
718
+ Walks up from current directory looking for existing Forge extensions
719
+ (detected by .settings.*.json.forge.* files in .claude/).
720
+ - Checks LOCAL first, then PROJECT, then USER
721
+ - Fails if no extensions found
722
+
723
+ \b
724
+ --all mode:
725
+ Disables ALL tracked installations (user + all local/project).
726
+ Uses ~/.forge/installed.json to find all installations.
727
+
728
+ \b
729
+ Examples:
730
+ forge extension disable # Auto-detect scope
731
+ forge extension disable --scope local # Disable local scope
732
+ forge extension disable --all --yes # Disable everything
733
+ """
734
+ yes = yes or force
735
+
736
+ if uninstall_all and scope is not None:
737
+ raise click.UsageError("--all and --scope are mutually exclusive.")
738
+ try:
739
+ tracking = TrackingStore()
740
+
741
+ if uninstall_all:
742
+ _uninstall_all_installations(tracking, yes)
743
+ return
744
+
745
+ if scope is None:
746
+ install_scope, project_root = find_forge_installation()
747
+ console.print(f"[dim]Auto-detected scope: {install_scope.value}[/dim]")
748
+ else:
749
+ install_scope = InstallScope(scope)
750
+ # Use canonical project root (finds .claude/ and resolves symlinks)
751
+ project_root = _resolve_project_root(install_scope)
752
+
753
+ project_path_str = str(project_root) if project_root else None
754
+ existing = tracking.get_installation(install_scope.value, project_path_str)
755
+
756
+ if existing is None:
757
+ console.print(f"[dim]No Forge installation for scope '{install_scope.value}'.[/dim]")
758
+ return
759
+
760
+ console.print(f"[bold]Will disable Forge extensions ({install_scope.value}):[/bold]")
761
+ console.print(f" Profile: {existing.profile}")
762
+ console.print(f" Mode: {existing.mode}")
763
+ console.print()
764
+
765
+ if existing.files:
766
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
767
+ table.add_column("ACTION", style="red")
768
+ table.add_column("PATH")
769
+ for f in existing.files:
770
+ # Truncate long paths for display
771
+ path_str = str(f.target_path)
772
+ if len(path_str) > 60:
773
+ path_str = path_str[:57] + "…"
774
+ table.add_row("remove", path_str)
775
+ console.print("[bold]Files:[/bold]")
776
+ console.print(table)
777
+ console.print()
778
+
779
+ if existing.settings_entries:
780
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
781
+ table.add_column("ACTION", style="red")
782
+ table.add_column("KEY")
783
+ for entry in existing.settings_entries:
784
+ table.add_row("unmerge", entry.key_path)
785
+ console.print("[bold]Settings:[/bold]")
786
+ console.print(table)
787
+
788
+ if not (force or yes):
789
+ if not click.confirm("\nProceed with disable?"):
790
+ console.print("[dim]Cancelled.[/dim]")
791
+ return
792
+
793
+ installer = Installer(scope=install_scope, project_root=project_root)
794
+ installer.uninstall()
795
+
796
+ # Remove .forge/ anchor if it's empty (no sessions, artifacts, etc.).
797
+ # .claude/ is NOT removed — it may contain user-authored content.
798
+ if project_root is not None:
799
+ forge_dir = project_root / ".forge"
800
+ if forge_dir.is_dir():
801
+ try:
802
+ forge_dir.rmdir() # Only succeeds if empty
803
+ _log.info("Removed empty %s", forge_dir)
804
+ except OSError:
805
+ pass # Non-empty: sessions/artifacts still present
806
+
807
+ console.print("\n[green]Extensions disabled.[/green]")
808
+
809
+ except NoForgeInstallationError as e:
810
+ console.print(f"[red]Error:[/red] {e}")
811
+ sys.exit(1)
812
+ except ForgeInstallError as e:
813
+ console.print(f"[red]Error:[/red] {e}")
814
+ sys.exit(1)
815
+ except TrackingCorruptedError as e:
816
+ console.print(f"[bold red]Error:[/bold red] {e}")
817
+ sys.exit(1)
818
+
819
+
820
+ @extensions.command("status")
821
+ @click.option(
822
+ "--scope",
823
+ "-S",
824
+ type=click.Choice(["local", "project", "user"]),
825
+ default=None,
826
+ help="Installation scope",
827
+ )
828
+ @click.option(
829
+ "--root",
830
+ "path",
831
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
832
+ default=None,
833
+ help="Target directory to check (default: walk up from cwd)",
834
+ )
835
+ @click.option("--all", "-a", "show_all", is_flag=True, help="Show all scopes")
836
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
837
+ def status_cmd(scope: str | None, path: str | None, show_all: bool, as_json: bool) -> None:
838
+ """Show extensions status.
839
+
840
+ Displays what Forge has enabled in the specified scope(s).
841
+
842
+ \b
843
+ Scope Detection (when no --scope/--all specified):
844
+ Walks up from current directory looking for existing Forge installations
845
+ (detected by .settings.*.json.forge.* files in .claude/).
846
+ - Checks LOCAL first, then PROJECT, then USER
847
+ - If no installation found, shows all scopes for informational purposes
848
+
849
+ \b
850
+ Examples:
851
+ forge extension status # Auto-detect
852
+ forge extension status --scope local --root /repo/api # Check specific install
853
+ forge extension status --root /repo/api # Auto-detect scope at path
854
+ forge extension status --all # Show all scopes
855
+ """
856
+ import os
857
+
858
+ anchor = Path(path) if path else None
859
+
860
+ if show_all and scope is not None:
861
+ raise click.UsageError("--all and --scope are mutually exclusive.")
862
+ if show_all and anchor is not None:
863
+ raise click.UsageError("--all and --root are mutually exclusive.")
864
+ if scope == "user" and anchor is not None:
865
+ raise click.UsageError("--scope user is global; --root is not applicable.")
866
+
867
+ try:
868
+ tracking = TrackingStore()
869
+ tracking.read()
870
+ except TrackingCorruptedError as e:
871
+ console.print(f"[bold red]Error:[/bold red] {e}")
872
+ raise SystemExit(1) from None
873
+
874
+ cwd = os.getcwd()
875
+
876
+ # When auto-detect finds the real install root (which may differ from
877
+ # anchor if --root points at a subdirectory), use it for tracking lookups.
878
+ detected_root: Path | None = None
879
+
880
+ detected_scope_name: str | None = None
881
+ if show_all:
882
+ scopes = [InstallScope.USER, InstallScope.PROJECT, InstallScope.LOCAL]
883
+ elif scope is None and anchor is None:
884
+ try:
885
+ detected_scope, detected_root = find_forge_installation()
886
+ detected_scope_name = detected_scope.value
887
+ scopes = [detected_scope]
888
+ except NoForgeInstallationError:
889
+ scopes = [InstallScope.USER, InstallScope.PROJECT, InstallScope.LOCAL]
890
+ elif scope is None and anchor is not None:
891
+ # --root without --scope: auto-detect scope at that path
892
+ try:
893
+ detected_scope, detected_root = find_forge_installation(start=anchor)
894
+ detected_scope_name = detected_scope.value
895
+ scopes = [detected_scope]
896
+ except NoForgeInstallationError:
897
+ scopes = [InstallScope.USER, InstallScope.PROJECT, InstallScope.LOCAL]
898
+ else:
899
+ scopes = [InstallScope(scope)]
900
+
901
+ # Use the detected root (from walk-up) over the raw anchor for lookups.
902
+ effective_anchor = detected_root if detected_root is not None else anchor
903
+
904
+ if as_json:
905
+ import json
906
+
907
+ data = []
908
+ for s in scopes:
909
+ try:
910
+ project_root = _resolve_project_root(s, anchor=effective_anchor)
911
+ project_path_str = str(project_root) if project_root else None
912
+ except NoClaudeDirectoryError:
913
+ project_path_str = None
914
+
915
+ inst = tracking.get_installation(s.value, project_path_str)
916
+ if inst is None:
917
+ continue
918
+ data.append(
919
+ {
920
+ "scope": s.value,
921
+ "profile": inst.profile,
922
+ "mode": inst.mode,
923
+ "modules": list(inst.modules_enabled),
924
+ "files_count": len(inst.files),
925
+ "settings_count": len(inst.settings_entries),
926
+ "installed_at": inst.installed_at,
927
+ "updated_at": inst.updated_at,
928
+ }
929
+ )
930
+ click.echo(json.dumps(data, indent=2, default=str))
931
+ return
932
+
933
+ if detected_scope_name:
934
+ console.print(f"[dim]Auto-detected scope: {detected_scope_name}[/dim]")
935
+ elif scope is None and not show_all:
936
+ location = display_path(str(anchor)) if anchor else display_path(cwd)
937
+ console.print(f"[dim]No extensions detected in {location}[/dim]")
938
+ console.print("[dim]Showing all scopes for this location:[/dim]")
939
+
940
+ for s in scopes:
941
+ try:
942
+ project_root = _resolve_project_root(s, anchor=effective_anchor)
943
+ project_path_str = str(project_root) if project_root else None
944
+ except NoClaudeDirectoryError:
945
+ project_path_str = None
946
+
947
+ installation = tracking.get_installation(s.value, project_path_str)
948
+
949
+ console.print(f"\n[bold]Scope: {s.value}[/bold]")
950
+
951
+ if installation is None:
952
+ if s == InstallScope.USER:
953
+ location = "~/.claude"
954
+ elif project_path_str:
955
+ location = project_path_str
956
+ else:
957
+ location = str(anchor) if anchor else cwd
958
+ console.print(f" [dim]Not enabled at {display_path(location)}[/dim]")
959
+ continue
960
+
961
+ console.print(f" Profile: {installation.profile}")
962
+ console.print(f" Mode: {installation.mode}")
963
+ console.print(f" Modules: {', '.join(installation.modules_enabled)}")
964
+ console.print(f" Files: {len(installation.files)}")
965
+ console.print(f" Settings: {len(installation.settings_entries)} entries")
966
+ console.print(f" Installed: {installation.installed_at}")
967
+ console.print(f" Updated: {installation.updated_at}")
968
+
969
+ try:
970
+ inst_profile = InstallProfile(installation.profile)
971
+ gated = get_gated_skills(inst_profile)
972
+ if gated:
973
+ skill_list = ", ".join(f"/forge:{name}" for name, _ in gated)
974
+ required = gated[0][1].value
975
+ console.print(f" [dim]Gated: {skill_list} (needs --profile {required})[/dim]")
976
+ except ValueError:
977
+ pass
978
+
979
+ if installation.files and len(installation.files) <= 10:
980
+ console.print("\n [dim]Files:[/dim]")
981
+ for f in installation.files:
982
+ console.print(f" - {display_path(f.target_path)}")
983
+
984
+ if scope is None and not show_all and anchor is None:
985
+ local_installed = any(
986
+ tracking.get_installation(
987
+ s.value,
988
+ str(_resolve_project_root(s)) if s != InstallScope.USER else None,
989
+ )
990
+ for s in scopes
991
+ if s == InstallScope.USER or _can_resolve_project_root(s)
992
+ )
993
+ if not local_installed:
994
+ all_installations = tracking.list_installations()
995
+ if all_installations:
996
+ console.print(
997
+ f"\n[dim]Tip: {len(all_installations)} installation(s) exist elsewhere. "
998
+ "Use 'forge info' to see all.[/dim]"
999
+ )
1000
+ else:
1001
+ console.print("\n[dim]Tip: Run 'forge extension enable' to set up Forge.[/dim]")