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
forge/cli/backend.py ADDED
@@ -0,0 +1,298 @@
1
+ """Backend management CLI commands.
2
+
3
+ Provides commands to manage backend services (LiteLLM, etc.) that proxies depend on:
4
+ - forge backend list: List all backends
5
+ - forge backend create: Create backend config
6
+ - forge backend start: Start a backend instance
7
+ - forge backend stop: Stop a backend instance
8
+ - forge backend delete: Delete backend config or instance
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from forge.backend import BackendManager
21
+ from forge.backend.adapters import get_adapter
22
+ from forge.backend.creation import create_backend_config, get_backend_config_path
23
+ from forge.backend.registry import BackendRegistryStore, is_pid_alive
24
+ from forge.core.paths import display_path, get_forge_home
25
+
26
+
27
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
28
+ def backend() -> None:
29
+ """Manage backends (LiteLLM, etc.).
30
+
31
+ \b
32
+ Examples:
33
+ forge backend list # List backends
34
+ forge backend create litellm # Create backend config
35
+ forge backend start litellm -p 4000 # Start an instance
36
+ """
37
+
38
+
39
+ @backend.command("list")
40
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
41
+ def list_cmd(as_json: bool) -> None:
42
+ """List all backends."""
43
+ console = Console(width=200)
44
+ store = BackendRegistryStore()
45
+
46
+ backends = store.read().backends
47
+
48
+ if as_json:
49
+ import json
50
+
51
+ data = []
52
+ for backend_id, b in backends.items():
53
+ data.append(
54
+ {
55
+ "backend_id": b.backend_id,
56
+ "adapter_type": b.adapter_type,
57
+ "port": b.port,
58
+ "pid": b.pid,
59
+ "status": b.status,
60
+ }
61
+ )
62
+ click.echo(json.dumps(data, indent=2, default=str))
63
+ return
64
+
65
+ if not backends:
66
+ console.print("No backends found.")
67
+ console.print("\n[dim]Tip: Run 'forge backend create litellm'.[/dim]")
68
+ return
69
+
70
+ table = Table(title="Forge Backends")
71
+ table.add_column("BACKEND ID", style="cyan")
72
+ table.add_column("ADAPTER")
73
+ table.add_column("PORT", justify="right")
74
+ table.add_column("PID", justify="right")
75
+ table.add_column("STATUS")
76
+
77
+ for backend_id, backend in backends.items():
78
+ table.add_row(
79
+ backend_id,
80
+ backend.adapter_type,
81
+ str(backend.port),
82
+ str(backend.pid) if backend.pid else "-",
83
+ backend.status,
84
+ )
85
+
86
+ console.print(table)
87
+
88
+
89
+ @backend.command("show")
90
+ @click.argument("backend_id")
91
+ @click.option("--raw", is_flag=True, help="Output raw config without syntax highlighting")
92
+ def show_cmd(backend_id: str, raw: bool) -> None:
93
+ """Show backend details and configuration.
94
+
95
+ \b
96
+ Examples:
97
+ forge backend show litellm-4000
98
+ """
99
+ console = Console(width=200)
100
+ store = BackendRegistryStore()
101
+
102
+ # Parse adapter type from backend_id (e.g., "litellm-4000" -> "litellm")
103
+ parts = backend_id.rsplit("-", 1)
104
+ adapter_type = parts[0] if len(parts) == 2 else backend_id
105
+
106
+ try:
107
+ registry = store.read()
108
+ instance = registry.backends.get(backend_id)
109
+ if instance:
110
+ alive = instance.pid is not None and is_pid_alive(instance.pid)
111
+ status_color = "green" if alive else "yellow"
112
+ console.print(f"[bold]Backend:[/bold] [cyan]{backend_id}[/cyan]")
113
+ console.print(f"[bold]Adapter:[/bold] {instance.adapter_type}")
114
+ console.print(f"[bold]Port:[/bold] {instance.port}")
115
+ console.print(f"[bold]PID:[/bold] {instance.pid or '-'}")
116
+ console.print(
117
+ f"[bold]Status:[/bold] [{status_color}]{'healthy' if alive else 'not running'}[/{status_color}]"
118
+ )
119
+ if instance.created_at:
120
+ console.print(f"[bold]Started:[/bold] {instance.created_at}")
121
+ else:
122
+ console.print(f"[bold]Backend:[/bold] [cyan]{backend_id}[/cyan] [dim](not in registry)[/dim]")
123
+ except Exception:
124
+ console.print(f"[bold]Backend:[/bold] [cyan]{backend_id}[/cyan]")
125
+
126
+ log_file = get_forge_home() / "logs" / "backend" / f"{backend_id}.log"
127
+ if log_file.exists():
128
+ console.print(f"[bold]Log:[/bold] {display_path(log_file)}")
129
+ else:
130
+ log_file = (
131
+ get_forge_home() / "logs" / "backend" / f"{adapter_type}-{parts[1] if len(parts) == 2 else '4000'}.log"
132
+ )
133
+ if log_file.exists():
134
+ console.print(f"[bold]Log:[/bold] {display_path(log_file)}")
135
+
136
+ config_path = get_backend_config_path(adapter_type)
137
+ if config_path.exists():
138
+ content = config_path.read_text()
139
+ console.print(f"[bold]Config:[/bold] {display_path(config_path)}\n")
140
+ if raw:
141
+ console.print(content)
142
+ else:
143
+ from rich.syntax import Syntax
144
+
145
+ syntax = Syntax(content, "yaml", theme="monokai", line_numbers=True)
146
+ console.print(syntax)
147
+ else:
148
+ console.print(f"\n[dim]No config found for adapter '{adapter_type}'.[/dim]")
149
+ console.print(f"[dim]Tip: Run 'forge backend create {adapter_type}'.[/dim]")
150
+
151
+
152
+ @backend.command("create")
153
+ @click.argument("adapter", type=click.Choice(["litellm"]))
154
+ @click.option(
155
+ "--config",
156
+ "-c",
157
+ type=Path,
158
+ help="Source config file (defaults to installed template)",
159
+ )
160
+ def create_cmd(adapter: str, config: Path | None) -> None:
161
+ """Create a backend config (copy to installed location).
162
+
163
+ Config is shared by all instances of this adapter type.
164
+ """
165
+ console = Console(width=200)
166
+
167
+ config_path = get_backend_config_path(adapter)
168
+ if config_path.exists():
169
+ console.print(f"[yellow]Backend config already exists:[/yellow] {display_path(config_path)}")
170
+ console.print("\n[dim]Start an instance with:[/dim]")
171
+ console.print(f" forge backend start {adapter} --port 4000")
172
+ return
173
+
174
+ try:
175
+ config_path = create_backend_config(
176
+ adapter_type=adapter,
177
+ source_config=config,
178
+ )
179
+ console.print(f"[green]Created[/green] backend config for '{adapter}'")
180
+ console.print(f" Config: {display_path(config_path)}")
181
+ console.print("\n[dim]Start an instance with:[/dim]")
182
+ console.print(f" forge backend start {adapter} --port 4000")
183
+ except Exception as e:
184
+ console.print(f"[red]Error:[/red] {e}")
185
+ sys.exit(1)
186
+
187
+
188
+ @backend.command("start")
189
+ @click.argument("adapter", type=click.Choice(["litellm"]))
190
+ @click.option("--port", "-p", type=int, required=True, help="Port number")
191
+ def start_cmd(adapter: str, port: int) -> None:
192
+ """Start a backend instance."""
193
+ console = Console(width=200)
194
+
195
+ config_path = get_backend_config_path(adapter)
196
+ if not config_path.exists():
197
+ console.print(f"[red]Error:[/red] Backend config not found for '{adapter}'")
198
+ console.print("\n[dim]Create it first:[/dim]")
199
+ console.print(f" forge backend create {adapter}")
200
+ sys.exit(1)
201
+
202
+ backend_id = f"{adapter}-{port}"
203
+ store = BackendRegistryStore()
204
+ manager = BackendManager(store)
205
+ manager.register_adapter(adapter, get_adapter(adapter))
206
+
207
+ try:
208
+ result = manager.ensure_backend(backend_id, adapter, port)
209
+ if result.source == "start":
210
+ console.print(f"[green]Started[/green] backend '{backend_id}' on port {port} (pid {result.instance.pid})")
211
+ else:
212
+ console.print(f"Backend '{backend_id}' already running on port {port}")
213
+ except Exception as e:
214
+ console.print(f"[red]Error:[/red] {e}")
215
+ sys.exit(1)
216
+
217
+
218
+ @backend.command("stop")
219
+ @click.argument("adapter", type=click.Choice(["litellm"]))
220
+ @click.option("--port", "-p", type=int, required=True, help="Port number")
221
+ def stop_cmd(adapter: str, port: int) -> None:
222
+ """Stop a backend instance."""
223
+ console = Console(width=200)
224
+ backend_id = f"{adapter}-{port}"
225
+
226
+ store = BackendRegistryStore()
227
+ manager = BackendManager(store)
228
+ manager.register_adapter(adapter, get_adapter(adapter))
229
+
230
+ try:
231
+ manager.stop_backend(backend_id)
232
+ console.print(f"[green]Stopped[/green] backend '{backend_id}'")
233
+ except Exception as e:
234
+ console.print(f"[red]Error:[/red] {e}")
235
+ sys.exit(1)
236
+
237
+
238
+ @backend.command("delete")
239
+ @click.argument("adapter", type=click.Choice(["litellm"]))
240
+ @click.option(
241
+ "--port",
242
+ "-p",
243
+ type=int,
244
+ help="Delete specific instance (if not specified, deletes config)",
245
+ )
246
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
247
+ @click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
248
+ def delete_cmd(adapter: str, port: int | None, yes: bool, force: bool) -> None:
249
+ """Delete a backend instance or config.
250
+
251
+ Without --port: Deletes the backend config (stops all instances first).
252
+ With --port: Stops and unregisters specific instance (keeps config).
253
+ """
254
+ import shutil
255
+
256
+ yes = yes or force
257
+ console = Console(width=200)
258
+
259
+ if port is not None:
260
+ backend_id = f"{adapter}-{port}"
261
+ if not yes and not click.confirm(f"Stop backend instance '{backend_id}'?"):
262
+ console.print("Cancelled.")
263
+ return
264
+
265
+ try:
266
+ stop_cmd.callback(adapter, port) # type: ignore[misc] # click.Command.callback is Optional[Callable]; always set here
267
+ console.print(f"[green]Stopped[/green] backend instance '{backend_id}'")
268
+ except Exception as e:
269
+ console.print(f"[red]Error:[/red] {e}")
270
+ sys.exit(1)
271
+ else:
272
+ backend_dir = get_forge_home() / "backends" / adapter
273
+ if not backend_dir.exists():
274
+ console.print(f"[red]Error:[/red] Backend config not found for '{adapter}'")
275
+ sys.exit(1)
276
+
277
+ if not yes and not click.confirm(f"Delete backend config for '{adapter}' (stops all instances)?"):
278
+ console.print("Cancelled.")
279
+ return
280
+
281
+ store = BackendRegistryStore()
282
+ registry = store.read()
283
+ stopped = []
284
+ for backend_id in list(registry.backends.keys()):
285
+ if backend_id.startswith(f"{adapter}-"):
286
+ try:
287
+ # Use rsplit to handle adapter names with hyphens (e.g., "some-adapter-4000")
288
+ port_str = backend_id.rsplit("-", 1)[1]
289
+ stop_cmd.callback(adapter, int(port_str)) # type: ignore[misc] # click.Command.callback is Optional[Callable]; always set here
290
+ stopped.append(backend_id)
291
+ except Exception:
292
+ pass
293
+
294
+ if stopped:
295
+ console.print(f"Stopped instances: {', '.join(stopped)}")
296
+
297
+ shutil.rmtree(backend_dir)
298
+ console.print(f"[green]Deleted[/green] backend config for '{adapter}'")
forge/cli/claude.py ADDED
@@ -0,0 +1,411 @@
1
+ """Claude Code management commands.
2
+
3
+ Commands:
4
+ - forge claude start --proxy <id> - Start Claude with specific proxy
5
+ - forge claude start --no-proxy - Start Claude without proxy
6
+ - forge claude preset - Manage settings preset
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import click
18
+ import httpx
19
+ from rich.console import Console
20
+
21
+ from forge.core.paths import display_path
22
+ from forge.proxy.proxies import (
23
+ ProxyRegistryCorruptedError,
24
+ ProxyRegistryStore,
25
+ ProxyResolutionError,
26
+ resolve_proxy,
27
+ )
28
+ from forge.session.direct_model import apply_direct_model_env
29
+
30
+ logger = logging.getLogger(__name__)
31
+ console = Console()
32
+
33
+
34
+ def _default_context_limit() -> int:
35
+ from forge.runtime_config import get_runtime_config
36
+
37
+ return get_runtime_config().context_limit
38
+
39
+
40
+ def _get_context_limit_for_proxy(proxy_id: str) -> int:
41
+ """Compute context limit from proxy's default tier model.
42
+
43
+ Deterministic: uses the specific proxy_id to look up the exact model config,
44
+ unlike the heuristic _get_context_limit_for_template() in session.py which
45
+ picks the first matching template.
46
+ """
47
+ try:
48
+ from forge.config.loader import load_proxy_instance_config
49
+ from forge.core.models import get_context_window_tokens
50
+
51
+ proxy_config = load_proxy_instance_config(proxy_id)
52
+ if proxy_config is None:
53
+ logger.debug(f"No proxy config found for {proxy_id}, using default context limit")
54
+ return _default_context_limit()
55
+
56
+ tier = proxy_config.default_tier or "sonnet"
57
+
58
+ model = proxy_config.tiers.get(tier)
59
+ if not model:
60
+ logger.debug(f"No model found for tier {tier}, using default context limit")
61
+ return _default_context_limit()
62
+
63
+ context_limit = get_context_window_tokens(model)
64
+ logger.debug(f"Computed context limit {context_limit} for model {model} (tier {tier})")
65
+ return context_limit
66
+
67
+ except Exception as e:
68
+ logger.debug(f"Failed to compute context limit: {e}, using default")
69
+ return _default_context_limit()
70
+
71
+
72
+ def _healthcheck_proxy(*, base_url: str, expected_template: str, expected_proxy_id: str) -> None:
73
+ """Validate proxy is reachable and matches proxy identity."""
74
+
75
+ url = base_url.rstrip("/") + "/"
76
+
77
+ try:
78
+ response = httpx.get(url, timeout=2.0)
79
+ except httpx.ConnectError:
80
+ raise ValueError(f"proxy is not running (connection refused at {url})")
81
+ except httpx.RequestError as e:
82
+ raise ValueError(f"proxy healthcheck failed at {url}: {e}")
83
+
84
+ if response.status_code != 200:
85
+ raise ValueError(f"proxy healthcheck failed at {url}: status {response.status_code}")
86
+
87
+ try:
88
+ data = response.json()
89
+ except ValueError as e:
90
+ raise ValueError(f"proxy healthcheck failed at {url}: invalid JSON: {e}")
91
+
92
+ if not isinstance(data, dict):
93
+ raise ValueError(f"proxy healthcheck failed at {url}: expected JSON object")
94
+
95
+ if data.get("is_proxy") is not True:
96
+ raise ValueError(f"proxy healthcheck failed at {url}: is_proxy is not true")
97
+
98
+ template = data.get("template")
99
+ if template != expected_template:
100
+ raise ValueError(
101
+ f"proxy healthcheck failed at {url}: template mismatch (expected '{expected_template}', got '{template}')"
102
+ )
103
+
104
+ proxy_block = data.get("proxy")
105
+ if not isinstance(proxy_block, dict):
106
+ raise ValueError(f"proxy healthcheck failed at {url}: missing proxy block")
107
+
108
+ actual_proxy_id = proxy_block.get("proxy_id")
109
+ if actual_proxy_id != expected_proxy_id:
110
+ raise ValueError(
111
+ f"proxy healthcheck failed at {url}: proxy_id mismatch (expected '{expected_proxy_id}', got '{actual_proxy_id}')"
112
+ )
113
+
114
+
115
+ # --- Group and Commands ---
116
+
117
+
118
+ @click.group(
119
+ context_settings={"help_option_names": ["-h", "--help"]},
120
+ )
121
+ def claude() -> None:
122
+ """Start and manage Claude Code.
123
+
124
+ \b
125
+ Examples:
126
+ forge claude start --proxy my-proxy # Start with specific proxy
127
+ forge claude start --no-proxy # Start without proxy (direct to Anthropic)
128
+ """
129
+ pass
130
+
131
+
132
+ def _build_bare_launch_env(
133
+ *,
134
+ base_url: str | None,
135
+ template: str | None,
136
+ context_limit: int | None,
137
+ ) -> tuple[dict[str, str], list[str]]:
138
+ """Build environment for bare Claude launch (no session state).
139
+
140
+ Returns (env_vars_to_set, env_vars_to_unset). Always scrubs session
141
+ identity vars so a nested ``forge claude start`` never inherits a
142
+ parent session.
143
+ """
144
+ env_vars: dict[str, str] = {}
145
+ unset_vars: list[str] = ["FORGE_SESSION", "FORGE_FORK_NAME", "FORGE_PARENT_SESSION"]
146
+
147
+ if base_url is None:
148
+ # Direct mode: don't touch CLAUDE_CODE_AUTO_COMPACT_WINDOW — it's a
149
+ # native CC env var the user may have set. Only scrub Forge-managed vars.
150
+ unset_vars.extend(["ANTHROPIC_BASE_URL", "ACTIVE_TEMPLATE"])
151
+ else:
152
+ env_vars["ANTHROPIC_BASE_URL"] = base_url
153
+ if context_limit is not None:
154
+ env_vars["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(context_limit)
155
+ if template:
156
+ env_vars["ACTIVE_TEMPLATE"] = template
157
+ else:
158
+ unset_vars.append("ACTIVE_TEMPLATE")
159
+
160
+ return env_vars, unset_vars
161
+
162
+
163
+ @claude.command("start")
164
+ @click.option(
165
+ "--proxy",
166
+ "proxy_id",
167
+ type=str,
168
+ default=None,
169
+ help="Proxy to use (proxy_id or template name)",
170
+ )
171
+ @click.option(
172
+ "--no-proxy",
173
+ "direct",
174
+ is_flag=True,
175
+ default=False,
176
+ help="Bypass the proxy and talk to Anthropic directly",
177
+ )
178
+ @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
179
+ def start_cmd(
180
+ proxy_id: str | None,
181
+ direct: bool,
182
+ claude_args: tuple[str, ...],
183
+ ) -> None:
184
+ """Start Claude Code with proxy routing or direct to Anthropic.
185
+
186
+ Bare launcher: no session state, no FORGE_SESSION. Use
187
+ ``forge session start`` for managed sessions with lifecycle tracking.
188
+
189
+ \b
190
+ Examples:
191
+ forge claude start --proxy my-proxy
192
+ forge claude start --no-proxy
193
+ forge claude start --proxy my-proxy -- --debug
194
+ """
195
+ if direct and proxy_id:
196
+ click.echo("Error: --no-proxy and --proxy are mutually exclusive")
197
+ sys.exit(1)
198
+ if not direct and not proxy_id:
199
+ click.echo("Error: one of --proxy or --no-proxy is required")
200
+ sys.exit(1)
201
+
202
+ from forge.session.claude.invoke import invoke_claude
203
+
204
+ # Resolve proxy to template + base_url
205
+ template: str | None = None
206
+ base_url: str | None = None
207
+ context_limit: int | None = None
208
+ proxy_display: str | None = None
209
+
210
+ if proxy_id:
211
+ proxy_store = ProxyRegistryStore()
212
+
213
+ try:
214
+ registry = proxy_store.read()
215
+ except ProxyRegistryCorruptedError as e:
216
+ click.echo(f"Error: {e}")
217
+ sys.exit(1)
218
+
219
+ try:
220
+ entry = resolve_proxy(registry, proxy_id)
221
+ except ProxyResolutionError as e:
222
+ click.echo(f"Error: {e}")
223
+ sys.exit(1)
224
+
225
+ try:
226
+ _healthcheck_proxy(
227
+ base_url=entry.base_url,
228
+ expected_template=entry.template,
229
+ expected_proxy_id=entry.proxy_id,
230
+ )
231
+ except ValueError as e:
232
+ click.echo(f"Error: {e}")
233
+ if "not running" in str(e):
234
+ click.echo(f"Tip: Run 'forge proxy start {entry.proxy_id}' to start it.")
235
+ sys.exit(1)
236
+
237
+ template = entry.template
238
+ base_url = entry.base_url
239
+ context_limit = _get_context_limit_for_proxy(entry.proxy_id)
240
+ proxy_display = entry.proxy_id
241
+
242
+ env_vars, unset_vars = _build_bare_launch_env(
243
+ base_url=base_url,
244
+ template=template,
245
+ context_limit=context_limit,
246
+ )
247
+
248
+ if direct:
249
+ from forge.runtime_config import get_default_direct_model
250
+
251
+ direct_model = get_default_direct_model()
252
+ error = apply_direct_model_env(env_vars, direct_model)
253
+ if error:
254
+ click.echo(f"Error: {error}")
255
+ sys.exit(1)
256
+
257
+ if proxy_display:
258
+ console.print(f"Starting Claude with proxy [green]{proxy_display}[/green] ({template})")
259
+ else:
260
+ console.print("Starting Claude [green]direct[/green] (no proxy)")
261
+
262
+ from forge.cli.session_addendum import (
263
+ resolve_addendum_content_for_proxy,
264
+ write_bare_addendum,
265
+ )
266
+
267
+ addendum_content = resolve_addendum_content_for_proxy(proxy_display)
268
+ addendum_path: Path | None = None
269
+ if addendum_content:
270
+ addendum_path = write_bare_addendum(addendum_content)
271
+
272
+ try:
273
+ sys.exit(
274
+ invoke_claude(
275
+ model=None,
276
+ system_prompt_file=str(addendum_path) if addendum_path else None,
277
+ env_vars=env_vars,
278
+ unset_env_vars=unset_vars,
279
+ extra_args=list(claude_args) if claude_args else None,
280
+ )
281
+ )
282
+ finally:
283
+ if addendum_path:
284
+ addendum_path.unlink(missing_ok=True)
285
+
286
+
287
+ # --- Preset subgroup ---
288
+
289
+
290
+ @claude.group("preset")
291
+ def preset() -> None:
292
+ """Manage Claude Code settings preset.
293
+
294
+ \b
295
+ The preset (~/.forge/claude.preset.json) controls what settings
296
+ Forge merges into Claude Code's settings.json on enable/sync.
297
+
298
+ \b
299
+ Examples:
300
+ forge claude preset show # Show current preset
301
+ forge claude preset edit # Open in $EDITOR
302
+ forge claude preset reset # Reset to built-in defaults
303
+ """
304
+
305
+
306
+ @preset.command("show")
307
+ @click.option("--raw", is_flag=True, help="Output raw JSON without syntax highlighting")
308
+ def preset_show(raw: bool = False) -> None:
309
+ """Show current Claude Code settings preset."""
310
+ from rich.syntax import Syntax
311
+
312
+ from forge.install.preset import ensure_preset, get_preset_path
313
+
314
+ preset_path = get_preset_path()
315
+ ensure_preset()
316
+
317
+ content = preset_path.read_text(encoding="utf-8")
318
+
319
+ if raw:
320
+ console.print(content, end="")
321
+ else:
322
+ console.print("[bold]Claude Code Settings Preset[/bold]")
323
+ console.print(f"[bold]Path:[/bold] {display_path(preset_path)}")
324
+ console.print()
325
+ syntax = Syntax(content, "json", theme="monokai")
326
+ console.print(syntax)
327
+
328
+
329
+ @preset.command("edit")
330
+ def preset_edit() -> None:
331
+ """Open settings preset in $EDITOR.
332
+
333
+ Creates the file with built-in defaults if it doesn't exist.
334
+ Validates JSON before saving.
335
+ """
336
+ import json
337
+ import shutil
338
+ import tempfile
339
+
340
+ from forge.install.preset import ensure_preset, get_preset_path
341
+
342
+ preset_path = get_preset_path()
343
+ ensure_preset()
344
+
345
+ editor = os.environ.get("EDITOR", "vim")
346
+ if not shutil.which(editor):
347
+ console.print(f"[red]Error:[/red] Editor '{editor}' not found. Set $EDITOR to an available editor.")
348
+ sys.exit(1)
349
+
350
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
351
+ tmp.write(preset_path.read_text(encoding="utf-8"))
352
+ tmp_path = Path(tmp.name)
353
+
354
+ success = False
355
+ try:
356
+ result = subprocess.run([editor, str(tmp_path)])
357
+ if result.returncode != 0:
358
+ console.print(f"[red]Error:[/red] Editor exited with code {result.returncode}")
359
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
360
+ sys.exit(1)
361
+
362
+ try:
363
+ with open(tmp_path, encoding="utf-8") as f:
364
+ edited_data = json.load(f)
365
+ except json.JSONDecodeError as e:
366
+ console.print(f"[red]Error:[/red] Invalid JSON: {e}")
367
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
368
+ sys.exit(1)
369
+
370
+ if not isinstance(edited_data, dict):
371
+ console.print("[red]Error:[/red] Preset must be a JSON object")
372
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
373
+ sys.exit(1)
374
+
375
+ from forge.core.state import atomic_write_text
376
+
377
+ content = json.dumps(edited_data, indent=2) + "\n"
378
+ atomic_write_text(preset_path, content)
379
+ os.chmod(str(preset_path), 0o600)
380
+
381
+ success = True
382
+ console.print("[green]Updated[/green] settings preset")
383
+
384
+ finally:
385
+ if success and tmp_path.exists():
386
+ try:
387
+ tmp_path.unlink()
388
+ except OSError:
389
+ pass
390
+
391
+
392
+ @preset.command("reset")
393
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
394
+ @click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
395
+ def preset_reset(yes: bool, force: bool) -> None:
396
+ """Reset settings preset to built-in defaults."""
397
+ from forge.core.state import atomic_write_text
398
+ from forge.install.preset import get_builtin_preset_json, get_preset_path
399
+
400
+ yes = yes or force
401
+ preset_path = get_preset_path()
402
+
403
+ if not yes:
404
+ if not click.confirm("Reset preset to built-in defaults?"):
405
+ console.print("[dim]Cancelled.[/dim]")
406
+ return
407
+
408
+ preset_path.parent.mkdir(parents=True, exist_ok=True)
409
+ atomic_write_text(preset_path, get_builtin_preset_json())
410
+ os.chmod(str(preset_path), 0o600)
411
+ console.print("[green]Reset[/green] preset to built-in defaults")