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,332 @@
1
+ """Git worktree creation utilities.
2
+
3
+ This module provides functions for creating git worktrees for session isolation.
4
+ Each session can have its own worktree, enabling parallel work without conflicts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from ..exceptions import (
15
+ BranchExistsError,
16
+ GitNotFoundError,
17
+ GitWorktreeError,
18
+ InvalidBranchNameError,
19
+ WorktreePathExistsError,
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class WorktreeResult:
25
+ """Result of worktree creation."""
26
+
27
+ worktree_path: str
28
+ branch: str
29
+ created_branch: bool # True if a new branch was created
30
+
31
+
32
+ def find_git_binary() -> str:
33
+ """Find git binary in PATH.
34
+
35
+ Returns:
36
+ Path to git binary.
37
+
38
+ Raises:
39
+ GitNotFoundError: If git is not found.
40
+ """
41
+ git_path = shutil.which("git")
42
+ if git_path is None:
43
+ raise GitNotFoundError()
44
+ return git_path
45
+
46
+
47
+ def get_repo_root(cwd: Path | None = None) -> Path:
48
+ """Get the root of the git repository or worktree.
49
+
50
+ For worktrees, this returns the worktree root, not the main repository.
51
+ Use get_main_repo_root() if you need the main repository.
52
+
53
+ Args:
54
+ cwd: Starting directory (defaults to current).
55
+
56
+ Returns:
57
+ Path to repository/worktree root.
58
+
59
+ Raises:
60
+ GitWorktreeError: If not in a git repository.
61
+ """
62
+ git = find_git_binary()
63
+ start = cwd or Path.cwd()
64
+
65
+ result = subprocess.run(
66
+ [git, "rev-parse", "--show-toplevel"],
67
+ cwd=str(start),
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+
72
+ if result.returncode != 0:
73
+ raise GitWorktreeError("rev-parse", "not in a git repository", result.returncode)
74
+
75
+ return Path(result.stdout.strip())
76
+
77
+
78
+ def get_main_repo_root(cwd: Path | None = None) -> Path:
79
+ """Get the root of the main git repository.
80
+
81
+ For worktrees, this returns the main repository root, not the worktree.
82
+ Uses git-common-dir to find the shared .git directory.
83
+
84
+ Args:
85
+ cwd: Starting directory (defaults to current).
86
+
87
+ Returns:
88
+ Path to main repository root.
89
+
90
+ Raises:
91
+ GitWorktreeError: If not in a git repository.
92
+ """
93
+ git = find_git_binary()
94
+ start = cwd or Path.cwd()
95
+
96
+ # Get the common git directory (shared by all worktrees)
97
+ result = subprocess.run(
98
+ [git, "rev-parse", "--path-format=absolute", "--git-common-dir"],
99
+ cwd=str(start),
100
+ capture_output=True,
101
+ text=True,
102
+ )
103
+
104
+ if result.returncode != 0:
105
+ raise GitWorktreeError("rev-parse", "not in a git repository", result.returncode)
106
+
107
+ common_dir = Path(result.stdout.strip())
108
+ if common_dir.name == ".git":
109
+ return common_dir.parent
110
+
111
+ # Handle edge case where common_dir might be .git/worktrees/name
112
+ while common_dir.name != ".git" and common_dir.parent != common_dir:
113
+ common_dir = common_dir.parent
114
+
115
+ if common_dir.name == ".git":
116
+ return common_dir.parent
117
+
118
+ return get_repo_root(cwd)
119
+
120
+
121
+ def branch_exists(branch: str, cwd: Path | None = None) -> bool:
122
+ """Check if a git branch exists.
123
+
124
+ Uses refs/heads/ to specifically check for local branches,
125
+ avoiding false positives from tags or other refs.
126
+
127
+ Args:
128
+ branch: Branch name to check.
129
+ cwd: Working directory.
130
+
131
+ Returns:
132
+ True if branch exists as a local branch.
133
+ """
134
+ git = find_git_binary()
135
+
136
+ result = subprocess.run(
137
+ [git, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
138
+ cwd=str(cwd or Path.cwd()),
139
+ capture_output=True,
140
+ text=True,
141
+ )
142
+
143
+ return result.returncode == 0
144
+
145
+
146
+ def get_worktree_for_branch(branch: str, cwd: Path | None = None) -> str | None:
147
+ """Find the worktree path that has a branch checked out.
148
+
149
+ Args:
150
+ branch: Branch name to look up.
151
+ cwd: Working directory.
152
+
153
+ Returns:
154
+ Worktree path if the branch is checked out, None otherwise.
155
+ """
156
+ git = find_git_binary()
157
+
158
+ result = subprocess.run(
159
+ [git, "worktree", "list", "--porcelain"],
160
+ cwd=str(cwd or Path.cwd()),
161
+ capture_output=True,
162
+ text=True,
163
+ )
164
+
165
+ if result.returncode != 0:
166
+ return None
167
+
168
+ # Porcelain format: blocks separated by blank lines, each has
169
+ # "worktree <path>" and "branch refs/heads/<name>"
170
+ current_path: str | None = None
171
+ for line in result.stdout.splitlines():
172
+ if line.startswith("worktree "):
173
+ current_path = line[len("worktree ") :]
174
+ elif line == f"branch refs/heads/{branch}":
175
+ return current_path
176
+
177
+ return None
178
+
179
+
180
+ def validate_branch_name(branch: str) -> None:
181
+ """Validate a git branch name.
182
+
183
+ Uses git check-ref-format to validate the branch name.
184
+ This is called for explicit --branch values.
185
+
186
+ Args:
187
+ branch: Branch name to validate.
188
+
189
+ Raises:
190
+ InvalidBranchNameError: If branch name is invalid.
191
+ """
192
+ git = find_git_binary()
193
+
194
+ result = subprocess.run(
195
+ [git, "check-ref-format", "--branch", branch],
196
+ capture_output=True,
197
+ text=True,
198
+ )
199
+
200
+ if result.returncode != 0:
201
+ reason = result.stderr.strip() if result.stderr else "invalid format"
202
+ raise InvalidBranchNameError(branch, reason)
203
+
204
+
205
+ def sanitize_branch_name(session_name: str) -> str:
206
+ """Convert session name to valid git branch name.
207
+
208
+ Session names are already strict (lowercase alphanumeric + hyphens),
209
+ which are valid git branch names. This is mainly a pass-through.
210
+
211
+ Args:
212
+ session_name: The session name.
213
+
214
+ Returns:
215
+ Valid git branch name.
216
+ """
217
+ # Session names are validated as lowercase alphanumeric + hyphens
218
+ # which are valid git branch names - just pass through
219
+ return session_name
220
+
221
+
222
+ def resolve_worktree_path(repo_root: Path, session_name: str) -> Path:
223
+ """Compute the worktree path for a session.
224
+
225
+ Worktree path: ../<project-name>-<session-name>
226
+ Places worktrees as siblings to the main repo.
227
+
228
+ Args:
229
+ repo_root: Path to the main repository.
230
+ session_name: The session name.
231
+
232
+ Returns:
233
+ Absolute path for the worktree.
234
+ """
235
+ project_name = repo_root.name
236
+ worktree_dir = f"{project_name}-{session_name}"
237
+ return (repo_root.parent / worktree_dir).resolve()
238
+
239
+
240
+ def create_worktree(
241
+ session_name: str,
242
+ branch: str | None = None,
243
+ cwd: Path | None = None,
244
+ *,
245
+ force: bool = False,
246
+ replace_owned_stale_state: bool = False,
247
+ ) -> WorktreeResult:
248
+ """Create a git worktree for a session.
249
+
250
+ Args:
251
+ session_name: Session name (used for path and default branch).
252
+ branch: Override branch name (defaults to session_name).
253
+ cwd: Starting directory (defaults to current).
254
+ force: Replace conflicting branch/worktree state. Deletes a merged
255
+ branch and removes a clean registered worktree before recreating.
256
+ Hard constraints still apply: BranchInUseError (checked out
257
+ elsewhere), BranchNotMergedError (unmerged work), and non-worktree
258
+ paths (no .git file).
259
+ replace_owned_stale_state: Allow force recovery only when the caller
260
+ has already verified the target worktree/branch belong to the same
261
+ stale Forge session being replaced.
262
+
263
+ Returns:
264
+ WorktreeResult with path and branch info.
265
+
266
+ Raises:
267
+ GitNotFoundError: If git is not found.
268
+ GitWorktreeError: If worktree creation fails.
269
+ InvalidBranchNameError: If explicit branch name is invalid.
270
+ BranchExistsError: If branch already exists (or force with explicit --branch).
271
+ WorktreePathExistsError: If worktree path already exists (or not a git worktree).
272
+ BranchInUseError: If branch is checked out in another worktree (force only).
273
+ BranchNotMergedError: If branch has unmerged work (force only).
274
+ """
275
+ git = find_git_binary()
276
+ repo_root = get_repo_root(cwd)
277
+
278
+ if branch is not None:
279
+ validate_branch_name(branch)
280
+ target_branch = branch
281
+ else:
282
+ # Derive from session name (already valid)
283
+ target_branch = sanitize_branch_name(session_name)
284
+
285
+ worktree_path = resolve_worktree_path(repo_root, session_name)
286
+
287
+ # --force only replaces worktree state when the caller has proved the
288
+ # derived target belongs to the same stale Forge child being recovered.
289
+ # Worktree first (un-checks-out the branch), then branch.
290
+ if force and replace_owned_stale_state and worktree_path.exists():
291
+ git_file = worktree_path / ".git"
292
+ if not git_file.is_file():
293
+ # Not a registered git worktree — refuse to delete arbitrary dirs
294
+ raise WorktreePathExistsError(str(worktree_path))
295
+ from .cleanup import remove_worktree
296
+
297
+ main_root = get_main_repo_root(worktree_path)
298
+ remove_worktree(worktree_path, force=True, repo_root=main_root)
299
+ if worktree_path.exists():
300
+ raise WorktreePathExistsError(str(worktree_path))
301
+ elif worktree_path.exists():
302
+ raise WorktreePathExistsError(str(worktree_path))
303
+
304
+ if branch_exists(target_branch, repo_root):
305
+ if not force or not replace_owned_stale_state:
306
+ wt = get_worktree_for_branch(target_branch, repo_root)
307
+ raise BranchExistsError(target_branch, worktree=wt)
308
+ if branch is not None:
309
+ # Explicit --branch: refuse to destroy a user-chosen name
310
+ wt = get_worktree_for_branch(target_branch, repo_root)
311
+ raise BranchExistsError(target_branch, worktree=wt)
312
+ # --force with auto-derived branch: delete (respects git merge safety).
313
+ # BranchInUseError/BranchNotMergedError propagate as hard constraints.
314
+ from .cleanup import delete_branch as _delete_branch
315
+
316
+ _delete_branch(target_branch, cwd=repo_root, force=False)
317
+
318
+ result = subprocess.run(
319
+ [git, "worktree", "add", str(worktree_path), "-b", target_branch],
320
+ cwd=str(repo_root),
321
+ capture_output=True,
322
+ text=True,
323
+ )
324
+
325
+ if result.returncode != 0:
326
+ raise GitWorktreeError("add", result.stderr.strip(), result.returncode)
327
+
328
+ return WorktreeResult(
329
+ worktree_path=str(worktree_path),
330
+ branch=target_branch,
331
+ created_branch=True,
332
+ )
@@ -0,0 +1,29 @@
1
+ """Sidecar execution module for Multi-Forge.
2
+
3
+ Bundles proxy + Claude Code in a Docker container with lifecycle coupling,
4
+ port isolation, and version consistency. Not a security sandbox — Claude
5
+ Code's native sandbox (Seatbelt/bubblewrap) handles that.
6
+ """
7
+
8
+ from forge.sidecar.container import (
9
+ ContainerExistsError,
10
+ container_exists,
11
+ exec_in_container,
12
+ get_container_id,
13
+ parse_mounts,
14
+ run_sidecar_session,
15
+ )
16
+ from forge.sidecar.docker import is_container_running, is_docker_available
17
+ from forge.sidecar.secrets import get_secrets_for_template
18
+
19
+ __all__ = [
20
+ "ContainerExistsError",
21
+ "container_exists",
22
+ "exec_in_container",
23
+ "get_container_id",
24
+ "get_secrets_for_template",
25
+ "is_container_running",
26
+ "is_docker_available",
27
+ "parse_mounts",
28
+ "run_sidecar_session",
29
+ ]
@@ -0,0 +1,161 @@
1
+ """Container lifecycle management for sidecar Claude Code sessions.
2
+
3
+ Bundles proxy + Claude Code in a Docker container. The key function
4
+ `run_sidecar_session()` is the container equivalent of `invoke_claude()`
5
+ — it runs interactively with inherited stdin/stdout/stderr.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from forge.sidecar.docker import _docker_name_filter
17
+
18
+
19
+ class ContainerExistsError(RuntimeError):
20
+ """Raised when a container with the given name already exists."""
21
+
22
+ def __init__(self, container_name: str) -> None:
23
+ self.container_name = container_name
24
+ super().__init__(f"Container '{container_name}' already exists. " f"Remove with: docker rm -f {container_name}")
25
+
26
+
27
+ def get_container_id(container_name: str) -> str | None:
28
+ """Get container ID by name (for running containers only)."""
29
+ result = subprocess.run(
30
+ ["docker", "ps", "-q", "-f", _docker_name_filter(container_name)],
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ return result.stdout.strip() or None
35
+
36
+
37
+ def container_exists(container_name: str) -> bool:
38
+ """Check if a container exists by name (running OR stopped).
39
+
40
+ Uses `docker ps -a` to detect ALL containers, including stopped/exited ones.
41
+ """
42
+ result = subprocess.run(
43
+ ["docker", "ps", "-aq", "-f", _docker_name_filter(container_name)],
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+ return bool(result.stdout.strip())
48
+
49
+
50
+ def run_sidecar_session(
51
+ *,
52
+ image: str,
53
+ template: str,
54
+ session_name: str,
55
+ project_dir: Path,
56
+ extra_mounts: list[tuple[str, str, str]] | None = None,
57
+ context_limit: int = 200000,
58
+ env_vars: dict[str, str] | None = None,
59
+ claude_args: list[str] | None = None,
60
+ ) -> int:
61
+ """Run Claude + proxy in a Docker container. Returns exit code.
62
+
63
+ Container lifecycle = Session lifecycle:
64
+ - Container starts when this function is called
65
+ - Container exits when Claude exits
66
+ - Container auto-cleaned via --rm flag
67
+ """
68
+ container_name = f"forge-{session_name}"
69
+
70
+ # Collision guard: detect both running AND stopped containers
71
+ if container_exists(container_name):
72
+ raise ContainerExistsError(container_name)
73
+
74
+ cmd = [
75
+ "docker",
76
+ "run",
77
+ "-it",
78
+ "--rm",
79
+ "--name",
80
+ container_name,
81
+ "-v",
82
+ f"{project_dir}:/workspace",
83
+ "-e",
84
+ f"FORGE_TEMPLATE={template}",
85
+ "-e",
86
+ f"CLAUDE_CODE_AUTO_COMPACT_WINDOW={context_limit}",
87
+ "-e",
88
+ f"FORGE_SESSION={session_name}",
89
+ "-e",
90
+ "FORGE_SIDECAR=1",
91
+ "-e",
92
+ "FORGE_LAUNCH_MODE=sidecar",
93
+ "-w",
94
+ "/workspace",
95
+ ]
96
+
97
+ if sys.platform == "linux":
98
+ uid, gid = os.getuid(), os.getgid()
99
+ cmd.extend(["--user", f"{uid}:{gid}"])
100
+
101
+ if extra_mounts:
102
+ for host_path, container_path, mode in extra_mounts:
103
+ cmd.extend(["-v", f"{host_path}:{container_path}:{mode}"])
104
+
105
+ # Write env vars to temp file instead of CLI args to avoid
106
+ # leaking secrets via `ps aux` (CR-022). Cleanup in finally.
107
+ env_file_path: str | None = None
108
+ try:
109
+ if env_vars:
110
+ fd, env_file_path = tempfile.mkstemp(prefix=".forge-env-", suffix=".env")
111
+ with os.fdopen(fd, "w") as f:
112
+ for k, v in env_vars.items():
113
+ f.write(f"{k}={v}\n")
114
+ os.chmod(env_file_path, 0o600)
115
+ cmd.extend(["--env-file", env_file_path])
116
+
117
+ cmd.append(image)
118
+ if claude_args:
119
+ cmd.extend(claude_args)
120
+
121
+ result = subprocess.run(cmd)
122
+ return result.returncode
123
+ finally:
124
+ if env_file_path:
125
+ try:
126
+ os.unlink(env_file_path)
127
+ except OSError:
128
+ pass
129
+
130
+
131
+ def exec_in_container(container_name: str, command: list[str]) -> int:
132
+ """Execute interactive command in running container."""
133
+ cmd = ["docker", "exec", "-it", container_name, *command]
134
+ result = subprocess.run(cmd)
135
+ return result.returncode
136
+
137
+
138
+ def parse_mounts(mount_specs: tuple[str, ...]) -> list[tuple[str, str, str]]:
139
+ """Parse --mount flag specifications into (host, container, mode) tuples.
140
+
141
+ Format: "host_path:container_path[:ro|rw]"
142
+ Default mode is "rw" if not specified.
143
+ """
144
+ mounts = []
145
+ for spec in mount_specs:
146
+ parts = spec.split(":")
147
+
148
+ if len(parts) < 2:
149
+ raise ValueError(f"Invalid mount specification: {spec}. Expected 'host:container[:ro|rw]'")
150
+
151
+ host_path = parts[0]
152
+ container_path = parts[1]
153
+ mode = parts[2] if len(parts) > 2 else "rw"
154
+
155
+ if mode not in ("ro", "rw"):
156
+ raise ValueError(f"Invalid mount mode: {mode}. Must be 'ro' or 'rw'")
157
+
158
+ host_path = os.path.expanduser(host_path)
159
+ mounts.append((host_path, container_path, mode))
160
+
161
+ return mounts
@@ -0,0 +1,86 @@
1
+ """Docker utility functions for sidecar execution.
2
+
3
+ Low-level Docker operations used by the container lifecycle module.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import subprocess
10
+
11
+
12
+ def _docker_name_filter(container_name: str) -> str:
13
+ """Build an exact-match docker ps name filter, escaping regex metacharacters."""
14
+ return f"name=^{re.escape(container_name)}$"
15
+
16
+
17
+ def is_container_running(container_name: str) -> bool:
18
+ """Check if a Docker container is running by name.
19
+
20
+ Uses docker ps filtering with exact name match to avoid partial matches.
21
+
22
+ Args:
23
+ container_name: The container name to check.
24
+
25
+ Returns:
26
+ True if container exists and is running, False otherwise.
27
+ """
28
+ result = subprocess.run(
29
+ ["docker", "ps", "-q", "-f", _docker_name_filter(container_name)],
30
+ capture_output=True,
31
+ text=True,
32
+ )
33
+ return bool(result.stdout.strip())
34
+
35
+
36
+ def is_docker_available() -> bool:
37
+ """Check if Docker is available and running.
38
+
39
+ Returns:
40
+ True if docker daemon is accessible, False otherwise.
41
+ """
42
+ try:
43
+ result = subprocess.run(
44
+ ["docker", "info"],
45
+ capture_output=True,
46
+ timeout=10,
47
+ )
48
+ return result.returncode == 0
49
+ except (subprocess.TimeoutExpired, FileNotFoundError):
50
+ return False
51
+
52
+
53
+ def stop_container(container_name: str) -> bool:
54
+ """Stop a running container by name.
55
+
56
+ Args:
57
+ container_name: The container name to stop.
58
+
59
+ Returns:
60
+ True if container was stopped, False if container was not running.
61
+ """
62
+ result = subprocess.run(
63
+ ["docker", "stop", container_name],
64
+ capture_output=True,
65
+ text=True,
66
+ )
67
+ return result.returncode == 0
68
+
69
+
70
+ def remove_container(container_name: str, force: bool = False) -> bool:
71
+ """Remove a container by name.
72
+
73
+ Args:
74
+ container_name: The container name to remove.
75
+ force: If True, force remove even if running.
76
+
77
+ Returns:
78
+ True if container was removed, False otherwise.
79
+ """
80
+ cmd = ["docker", "rm"]
81
+ if force:
82
+ cmd.append("-f")
83
+ cmd.append(container_name)
84
+
85
+ result = subprocess.run(cmd, capture_output=True, text=True)
86
+ return result.returncode == 0
@@ -0,0 +1,19 @@
1
+ """Secrets propagation for sidecar sessions.
2
+
3
+ Forward required secrets (API keys, connection values) from the host
4
+ environment into Docker containers. Secrets are template-dependent.
5
+
6
+ Resolution order: environment variable -> credential file (~/.forge/credentials.yaml).
7
+
8
+ Implementation lives in ``forge.core.auth.template_secrets``; this module
9
+ re-exports the public API for backward compatibility.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from forge.core.auth.template_secrets import (
15
+ TEMPLATE_SECRETS,
16
+ get_secrets_for_template,
17
+ )
18
+
19
+ __all__ = ["TEMPLATE_SECRETS", "get_secrets_for_template"]