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,238 @@
1
+ """Tracking store for ~/.forge/installed.json.
2
+
3
+ Manages the persistent record of what Forge has installed, enabling
4
+ reversible update and uninstall operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from dataclasses import asdict
12
+ from pathlib import Path
13
+
14
+ import dacite
15
+
16
+ from forge.core.paths import get_forge_home
17
+ from forge.core.state import atomic_write_json, file_lock_for_target
18
+
19
+ from .exceptions import TrackingCorruptedError
20
+ from .models import (
21
+ TRACKING_VERSION,
22
+ Installation,
23
+ InstalledManifest,
24
+ make_installation_key,
25
+ parse_installation_key,
26
+ )
27
+
28
+ # Constants
29
+ TRACKING_FILENAME = "installed.json"
30
+
31
+
32
+ def get_tracking_path() -> Path:
33
+ """Get path to tracking file (~/.forge/installed.json)."""
34
+ return get_forge_home() / TRACKING_FILENAME
35
+
36
+
37
+ def compute_checksum(path: Path) -> str:
38
+ """Compute SHA256 checksum of a file.
39
+
40
+ Args:
41
+ path: Path to the file to checksum.
42
+
43
+ Returns:
44
+ Hex-encoded SHA256 hash of file contents.
45
+ """
46
+ sha256 = hashlib.sha256()
47
+ with open(path, "rb") as f:
48
+ for chunk in iter(lambda: f.read(8192), b""):
49
+ sha256.update(chunk)
50
+ return sha256.hexdigest()
51
+
52
+
53
+ class TrackingStore:
54
+ """Manage the tracking manifest at ~/.forge/installed.json.
55
+
56
+ The tracking manifest records what Forge has installed so that:
57
+ - `forge update` updates only tracked items
58
+ - `forge uninstall` removes only tracked files and settings entries
59
+
60
+ Error handling:
61
+ - Missing file: Return empty manifest (not an error)
62
+ - Corrupted JSON: Raise TrackingCorruptedError (fail loudly to preserve safety)
63
+ """
64
+
65
+ def __init__(self, tracking_path: Path | None = None) -> None:
66
+ """Initialize store.
67
+
68
+ Args:
69
+ tracking_path: Override path to tracking file (for testing).
70
+ """
71
+ self._path = tracking_path or get_tracking_path()
72
+
73
+ @property
74
+ def path(self) -> Path:
75
+ """Return the full path to the tracking file."""
76
+ return self._path
77
+
78
+ def exists(self) -> bool:
79
+ """Check if tracking file exists."""
80
+ return self._path.is_file()
81
+
82
+ def read(self) -> InstalledManifest:
83
+ """Read tracking manifest.
84
+
85
+ Returns empty manifest if file doesn't exist.
86
+ Raises TrackingCorruptedError if file exists but is invalid.
87
+
88
+ Returns:
89
+ The tracking manifest.
90
+
91
+ Raises:
92
+ TrackingCorruptedError: If file is corrupted or has invalid schema.
93
+ """
94
+ if not self.exists():
95
+ return InstalledManifest()
96
+
97
+ try:
98
+ with open(self._path, encoding="utf-8") as f:
99
+ data = json.load(f)
100
+ except json.JSONDecodeError as e:
101
+ raise TrackingCorruptedError(str(self._path), f"invalid JSON: {e}")
102
+ except OSError as e:
103
+ raise TrackingCorruptedError(str(self._path), f"read error: {e}")
104
+
105
+ # Version check (no migration support)
106
+ version = data.get("version", 1)
107
+ if version != TRACKING_VERSION:
108
+ raise TrackingCorruptedError(
109
+ str(self._path),
110
+ f"incompatible version {version} (this Forge expects {TRACKING_VERSION}). "
111
+ f"Delete this file and run 'forge extension enable' again.",
112
+ )
113
+
114
+ # Guard: reject manifests from pre-OSS patching builds.
115
+ # patched_files was removed from the Installation dataclass; dacite
116
+ # strict=True rejects even "patched_files": []. Check raw JSON before
117
+ # deserialization so the error message is actionable.
118
+ installations = data.get("installations", {})
119
+ for inst in installations.values():
120
+ if isinstance(inst, dict) and "patched_files" in inst:
121
+ raise TrackingCorruptedError(
122
+ str(self._path),
123
+ "This Forge install manifest was created by a pre-OSS patching build. "
124
+ f"Remove {self._path} and run `forge extension enable` again. "
125
+ "If Claude Code was patched, run `claude update` or reinstall Claude Code.",
126
+ )
127
+
128
+ try:
129
+ return dacite.from_dict(
130
+ data_class=InstalledManifest,
131
+ data=data,
132
+ config=dacite.Config(strict=True),
133
+ )
134
+ except (dacite.DaciteError, TypeError, KeyError) as e:
135
+ raise TrackingCorruptedError(str(self._path), f"deserialization error: {e}")
136
+
137
+ def write(self, manifest: InstalledManifest) -> None:
138
+ """Write tracking manifest atomically.
139
+
140
+ Uses core.state.atomic_write_json for atomic writes.
141
+ Creates parent directory if needed.
142
+
143
+ Args:
144
+ manifest: The manifest to write.
145
+ """
146
+ data = asdict(manifest)
147
+ atomic_write_json(self._path, data)
148
+
149
+ def get_installation(self, scope: str, project_path: str | None = None) -> Installation | None:
150
+ """Get installation for a specific scope and project.
151
+
152
+ Args:
153
+ scope: The scope to look up ("user", "project", "local").
154
+ project_path: Project path (required for project/local scope).
155
+
156
+ Returns:
157
+ The Installation record, or None if not installed.
158
+ """
159
+ key = make_installation_key(scope, project_path)
160
+ manifest = self.read()
161
+ return manifest.installations.get(key)
162
+
163
+ def set_installation(self, scope: str, installation: Installation, project_path: str | None = None) -> None:
164
+ """Set installation for a scope and project.
165
+
166
+ Args:
167
+ scope: The scope to set.
168
+ installation: The installation record.
169
+ project_path: Project path (required for project/local scope).
170
+ """
171
+ key = make_installation_key(scope, project_path)
172
+ installation.project_path = project_path
173
+ with file_lock_for_target(target_path=self._path, timeout_s=5.0):
174
+ manifest = self.read()
175
+ manifest.installations[key] = installation
176
+ self.write(manifest)
177
+
178
+ def remove_installation(self, scope: str, project_path: str | None = None) -> bool:
179
+ """Remove installation for a scope and project.
180
+
181
+ Args:
182
+ scope: The scope to remove.
183
+ project_path: Project path (required for project/local scope).
184
+
185
+ Returns:
186
+ True if removed, False if didn't exist.
187
+ """
188
+ key = make_installation_key(scope, project_path)
189
+ with file_lock_for_target(target_path=self._path, timeout_s=5.0):
190
+ manifest = self.read()
191
+ if key not in manifest.installations:
192
+ return False
193
+ del manifest.installations[key]
194
+ self.write(manifest)
195
+ return True
196
+
197
+ def list_installations(self) -> list[tuple[str, str | None, Installation]]:
198
+ """List all tracked installations.
199
+
200
+ Returns:
201
+ List of (scope, project_path, installation) tuples.
202
+ """
203
+ manifest = self.read()
204
+ result = []
205
+ for key, installation in manifest.installations.items():
206
+ scope, project_path = parse_installation_key(key)
207
+ result.append((scope, project_path, installation))
208
+ return result
209
+
210
+ def has_installation(self, scope: str, project_path: str | None = None) -> bool:
211
+ """Check if an installation exists for the given scope and project.
212
+
213
+ Args:
214
+ scope: The scope to check.
215
+ project_path: Project path (required for project/local scope).
216
+
217
+ Returns:
218
+ True if installation exists.
219
+ """
220
+ return self.get_installation(scope, project_path) is not None
221
+
222
+ def is_forge_managed(self, path: str, scope: str, project_path: str | None = None) -> bool:
223
+ """Check if a path is managed by Forge in the given scope.
224
+
225
+ Args:
226
+ path: Absolute path to check.
227
+ scope: Scope to check within.
228
+ project_path: Project path (required for project/local scope).
229
+
230
+ Returns:
231
+ True if the path is a Forge-managed file.
232
+ """
233
+ installation = self.get_installation(scope, project_path)
234
+ if installation is None:
235
+ return False
236
+
237
+ normalized = str(Path(path).resolve())
238
+ return any(str(Path(f.target_path).resolve()) == normalized for f in installation.files)
@@ -0,0 +1,141 @@
1
+ """Claude Code minimum version detection and enforcement.
2
+
3
+ Forge requires a minimum Claude Code version to ensure hooks, policy enforcement,
4
+ and session features work correctly. This module provides cached version detection
5
+ and comparison utilities used by the installer and session launch flow.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import subprocess
12
+ import time
13
+ from dataclasses import dataclass
14
+
15
+ from packaging.version import InvalidVersion, Version
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Minimum Claude Code version required by Forge.
20
+ # v2.1.78: hooks load in worktrees, StopFailure event, PreToolUse deny fix (v2.1.77),
21
+ # transcript_path correct for forked/resumed sessions (v2.1.72).
22
+ MIN_CLAUDE_CODE_VERSION = "2.1.78"
23
+
24
+ # Process-scoped cache to avoid running `claude --version` on every call.
25
+ _VERSION_CACHE_TTL_S = 300 # 5 minutes
26
+ _cached_version: tuple[float, str | None] | None = None
27
+
28
+
29
+ @dataclass
30
+ class VersionCheckResult:
31
+ """Result of checking Claude Code version against the minimum."""
32
+
33
+ ok: bool
34
+ version: str | None
35
+ minimum: str
36
+ reason: str
37
+
38
+
39
+ def get_claude_runtime_version() -> str | None:
40
+ """Detect the installed Claude Code version via ``claude --version``.
41
+
42
+ Returns the version string (e.g. ``"2.1.78"``) or None if Claude Code
43
+ is not installed, times out, or produces unparseable output.
44
+
45
+ Results are cached for ``_VERSION_CACHE_TTL_S`` seconds to avoid
46
+ repeated subprocess calls within a single CLI invocation.
47
+ """
48
+ global _cached_version # noqa: PLW0603 — module-level cache by design
49
+
50
+ now = time.monotonic()
51
+ if _cached_version is not None:
52
+ cached_at, cached_value = _cached_version
53
+ if now - cached_at < _VERSION_CACHE_TTL_S:
54
+ return cached_value
55
+
56
+ version = _run_claude_version()
57
+ _cached_version = (now, version)
58
+ return version
59
+
60
+
61
+ def _run_claude_version() -> str | None:
62
+ """Run ``claude --version`` and parse the output."""
63
+ try:
64
+ result = subprocess.run(
65
+ ["claude", "--version"],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=5,
69
+ )
70
+ if result.returncode != 0:
71
+ return None
72
+ raw = result.stdout.strip()
73
+ if not raw:
74
+ return None
75
+ # Output is like "2.1.78 (Claude Code)" — strip the suffix
76
+ if " (Claude Code)" in raw:
77
+ raw = raw.replace(" (Claude Code)", "")
78
+ return raw.split()[0] if raw else None
79
+ except FileNotFoundError:
80
+ return None
81
+ except subprocess.TimeoutExpired:
82
+ return None
83
+ except Exception:
84
+ logger.debug("Unexpected error detecting Claude Code version", exc_info=True)
85
+ return None
86
+
87
+
88
+ def check_minimum_version(version_str: str | None = None) -> VersionCheckResult:
89
+ """Check whether the installed Claude Code meets the minimum version.
90
+
91
+ Args:
92
+ version_str: Explicit version string (for testing). If None, detects
93
+ the runtime version via ``get_claude_runtime_version()``.
94
+ """
95
+ minimum = MIN_CLAUDE_CODE_VERSION
96
+
97
+ if version_str is None:
98
+ version_str = get_claude_runtime_version()
99
+
100
+ if version_str is None:
101
+ return VersionCheckResult(
102
+ ok=False,
103
+ version=None,
104
+ minimum=minimum,
105
+ reason="Claude Code not found. Install it first: https://docs.anthropic.com/en/docs/claude-code",
106
+ )
107
+
108
+ try:
109
+ detected = Version(version_str)
110
+ except InvalidVersion:
111
+ return VersionCheckResult(
112
+ ok=False,
113
+ version=version_str,
114
+ minimum=minimum,
115
+ reason=f"Could not parse Claude Code version '{version_str}'.",
116
+ )
117
+
118
+ required = Version(minimum)
119
+ if detected < required:
120
+ return VersionCheckResult(
121
+ ok=False,
122
+ version=version_str,
123
+ minimum=minimum,
124
+ reason=(
125
+ f"Claude Code {version_str} is below the minimum required "
126
+ f"version {minimum}. Run 'claude update' to upgrade."
127
+ ),
128
+ )
129
+
130
+ return VersionCheckResult(
131
+ ok=True,
132
+ version=version_str,
133
+ minimum=minimum,
134
+ reason="OK",
135
+ )
136
+
137
+
138
+ def reset_version_cache() -> None:
139
+ """Clear the cached version (for testing)."""
140
+ global _cached_version # noqa: PLW0603
141
+ _cached_version = None
File without changes
@@ -0,0 +1,181 @@
1
+ """
2
+ Abstract base class for all LLM provider clients.
3
+
4
+ This module defines the interface that all provider clients must implement,
5
+ ensuring consistent behavior across different LLM providers (OpenAI, Gemini, etc.).
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Any, AsyncGenerator, Dict, List, Optional
10
+
11
+ # Import canonical error types from core.llm.errors
12
+ from forge.core.llm.errors import LLMError
13
+
14
+
15
+ class AbstractLLMClient(ABC):
16
+ """
17
+ Base class for all LLM provider clients.
18
+
19
+ All provider-specific clients must inherit from this class and implement
20
+ the required methods. This ensures a consistent interface across providers.
21
+
22
+ Methods accept and return data in OpenAI format for consistency, as it
23
+ serves as both a universal intermediate format and the native format for
24
+ OpenAI providers.
25
+ """
26
+
27
+ @abstractmethod
28
+ async def create_completion(self, openai_request_dict: Dict[str, Any], request_id: str) -> Dict[str, Any]:
29
+ """
30
+ Create a non-streaming completion.
31
+
32
+ Args:
33
+ openai_request_dict: Request in OpenAI format containing messages,
34
+ tools, model parameters, etc.
35
+ request_id: Unique identifier for request tracking and logging
36
+
37
+ Returns:
38
+ Response in OpenAI format
39
+
40
+ Raises:
41
+ AuthenticationError: When authentication fails
42
+ Exception: For other provider-specific errors
43
+ """
44
+ pass
45
+
46
+ @abstractmethod
47
+ async def create_streaming_completion(
48
+ self, openai_request_dict: Dict[str, Any], request_id: str
49
+ ) -> AsyncGenerator[str, None]:
50
+ """
51
+ Create a streaming completion.
52
+
53
+ Args:
54
+ openai_request_dict: Request in OpenAI format containing messages,
55
+ tools, model parameters, etc.
56
+ request_id: Unique identifier for request tracking and logging
57
+
58
+ Yields:
59
+ Server-sent events (SSE) in OpenAI streaming format
60
+
61
+ Raises:
62
+ AuthenticationError: When authentication fails
63
+ Exception: For other provider-specific errors
64
+ """
65
+ pass
66
+
67
+ @abstractmethod
68
+ async def count_tokens(
69
+ self,
70
+ messages: List[Dict[str, Any]],
71
+ tools: Optional[List[Dict[str, Any]]] = None,
72
+ ) -> int:
73
+ """
74
+ Count tokens for the given messages and optional tools.
75
+
76
+ Args:
77
+ messages: List of messages in OpenAI format
78
+ tools: Optional list of tools in OpenAI format
79
+
80
+ Returns:
81
+ Total token count
82
+
83
+ Raises:
84
+ Exception: If token counting fails
85
+ """
86
+ pass
87
+
88
+
89
+ class ToolCallError(LLMError):
90
+ """
91
+ Raised when a tool call fails, formatted for LLM consumption.
92
+
93
+ This error provides structured information that helps LLMs understand
94
+ and potentially fix tool-related issues.
95
+ """
96
+
97
+ def __init__(self, error_type: str, tool_name: str, details: Dict[str, Any]):
98
+ """
99
+ Initialize a tool call error with LLM-friendly formatting.
100
+
101
+ Args:
102
+ error_type: Category of error (MISSING_PARAM, SCHEMA_MISMATCH, etc.)
103
+ tool_name: Name of the tool that failed
104
+ details: Additional context including expected/actual values and suggestions
105
+ """
106
+ self.error_type = error_type
107
+ self.tool_name = tool_name
108
+ self.details = details
109
+
110
+ # Build LLM-friendly message
111
+ message_parts = [f"Tool call failed [{error_type}]: {tool_name}"]
112
+
113
+ if error_type == "MISSING_PARAM":
114
+ if "param" in details:
115
+ message_parts.append(f"Missing required parameter: '{details['param']}'")
116
+ if "expected" in details:
117
+ message_parts.append(f"Expected structure: {details['expected']}")
118
+ if "actual" in details:
119
+ message_parts.append(f"Received: {details['actual']}")
120
+
121
+ elif error_type == "SCHEMA_MISMATCH":
122
+ if "message" in details:
123
+ message_parts.append(details["message"])
124
+ if "expected_type" in details:
125
+ message_parts.append(f"Expected type: {details['expected_type']}")
126
+ if "actual_type" in details:
127
+ message_parts.append(f"Actual type: {details['actual_type']}")
128
+
129
+ elif error_type == "INVALID_FORMAT":
130
+ if "message" in details:
131
+ message_parts.append(details["message"])
132
+ if "field" in details:
133
+ message_parts.append(f"Invalid field: '{details['field']}'")
134
+
135
+ # Add suggestion if available
136
+ if "suggestion" in details:
137
+ message_parts.append(f"Suggestion: {details['suggestion']}")
138
+
139
+ super().__init__("\n".join(message_parts))
140
+
141
+
142
+ class ProxyStreamError(LLMError):
143
+ """Raised during streaming when an error occurs.
144
+
145
+ This error carries structured information that allows the proxy server
146
+ to return appropriate HTTP status codes and OpenAI-compatible error responses
147
+ instead of generic 500 errors.
148
+
149
+ Common error types and their HTTP mappings:
150
+ - "authentication_error" -> 401
151
+ - "rate_limit_error" -> 429
152
+ - "invalid_request_error" -> 400
153
+ - "api_error" -> 500
154
+ """
155
+
156
+ # Standard error type to HTTP status code mapping
157
+ ERROR_STATUS_MAP = {
158
+ "authentication_error": 401,
159
+ "rate_limit_error": 429,
160
+ "invalid_request_error": 400,
161
+ "permission_error": 403,
162
+ "not_found_error": 404,
163
+ "api_error": 500,
164
+ }
165
+
166
+ def __init__(
167
+ self,
168
+ message: str,
169
+ error_type: str = "api_error",
170
+ status_code: int | None = None,
171
+ ) -> None:
172
+ """Initialize a proxy stream error.
173
+
174
+ Args:
175
+ message: Human-readable error message.
176
+ error_type: OpenAI-compatible error type for client handling.
177
+ status_code: HTTP status code override. If None, derived from error_type.
178
+ """
179
+ self.error_type = error_type
180
+ self.status_code = status_code or self.ERROR_STATUS_MAP.get(error_type, 500)
181
+ super().__init__(message)