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,236 @@
1
+ """Claude binary invocation utilities.
2
+
3
+ This module provides utilities for invoking the Claude Code CLI binary
4
+ with proper argument handling and environment setup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+
13
+ def invoke_claude(
14
+ *,
15
+ session_id: str | None = None,
16
+ resume_id: str | None = None,
17
+ fork_session: bool = False,
18
+ name: str | None = None,
19
+ model: str | None = None,
20
+ system_prompt_file: str | None = None,
21
+ env_vars: dict[str, str] | None = None,
22
+ unset_env_vars: list[str] | None = None,
23
+ cwd: str | None = None,
24
+ extra_args: list[str] | None = None,
25
+ ) -> int:
26
+ """Invoke the Claude Code CLI binary.
27
+
28
+ Builds the command line arguments, sets up environment variables,
29
+ and runs Claude as a subprocess.
30
+
31
+ Args:
32
+ session_id: UUID for new session (--session-id flag).
33
+ resume_id: UUID to resume (--resume flag).
34
+ fork_session: Whether to fork (--fork-session flag).
35
+ name: Display name for Claude's session (--name flag).
36
+ model: Model tier to use (--model flag).
37
+ system_prompt_file: Path to system prompt file (--append-system-prompt-file flag).
38
+ env_vars: Additional environment variables to set.
39
+ unset_env_vars: Environment variables to remove from the child process.
40
+ cwd: Working directory for Claude process.
41
+ extra_args: Additional CLI arguments to pass through to Claude.
42
+
43
+ Returns:
44
+ Claude's exit code.
45
+
46
+ Raises:
47
+ FileNotFoundError: If claude binary is not found.
48
+
49
+ Example:
50
+ >>> # Start new session
51
+ >>> exit_code = invoke_claude(
52
+ ... session_id="abc-123",
53
+ ... model="opus",
54
+ ... env_vars={"FORGE_SESSION": "my-session"},
55
+ ... )
56
+
57
+ >>> # Resume session
58
+ >>> exit_code = invoke_claude(
59
+ ... resume_id="abc-123",
60
+ ... env_vars={"FORGE_SESSION": "my-session"},
61
+ ... )
62
+
63
+ >>> # Fork session
64
+ >>> exit_code = invoke_claude(
65
+ ... resume_id="parent-uuid",
66
+ ... fork_session=True,
67
+ ... env_vars={
68
+ ... "FORGE_SESSION": "fork-name",
69
+ ... "FORGE_FORK_NAME": "fork-name",
70
+ ... "FORGE_PARENT_SESSION": "parent-session",
71
+ ... },
72
+ ... )
73
+ """
74
+ cmd = _build_command(
75
+ session_id=session_id,
76
+ resume_id=resume_id,
77
+ fork_session=fork_session,
78
+ name=name,
79
+ model=model,
80
+ system_prompt_file=system_prompt_file,
81
+ extra_args=extra_args,
82
+ )
83
+
84
+ env = _build_environment(env_vars, unset_env_vars)
85
+
86
+ return _run_claude(cmd, env=env, cwd=cwd)
87
+
88
+
89
+ def build_claude_args(
90
+ *,
91
+ session_id: str | None = None,
92
+ resume_id: str | None = None,
93
+ fork_session: bool = False,
94
+ name: str | None = None,
95
+ model: str | None = None,
96
+ system_prompt_file: str | None = None,
97
+ extra_args: list[str] | None = None,
98
+ ) -> list[str]:
99
+ """Build Claude CLI arguments without the executable prefix."""
100
+ args: list[str] = []
101
+
102
+ if session_id:
103
+ args.extend(["--session-id", session_id])
104
+ elif resume_id:
105
+ args.extend(["--resume", resume_id])
106
+ if fork_session:
107
+ args.append("--fork-session")
108
+
109
+ # --name works with both --session-id and --resume
110
+ if name:
111
+ args.extend(["--name", name])
112
+
113
+ if model:
114
+ args.extend(["--model", model])
115
+
116
+ if system_prompt_file:
117
+ args.extend(["--append-system-prompt-file", system_prompt_file])
118
+
119
+ if extra_args: # e.g. -- --debug
120
+ args.extend(extra_args)
121
+ return args
122
+
123
+
124
+ def _build_command(
125
+ *,
126
+ session_id: str | None = None,
127
+ resume_id: str | None = None,
128
+ fork_session: bool = False,
129
+ name: str | None = None,
130
+ model: str | None = None,
131
+ system_prompt_file: str | None = None,
132
+ extra_args: list[str] | None = None,
133
+ ) -> list[str]:
134
+ """Build the command line arguments for Claude.
135
+
136
+ Args:
137
+ session_id: UUID for new session.
138
+ resume_id: UUID to resume.
139
+ fork_session: Whether to fork.
140
+ model: Model tier.
141
+ system_prompt_file: Path to system prompt file.
142
+ extra_args: Additional CLI arguments appended after all other flags.
143
+
144
+ Returns:
145
+ List of command line arguments.
146
+ """
147
+ # No --bare: interactive sessions need hooks, LSP, and plugin sync
148
+ return [
149
+ "claude",
150
+ *build_claude_args(
151
+ session_id=session_id,
152
+ resume_id=resume_id,
153
+ fork_session=fork_session,
154
+ name=name,
155
+ model=model,
156
+ system_prompt_file=system_prompt_file,
157
+ extra_args=extra_args,
158
+ ),
159
+ ]
160
+
161
+
162
+ def _build_environment(
163
+ extra_vars: dict[str, str] | None = None,
164
+ unset_vars: list[str] | None = None,
165
+ ) -> dict[str, str]:
166
+ """Build the environment for Claude process.
167
+
168
+ Delegates to the shared ``build_claude_env`` utility.
169
+
170
+ Args:
171
+ extra_vars: Additional environment variables to set.
172
+ unset_vars: Environment variables to remove from the child process.
173
+
174
+ Returns:
175
+ Complete environment dictionary.
176
+ """
177
+ from forge.core.reactive.env import build_claude_env
178
+
179
+ env = build_claude_env(extra_vars=extra_vars)
180
+ for key in unset_vars or ():
181
+ env.pop(key, None)
182
+ return env
183
+
184
+
185
+ def _run_claude(
186
+ cmd: list[str],
187
+ env: dict[str, str] | None = None,
188
+ cwd: str | None = None,
189
+ ) -> int:
190
+ """Run the Claude binary as a subprocess.
191
+
192
+ Args:
193
+ cmd: Command line arguments.
194
+ env: Environment variables.
195
+ cwd: Working directory.
196
+
197
+ Returns:
198
+ Claude's exit code.
199
+
200
+ Raises:
201
+ FileNotFoundError: If claude binary is not found.
202
+ """
203
+ if cwd:
204
+ cwd = str(Path(cwd).resolve())
205
+
206
+ result = subprocess.run(
207
+ cmd,
208
+ env=env,
209
+ cwd=cwd,
210
+ # Let Claude take over the terminal
211
+ stdin=None,
212
+ stdout=None,
213
+ stderr=None,
214
+ )
215
+
216
+ return result.returncode
217
+
218
+
219
+ def find_claude_binary() -> str | None:
220
+ """Find the claude binary in PATH.
221
+
222
+ Returns:
223
+ Path to claude binary, or None if not found.
224
+ """
225
+ import shutil
226
+
227
+ return shutil.which("claude")
228
+
229
+
230
+ def is_claude_available() -> bool:
231
+ """Check if claude binary is available.
232
+
233
+ Returns:
234
+ True if claude is in PATH, False otherwise.
235
+ """
236
+ return find_claude_binary() is not None
@@ -0,0 +1,200 @@
1
+ """Path utilities for Claude Code integration.
2
+
3
+ Claude stores session data at: ~/.claude/projects/<encoded-path>/
4
+ - <session_id>.jsonl - Transcript
5
+ - agent-<uuid>.jsonl - Agent logs
6
+
7
+ This module provides utilities for:
8
+ - Encoding project paths for Claude's directory structure
9
+ - Resolving transcript and agent log paths
10
+ - Finding project roots (handles git worktrees)
11
+ - Computing Claude's effective project root for a session
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from pathlib import Path
18
+
19
+ from forge.session.models import SessionState
20
+
21
+
22
+ def get_claude_home() -> Path:
23
+ """Get the Claude home directory (~/.claude).
24
+
25
+ Respects CLAUDE_HOME environment variable if set (for testing isolation).
26
+ Note: We expand a leading "~" so values like "~/.claude" work correctly,
27
+ even though that's the default.
28
+
29
+ Returns:
30
+ Path to ~/.claude directory (or CLAUDE_HOME override).
31
+ """
32
+ claude_home = os.environ.get("CLAUDE_HOME")
33
+ if claude_home:
34
+ return Path(claude_home).expanduser()
35
+ return Path.home() / ".claude"
36
+
37
+
38
+ def get_claude_projects_dir() -> Path:
39
+ """Get the Claude projects directory (~/.claude/projects).
40
+
41
+ Returns:
42
+ Path to ~/.claude/projects directory.
43
+ """
44
+ return get_claude_home() / "projects"
45
+
46
+
47
+ def encode_project_path(project_root: str) -> str:
48
+ """Encode project path for Claude's directory structure.
49
+
50
+ Claude stores session data in directories named after the project path,
51
+ with path separators and dots replaced by hyphens.
52
+
53
+ Args:
54
+ project_root: Absolute path to project root.
55
+
56
+ Returns:
57
+ Encoded path string (e.g., '/home/user/project' -> '-home-user-project').
58
+
59
+ Example:
60
+ >>> encode_project_path("/home/user/my.project")
61
+ '-home-user-my-project'
62
+ """
63
+ normalized = str(Path(project_root).resolve())
64
+ encoded = normalized.replace("/", "-").replace(".", "-")
65
+
66
+ return encoded
67
+
68
+
69
+ def get_transcript_path(project_root: str, session_id: str) -> Path:
70
+ """Get the path to a session transcript file.
71
+
72
+ Args:
73
+ project_root: Absolute path to project root.
74
+ session_id: Claude session UUID.
75
+
76
+ Returns:
77
+ Path to the transcript file (may not exist).
78
+
79
+ Example:
80
+ >>> get_transcript_path("/home/user/project", "abc-123")
81
+ PosixPath('/home/user/.claude/projects/-home-user-project/abc-123.jsonl')
82
+ """
83
+ encoded_path = encode_project_path(project_root)
84
+ return get_claude_projects_dir() / encoded_path / f"{session_id}.jsonl"
85
+
86
+
87
+ def find_agent_logs(project_root: str, session_id: str) -> list[Path]:
88
+ """Find agent log files containing a specific session ID.
89
+
90
+ Agent logs don't use session UUID in filename, only in content.
91
+ This function searches log file contents to find matching logs.
92
+
93
+ Args:
94
+ project_root: Absolute path to project root.
95
+ session_id: Claude session UUID to search for.
96
+
97
+ Returns:
98
+ List of paths to agent log files containing the session ID.
99
+ Returns empty list if directory doesn't exist or no matches found.
100
+ """
101
+ encoded_path = encode_project_path(project_root)
102
+ project_dir = get_claude_projects_dir() / encoded_path
103
+
104
+ if not project_dir.exists():
105
+ return []
106
+
107
+ matching_logs: list[Path] = []
108
+
109
+ for log_file in project_dir.glob("agent-*.jsonl"):
110
+ try:
111
+ content = log_file.read_text(encoding="utf-8")
112
+ if session_id in content:
113
+ matching_logs.append(log_file)
114
+ except (OSError, UnicodeDecodeError):
115
+ continue
116
+
117
+ return matching_logs
118
+
119
+
120
+ def resolve_claude_project_root(state: SessionState) -> str:
121
+ """Claude Code project root for a session.
122
+
123
+ Claude Code scopes .claude/ settings, conversations, and transcripts
124
+ to its launch CWD. This computes the correct CWD for any session
125
+ topology so that hooks, transcripts, and --resume all resolve correctly.
126
+
127
+ Rules:
128
+ - Non-worktree sessions: use forge_root (always correct).
129
+ - Nested projects (forge_root inside checkout): use forge_root so
130
+ Claude finds .claude/ at the nested path.
131
+ - Root-level worktrees (forge_root anchored at parent repo): use
132
+ worktree.path because extensions are installed at the checkout root.
133
+ """
134
+ if not state.worktree:
135
+ return state.forge_root or str(Path.cwd())
136
+
137
+ worktree_root = Path(state.worktree.path)
138
+ if state.forge_root:
139
+ try:
140
+ Path(state.forge_root).relative_to(worktree_root)
141
+ return state.forge_root # Nested: forge_root is inside checkout
142
+ except ValueError:
143
+ pass
144
+ return str(worktree_root) # Root-level: forge_root is at parent repo
145
+
146
+
147
+ def find_project_root(start_path: str | None = None) -> Path:
148
+ """Find the git repository root by walking up the directory tree.
149
+
150
+ Handles both regular git repositories (where .git is a directory)
151
+ and git worktrees (where .git is a file pointing to the main repo).
152
+
153
+ Args:
154
+ start_path: Starting directory to search from. Defaults to cwd.
155
+
156
+ Returns:
157
+ Path to the git repository root.
158
+
159
+ Raises:
160
+ FileNotFoundError: If no git repository found.
161
+
162
+ Example:
163
+ >>> find_project_root("/home/user/project/src/module")
164
+ PosixPath('/home/user/project')
165
+ """
166
+ if start_path is None:
167
+ current = Path.cwd().resolve()
168
+ else:
169
+ current = Path(start_path).resolve()
170
+
171
+ while current != current.parent:
172
+ git_path = current / ".git"
173
+
174
+ # In worktrees, .git is a FILE; in main checkout, it's a DIRECTORY
175
+ if git_path.exists():
176
+ return current
177
+
178
+ current = current.parent
179
+
180
+ if (current / ".git").exists():
181
+ return current
182
+
183
+ raise FileNotFoundError(f"No git repository found at or above '{start_path or os.getcwd()}'")
184
+
185
+
186
+ def get_project_encoded_dir(project_root: str) -> Path:
187
+ """Get the Claude projects subdirectory for a project.
188
+
189
+ Args:
190
+ project_root: Absolute path to project root.
191
+
192
+ Returns:
193
+ Path to the project's Claude data directory.
194
+
195
+ Example:
196
+ >>> get_project_encoded_dir("/home/user/project")
197
+ PosixPath('/home/user/.claude/projects/-home-user-project')
198
+ """
199
+ encoded_path = encode_project_path(project_root)
200
+ return get_claude_projects_dir() / encoded_path
@@ -0,0 +1,216 @@
1
+ """Session age-based cleanup.
2
+
3
+ Mirrors the log management cleanup pattern (forge.cli.logs):
4
+ - auto_clean_old_sessions(): called on CLI startup (best-effort)
5
+ - clean_old_sessions(): core logic for both auto and manual cleanup
6
+
7
+ Uses SessionManager.delete_session() for all actual deletion — it already
8
+ handles manifests, index, transcripts, worktrees, co-resident sessions,
9
+ and active-registry cleanup.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime
17
+
18
+ from forge.core.state import parse_iso
19
+ from forge.runtime_config import get_runtime_config
20
+ from forge.session import SessionManager
21
+ from forge.session.active import ActiveSessionStore
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class SessionCleanupResult:
28
+ """Result of a session cleanup operation.
29
+
30
+ All skip categories are surfaced so --dry-run and CLI output can
31
+ report every case. No silent drops.
32
+ """
33
+
34
+ deleted: list[str] = field(default_factory=list)
35
+ skipped_active: list[str] = field(default_factory=list)
36
+ skipped_unparseable: list[str] = field(default_factory=list)
37
+ failed: list[tuple[str, str]] = field(default_factory=list)
38
+ aborted_error: str | None = None
39
+
40
+ @property
41
+ def aborted(self) -> bool:
42
+ """Return True when cleanup stopped before evaluating sessions."""
43
+ return self.aborted_error is not None
44
+
45
+ @property
46
+ def is_empty(self) -> bool:
47
+ """Return True when cleanup found nothing actionable and did not abort."""
48
+ return (
49
+ not self.deleted
50
+ and not self.skipped_active
51
+ and not self.skipped_unparseable
52
+ and not self.failed
53
+ and self.aborted_error is None
54
+ )
55
+
56
+ @property
57
+ def has_failures(self) -> bool:
58
+ """Return True when cleanup aborted or any deletion failed."""
59
+ return self.aborted_error is not None or bool(self.failed)
60
+
61
+ @property
62
+ def failure_count(self) -> int:
63
+ """Return the number of surfaced cleanup failures."""
64
+ return len(self.failed) + (1 if self.aborted_error is not None else 0)
65
+
66
+ def failure_items(self) -> list[tuple[str, str]]:
67
+ """Return cleanup failures as display-ready (name, error) pairs."""
68
+ items = list(self.failed)
69
+ if self.aborted_error is not None:
70
+ items.insert(0, ("active session registry", self.aborted_error))
71
+ return items
72
+
73
+ @property
74
+ def should_exit_nonzero(self) -> bool:
75
+ """Return True when CLI cleanup should exit with an error."""
76
+ return self.has_failures
77
+
78
+ @property
79
+ def has_partial_success(self) -> bool:
80
+ """Return True when cleanup deleted sessions before later failures."""
81
+ return bool(self.deleted)
82
+
83
+ @property
84
+ def has_only_skips(self) -> bool:
85
+ """Return True when cleanup evaluated sessions but only skipped them."""
86
+ return (
87
+ not self.deleted
88
+ and not self.failed
89
+ and self.aborted_error is None
90
+ and bool(self.skipped_active or self.skipped_unparseable)
91
+ )
92
+
93
+ @property
94
+ def has_results(self) -> bool:
95
+ """Return True when cleanup produced any visible outcome."""
96
+ return not self.is_empty
97
+
98
+ @property
99
+ def summary_failed_count(self) -> int:
100
+ """Return the number of failures for user-facing summaries."""
101
+ return self.failure_count
102
+
103
+ @property
104
+ def summary_failed_label(self) -> str:
105
+ """Return singular/plural label for failure summaries."""
106
+ return "failure" if self.failure_count == 1 else "failures"
107
+
108
+
109
+ def clean_old_sessions(
110
+ older_than_days: int,
111
+ *,
112
+ delete_transcripts: bool = True,
113
+ delete_worktree: bool = False,
114
+ delete_branch: bool = False,
115
+ force: bool = False,
116
+ ) -> SessionCleanupResult:
117
+ """Delete sessions whose last_accessed_at is older than the threshold.
118
+
119
+ Active sessions (per ActiveSessionStore) are always skipped. Sessions
120
+ with unparseable timestamps are skipped and reported.
121
+
122
+ Args:
123
+ older_than_days: Age threshold in days.
124
+ delete_transcripts: Delete Claude transcript files (~/.claude/projects/*.jsonl).
125
+ Forge artifact snapshots (.forge/artifacts/) are never removed.
126
+ delete_worktree: Delete git worktree directories (default False for safety).
127
+ delete_branch: Delete git branches (requires delete_worktree=True).
128
+ force: Bypass dirty-worktree protection (only relevant when delete_worktree=True).
129
+ """
130
+ result = SessionCleanupResult()
131
+ manager = SessionManager()
132
+
133
+ all_sessions = manager.list_sessions(include_incognito=True)
134
+
135
+ # One-pass active session lookup (single lock/read/probe cycle).
136
+ # Fail-closed: if we can't determine liveness, abort cleanup entirely.
137
+ # Sessions are high-value objects — deleting one whose Claude process is
138
+ # still running would destroy state.
139
+ active_store = ActiveSessionStore()
140
+ try:
141
+ active_entries = active_store.list_sessions()
142
+ # Use (name, forge_root) tuples to avoid cross-project false positives
143
+ active_identities = {(name, ae.forge_root or ae.worktree_path) for name, ae in active_entries}
144
+ except Exception as e:
145
+ logger.debug("Cannot read active session registry, aborting cleanup: %s", e)
146
+ result.aborted_error = str(e)
147
+ return result
148
+
149
+ for name, entry in all_sessions:
150
+ # Check age
151
+ try:
152
+ dt = parse_iso(entry.last_accessed_at)
153
+ age_days = (datetime.now(UTC) - dt).total_seconds() / 86400
154
+ except (ValueError, TypeError, AttributeError):
155
+ result.skipped_unparseable.append(name)
156
+ continue
157
+
158
+ if age_days <= older_than_days:
159
+ continue
160
+
161
+ # Check active status (scoped by forge_root to avoid cross-project false positives)
162
+ entry_identity = (name, entry.forge_root or entry.worktree_path)
163
+ if entry_identity in active_identities:
164
+ result.skipped_active.append(name)
165
+ continue
166
+
167
+ # Delete (scoped by forge_root to avoid cross-project collisions)
168
+ try:
169
+ manager.delete_session(
170
+ name,
171
+ delete_transcripts=delete_transcripts,
172
+ delete_worktree=delete_worktree,
173
+ delete_branch=delete_branch,
174
+ force=force,
175
+ forge_root=entry.forge_root or entry.worktree_path,
176
+ )
177
+ result.deleted.append(name)
178
+ except Exception as e:
179
+ result.failed.append((name, str(e)))
180
+ logger.debug("Failed to clean session '%s': %s", name, e, exc_info=True)
181
+
182
+ return result
183
+
184
+
185
+ def auto_clean_old_sessions() -> None:
186
+ """Auto-prune old sessions based on session_retention_days config.
187
+
188
+ Called opportunistically on CLI startup. Best-effort: swallows all
189
+ exceptions to avoid breaking CLI commands.
190
+
191
+ Auto-cleanup uses safe defaults:
192
+ - delete_transcripts=True (transcripts are useless without sessions)
193
+ - delete_worktree=False (too destructive for automatic operation)
194
+ - delete_branch=False (branches are lightweight, keep them)
195
+ - force=True (safe because delete_worktree=False means dirty-check is never reached)
196
+ """
197
+ try:
198
+ rc = get_runtime_config()
199
+ if rc.session_retention_days <= 0:
200
+ return
201
+
202
+ cleanup_result = clean_old_sessions(
203
+ older_than_days=rc.session_retention_days,
204
+ delete_transcripts=True,
205
+ delete_worktree=False,
206
+ delete_branch=False,
207
+ force=True,
208
+ )
209
+ if cleanup_result.deleted:
210
+ logger.debug(
211
+ "Auto-cleaned %d session(s) older than %d days",
212
+ len(cleanup_result.deleted),
213
+ rc.session_retention_days,
214
+ )
215
+ except Exception as e:
216
+ logger.debug("Session auto-cleanup error (non-fatal): %s", e)
@@ -0,0 +1,34 @@
1
+ """Default configuration values for forge session.
2
+
3
+ These defaults can be overridden via environment variables:
4
+ - FORGE_DEFAULT_PROXY_TEMPLATE
5
+ - FORGE_DEFAULT_PROXY_BASE_URL
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ # Proxy defaults (configurable via env vars)
14
+ DEFAULT_PROXY_TEMPLATE = os.environ.get("FORGE_DEFAULT_PROXY_TEMPLATE", "litellm-openai")
15
+ DEFAULT_PROXY_BASE_URL = os.environ.get("FORGE_DEFAULT_PROXY_BASE_URL", "http://localhost:8085")
16
+
17
+ # Launch mode constants
18
+ LAUNCH_MODE_HOST = "host"
19
+ LAUNCH_MODE_SIDECAR = "sidecar"
20
+
21
+ # Sidecar sessions always talk to the proxy on the container-local loopback.
22
+ SIDECAR_RUNTIME_BASE_URL = "http://localhost:8085"
23
+
24
+
25
+ def _discover_templates() -> tuple[str, ...]:
26
+ """Derive valid proxy templates from the templates directory."""
27
+ templates_dir = Path(__file__).parent.parent / "config" / "defaults" / "templates"
28
+ if not templates_dir.is_dir():
29
+ return ()
30
+ return tuple(sorted(p.stem for p in templates_dir.glob("*.yaml")))
31
+
32
+
33
+ # Valid proxy templates (derived from templates directory)
34
+ VALID_PROXY_TEMPLATES: tuple[str, ...] = _discover_templates()