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,55 @@
1
+ """Forge installer for Claude Code extensions.
2
+
3
+ Provides `forge init` / `forge update` / `forge uninstall` / `forge status` commands
4
+ to manage installation of commands, agents, hooks, skills, and settings.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .exceptions import (
10
+ ConflictError,
11
+ FileConflictError,
12
+ ForgeInstallError,
13
+ NotInstalledError,
14
+ SettingsConflictError,
15
+ SourceNotFoundError,
16
+ TrackingCorruptedError,
17
+ )
18
+ from .models import (
19
+ FilePlan,
20
+ Installation,
21
+ InstalledFile,
22
+ InstalledManifest,
23
+ InstalledSettingsEntry,
24
+ InstallMode,
25
+ InstallModule,
26
+ InstallPlan,
27
+ InstallProfile,
28
+ InstallScope,
29
+ SettingsPlan,
30
+ )
31
+
32
+ __all__ = [
33
+ # Enums
34
+ "InstallScope",
35
+ "InstallMode",
36
+ "InstallProfile",
37
+ "InstallModule",
38
+ # Tracking dataclasses
39
+ "InstalledFile",
40
+ "InstalledSettingsEntry",
41
+ "Installation",
42
+ "InstalledManifest",
43
+ # Plan dataclasses
44
+ "FilePlan",
45
+ "SettingsPlan",
46
+ "InstallPlan",
47
+ # Exceptions
48
+ "ForgeInstallError",
49
+ "ConflictError",
50
+ "FileConflictError",
51
+ "SettingsConflictError",
52
+ "TrackingCorruptedError",
53
+ "NotInstalledError",
54
+ "SourceNotFoundError",
55
+ ]
forge/install/cli.py ADDED
@@ -0,0 +1,281 @@
1
+ """CLI command for forge info (global installation information).
2
+
3
+ The info command remains at top-level for quick diagnostics.
4
+ Other installation lifecycle commands have moved to `forge extensions` group.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from forge.core.paths import display_path
14
+
15
+ from .tracking import TrackingStore
16
+
17
+ console = Console()
18
+
19
+
20
+ # --- Info Command ---
21
+
22
+
23
+ @click.command("info")
24
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
25
+ @click.option("--sessions", "-s", "max_sessions", default=5, help="Max recent sessions to show")
26
+ def info_cmd(as_json: bool, max_sessions: int) -> None:
27
+ """Show global Forge installation information.
28
+
29
+ Displays comprehensive system status including:
30
+ - Forge and Claude Code versions
31
+ - Active session
32
+ - Tracked installations
33
+ - Registered proxies
34
+ - Recent sessions
35
+
36
+ \b
37
+ Examples:
38
+ forge info # Full dashboard
39
+ forge info --json # JSON output for scripting
40
+ forge info --sessions 10 # Show more recent sessions
41
+ """
42
+ info_data = _gather_info_data(max_sessions)
43
+
44
+ if as_json:
45
+ console.print_json(data=info_data)
46
+ return
47
+
48
+ _print_info_human(info_data)
49
+
50
+
51
+ def _gather_info_data(max_sessions: int) -> dict:
52
+ """Gather all info data into a dict (for both JSON and human output)."""
53
+ import shutil
54
+ import subprocess
55
+
56
+ from .models import parse_installation_key
57
+
58
+ data: dict = {}
59
+
60
+ # Forge info
61
+ try:
62
+ from importlib.metadata import version
63
+
64
+ data["forge_version"] = version("multi-forge")
65
+ except Exception:
66
+ data["forge_version"] = "unknown"
67
+
68
+ from forge.core.paths import get_forge_home
69
+
70
+ data["forge_home"] = str(get_forge_home())
71
+
72
+ # Claude Code info
73
+ claude_path = shutil.which("claude")
74
+ data["claude_code"] = {
75
+ "path": claude_path,
76
+ "version": None,
77
+ }
78
+ if claude_path:
79
+ try:
80
+ result = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=5)
81
+ if result.returncode == 0:
82
+ version_str = result.stdout.strip()
83
+ if " (Claude Code)" in version_str:
84
+ version_str = version_str.replace(" (Claude Code)", "")
85
+ data["claude_code"]["version"] = version_str
86
+ except Exception:
87
+ pass
88
+
89
+ # Python/uv versions
90
+ try:
91
+ import sys
92
+
93
+ data["python_version"] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
94
+ except Exception:
95
+ data["python_version"] = "unknown"
96
+
97
+ try:
98
+ result = subprocess.run(["uv", "--version"], capture_output=True, text=True, timeout=5)
99
+ if result.returncode == 0:
100
+ uv_ver = result.stdout.strip().replace("uv ", "")
101
+ # Strip git hash suffix if present (e.g., "0.6.14 (a4cec56dc 2025-04-09)")
102
+ if " (" in uv_ver:
103
+ uv_ver = uv_ver.split(" (")[0]
104
+ data["uv_version"] = uv_ver
105
+ except Exception:
106
+ data["uv_version"] = "unknown"
107
+
108
+ # Installations
109
+ tracking = TrackingStore()
110
+ try:
111
+ manifest = tracking.read()
112
+ except Exception as e:
113
+ data["tracking_file"] = str(tracking.path)
114
+ data["tracking_error"] = str(e)
115
+ data["installations"] = []
116
+ data["proxies"] = []
117
+ data["sessions"] = []
118
+ return data
119
+ data["tracking_file"] = str(tracking.path)
120
+ data["installations"] = []
121
+ for key, inst in manifest.installations.items():
122
+ scope, project_path = parse_installation_key(key)
123
+ data["installations"].append(
124
+ {
125
+ "key": key,
126
+ "scope": scope,
127
+ "project_path": project_path,
128
+ "profile": inst.profile,
129
+ "mode": inst.mode,
130
+ "files_count": len(inst.files),
131
+ "settings_count": len(inst.settings_entries),
132
+ }
133
+ )
134
+
135
+ # Proxies
136
+ data["proxies"] = []
137
+ try:
138
+ from forge.proxy.proxies import ProxyRegistryStore
139
+
140
+ proxy_store = ProxyRegistryStore()
141
+ proxy_registry = proxy_store.read()
142
+ for proxy_id, proxy_entry in proxy_registry.proxies.items():
143
+ data["proxies"].append(
144
+ {
145
+ "proxy_id": proxy_id,
146
+ "base_url": proxy_entry.base_url,
147
+ "template": proxy_entry.template,
148
+ }
149
+ )
150
+ except Exception:
151
+ pass
152
+
153
+ # Recent sessions
154
+ data["sessions"] = []
155
+ try:
156
+ from forge.session import SessionManager
157
+
158
+ manager = SessionManager()
159
+ sessions = manager.list_sessions(include_incognito=False)
160
+ for name, entry in sessions[:max_sessions]:
161
+ data["sessions"].append(
162
+ {
163
+ "name": name,
164
+ "worktree": entry.worktree_path,
165
+ "last_accessed": entry.last_accessed_at,
166
+ }
167
+ )
168
+ except Exception:
169
+ pass
170
+
171
+ return data
172
+
173
+
174
+ def _print_info_human(data: dict) -> None:
175
+ """Print info in human-readable format."""
176
+ # Header
177
+ console.print("\n[bold cyan]Forge Info[/bold cyan]")
178
+ console.print("[cyan]" + "─" * 50 + "[/cyan]")
179
+
180
+ # System info
181
+ console.print("\n[bold]System[/bold]")
182
+ console.print(f" Forge: {data.get('forge_version', 'unknown')}")
183
+ console.print(f" Install Path: {display_path(data.get('forge_home', 'unknown'))}")
184
+
185
+ cc = data.get("claude_code", {})
186
+ if cc.get("version"):
187
+ cc_info = cc["version"]
188
+ elif cc.get("path"):
189
+ cc_info = f"at {display_path(cc['path'])}"
190
+ else:
191
+ cc_info = "[dim]not found[/dim]"
192
+ console.print(f" Claude Code: {cc_info}")
193
+
194
+ console.print(f" Python: {data.get('python_version', 'unknown')}")
195
+ console.print(f" uv: {data.get('uv_version', 'unknown')}")
196
+
197
+ # Tracking errors (e.g., stale pre-OSS manifest)
198
+ if "tracking_error" in data:
199
+ console.print("\n[bold red]Tracking Error[/bold red]")
200
+ console.print(f" {data['tracking_error']}")
201
+
202
+ # Installations
203
+ installations = data.get("installations", [])
204
+ console.print(f"\n[bold]Installations[/bold] ({len(installations)})")
205
+ if installations:
206
+ table = Table(
207
+ show_header=True,
208
+ header_style="bold",
209
+ box=None,
210
+ expand=False,
211
+ padding=(0, 1),
212
+ )
213
+ table.add_column("SCOPE", width=8)
214
+ table.add_column("PATH", overflow="fold", no_wrap=False)
215
+ table.add_column("PROFILE", width=10)
216
+ table.add_column("MODE", width=8)
217
+
218
+ for inst in installations:
219
+ raw_path = inst.get("project_path")
220
+ path_display = display_path(raw_path) if raw_path else "[dim]~/.claude[/dim]"
221
+ table.add_row(
222
+ inst.get("scope", ""),
223
+ path_display,
224
+ inst.get("profile", ""),
225
+ inst.get("mode", ""),
226
+ )
227
+ console.print(table)
228
+ else:
229
+ console.print(" [dim](none)[/dim]")
230
+
231
+ # Proxies
232
+ proxies = data.get("proxies", [])
233
+ console.print(f"\n[bold]Proxies[/bold] ({len(proxies)})")
234
+ if proxies:
235
+ table = Table(
236
+ show_header=True,
237
+ header_style="bold",
238
+ box=None,
239
+ expand=False,
240
+ padding=(0, 1),
241
+ )
242
+ table.add_column("PROXY ID", width=25)
243
+ table.add_column("TEMPLATE", width=20)
244
+ table.add_column("BASE URL", overflow="fold")
245
+
246
+ for proxy in proxies:
247
+ table.add_row(
248
+ proxy.get("proxy_id", ""),
249
+ proxy.get("template") or "[dim]-[/dim]",
250
+ proxy.get("base_url", ""),
251
+ )
252
+ console.print(table)
253
+ else:
254
+ console.print(" [dim](none)[/dim]")
255
+
256
+ # Recent sessions
257
+ sessions = data.get("sessions", [])
258
+ console.print(f"\n[bold]Recent Sessions[/bold] ({len(sessions)} shown)")
259
+ if sessions:
260
+ table = Table(
261
+ show_header=True,
262
+ header_style="bold",
263
+ box=None,
264
+ expand=False,
265
+ padding=(0, 1),
266
+ )
267
+ table.add_column("NAME", width=25)
268
+ table.add_column("LAST ACCESSED", width=20)
269
+
270
+ for sess in sessions:
271
+ # Format timestamp
272
+ last_accessed = sess.get("last_accessed", "")
273
+ if last_accessed:
274
+ # Truncate to date+time
275
+ last_accessed = last_accessed[:19].replace("T", " ")
276
+ table.add_row(sess.get("name", ""), last_accessed)
277
+ console.print(table)
278
+ else:
279
+ console.print(" [dim](none)[/dim]")
280
+
281
+ console.print()
@@ -0,0 +1,163 @@
1
+ """Exceptions for Forge Installer.
2
+
3
+ Follows the pattern from session/exceptions.py: specific exception types
4
+ with context fields for debugging.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class ForgeInstallError(Exception):
13
+ """Base exception for install module."""
14
+
15
+
16
+ class ConflictError(ForgeInstallError):
17
+ """Base for conflict errors."""
18
+
19
+
20
+ class FileConflictError(ConflictError):
21
+ """Raised when a file conflict is detected.
22
+
23
+ Attributes:
24
+ path: The conflicting file path.
25
+ reason: Why the conflict occurred.
26
+ """
27
+
28
+ def __init__(self, path: str, reason: str) -> None:
29
+ self.path = path
30
+ self.reason = reason
31
+ super().__init__(f"file conflict at '{path}': {reason}")
32
+
33
+
34
+ class SettingsConflictError(ConflictError):
35
+ """Raised when a settings conflict is detected.
36
+
37
+ Attributes:
38
+ key_path: The conflicting settings key (dot-notation).
39
+ current_value: The existing value in settings.
40
+ forge_value: The value Forge wants to set.
41
+ """
42
+
43
+ def __init__(self, key_path: str, current_value: Any, forge_value: Any) -> None:
44
+ self.key_path = key_path
45
+ self.current_value = current_value
46
+ self.forge_value = forge_value
47
+ super().__init__(f"settings conflict at '{key_path}': " f"current={current_value!r}, forge={forge_value!r}")
48
+
49
+
50
+ class TrackingCorruptedError(ForgeInstallError):
51
+ """Raised when tracking file cannot be parsed.
52
+
53
+ Attributes:
54
+ path: Path to the problematic tracking file.
55
+ reason: What went wrong during parsing.
56
+ """
57
+
58
+ def __init__(self, path: str, reason: str) -> None:
59
+ self.path = path
60
+ self.reason = reason
61
+ super().__init__(f"tracking file at '{path}': {reason}")
62
+
63
+
64
+ class NotInstalledError(ForgeInstallError):
65
+ """Raised when trying to update/uninstall with no installation.
66
+
67
+ Attributes:
68
+ scope: The scope that has no installation.
69
+ """
70
+
71
+ def __init__(self, scope: str) -> None:
72
+ self.scope = scope
73
+ super().__init__(f"no Forge installation found for scope '{scope}'")
74
+
75
+
76
+ class SourceNotFoundError(ForgeInstallError):
77
+ """Raised when source extension files are missing.
78
+
79
+ Attributes:
80
+ module: The module whose source is missing.
81
+ path: Expected path to the source.
82
+ """
83
+
84
+ def __init__(self, module: str, path: str) -> None:
85
+ self.module = module
86
+ self.path = path
87
+ super().__init__(f"source for module '{module}' not found at '{path}'")
88
+
89
+
90
+ class NestedClaudeDirectoryError(ForgeInstallError):
91
+ """Raised when project_root is inside a .claude directory.
92
+
93
+ This prevents creating nested .claude/.claude directories which can
94
+ happen if `forge init --project` is run from within a .claude directory.
95
+
96
+ Attributes:
97
+ project_root: The problematic project root path.
98
+ """
99
+
100
+ def __init__(self, project_root: str) -> None:
101
+ self.project_root = project_root
102
+ super().__init__(
103
+ f"project root '{project_root}' is inside a .claude directory; "
104
+ "this would create nested .claude/.claude directories. "
105
+ "Run from the project root, not from within .claude/"
106
+ )
107
+
108
+
109
+ class NoClaudeDirectoryError(ForgeInstallError):
110
+ """Raised when no .claude directory is found walking up from cwd.
111
+
112
+ This indicates Forge is being run outside of a Claude Code project,
113
+ and the user hasn't explicitly specified a scope.
114
+
115
+ Attributes:
116
+ start_path: The directory where the search started.
117
+ """
118
+
119
+ def __init__(self, start_path: str) -> None:
120
+ self.start_path = start_path
121
+ super().__init__(
122
+ f"no .claude directory found walking up from '{start_path}'. "
123
+ "Run from within a Claude Code project, or use '--scope user' for global install."
124
+ )
125
+
126
+
127
+ class NoForgeInstallationError(ForgeInstallError):
128
+ """Raised when no Forge installation is found walking up from cwd.
129
+
130
+ Different from NotInstalledError: this is for auto-detection when no
131
+ scope is specified, whereas NotInstalledError is for a specific scope.
132
+
133
+ Attributes:
134
+ start_path: The directory where the search started.
135
+ """
136
+
137
+ def __init__(self, start_path: str) -> None:
138
+ self.start_path = start_path
139
+ super().__init__(
140
+ f"no Forge installation found walking up from '{start_path}'. "
141
+ "Run 'forge init' first, or specify a scope explicitly."
142
+ )
143
+
144
+
145
+ class PathBoundaryViolationError(ForgeInstallError):
146
+ """Raised when a path is outside its expected boundary.
147
+
148
+ This is a security check to prevent malicious tracking file modifications
149
+ from causing deletion of arbitrary system files.
150
+
151
+ Attributes:
152
+ path: The offending path.
153
+ expected_base: The expected parent directory.
154
+ operation: What was being attempted (e.g., "delete").
155
+ """
156
+
157
+ def __init__(self, path: str, expected_base: str, operation: str = "access") -> None:
158
+ self.path = path
159
+ self.expected_base = expected_base
160
+ self.operation = operation
161
+ super().__init__(
162
+ f"security violation: refusing to {operation} '{path}' - " f"not within expected boundary '{expected_base}'"
163
+ )
forge/install/hooks.py ADDED
@@ -0,0 +1,109 @@
1
+ """Hook installation detection.
2
+
3
+ Checks whether Forge hooks are installed in Claude Code settings,
4
+ used by CLI commands to warn when features depend on hooks that aren't present.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+
13
+ def _find_claude_dir(start: Path) -> Path | None:
14
+ """Walk up from start to find the nearest .claude/ directory.
15
+
16
+ Returns the directory containing .claude/, or None if not found
17
+ before reaching the filesystem root.
18
+ """
19
+ current = start.resolve()
20
+ for _ in range(50): # safety bound
21
+ if (current / ".claude").is_dir():
22
+ return current
23
+ parent = current.parent
24
+ if parent == current:
25
+ return None
26
+ current = parent
27
+ return None
28
+
29
+
30
+ def _settings_paths(worktree_path: Path) -> list[Path]:
31
+ """Return settings files to scan in priority order (local > project > user).
32
+
33
+ Walks up from worktree_path to find the nearest .claude/ directory,
34
+ so detection works correctly from subdirectories.
35
+ """
36
+ from forge.session.claude.paths import get_claude_home
37
+
38
+ project_root = _find_claude_dir(worktree_path) or worktree_path
39
+ return [
40
+ project_root / ".claude" / "settings.local.json",
41
+ project_root / ".claude" / "settings.json",
42
+ get_claude_home() / "settings.local.json",
43
+ get_claude_home() / "settings.json",
44
+ ]
45
+
46
+
47
+ def _entry_has_command(entry: dict, needle: str) -> bool:
48
+ """Check if a hook entry contains a command matching the needle.
49
+
50
+ System boundary: reads Claude Code settings.json which may contain
51
+ either format depending on when the user last ran forge extensions sync.
52
+ - Current: {"hooks": [{"type": "command", "command": "..."}]}
53
+ - Pre-sync: {"type": "command", "command": "..."}
54
+ """
55
+ # Pre-sync format: command at entry top level
56
+ cmd = entry.get("command")
57
+ if isinstance(cmd, str) and needle in cmd:
58
+ return True
59
+ # Current format: nested hooks array
60
+ for hook in entry.get("hooks", []):
61
+ if not isinstance(hook, dict):
62
+ continue
63
+ cmd = hook.get("command", "")
64
+ if isinstance(cmd, str) and needle in cmd:
65
+ return True
66
+ return False
67
+
68
+
69
+ def has_forge_hook(worktree_path: Path, hook_type: str, command_needle: str = "forge hook") -> bool:
70
+ """Check if a specific Forge hook type is installed in any settings scope.
71
+
72
+ Scans local, project, and user settings files for a hook entry whose
73
+ command contains *command_needle*. The default needle ``"forge hook"``
74
+ matches any Forge hook; pass a more specific string like
75
+ ``"forge hook policy-check"`` to require a particular handler.
76
+
77
+ Args:
78
+ worktree_path: Project/worktree root to resolve local/project settings.
79
+ hook_type: Claude Code hook event name (e.g., "SessionStart", "PreToolUse", "Stop").
80
+ command_needle: Substring to look for in the command string.
81
+ """
82
+ for settings_path in _settings_paths(worktree_path):
83
+ try:
84
+ data = json.loads(settings_path.read_text())
85
+ if not isinstance(data, dict):
86
+ continue
87
+ hooks = data.get("hooks")
88
+ if not isinstance(hooks, dict):
89
+ continue
90
+ hook_entries = hooks.get(hook_type)
91
+ if not hook_entries or not isinstance(hook_entries, list):
92
+ continue
93
+ for entry in hook_entries:
94
+ if not isinstance(entry, dict):
95
+ continue
96
+ if _entry_has_command(entry, command_needle):
97
+ return True
98
+ except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError, AttributeError):
99
+ continue
100
+ return False
101
+
102
+
103
+ def has_forge_hooks(worktree_path: Path) -> bool:
104
+ """Check if any Forge hooks are installed.
105
+
106
+ Uses SessionStart as the sentinel — it's included in all Forge
107
+ installations and is the minimum viable hook for session tracking.
108
+ """
109
+ return has_forge_hook(worktree_path, "SessionStart")