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/auth.py ADDED
@@ -0,0 +1,483 @@
1
+ """Authentication CLI commands.
2
+
3
+ Provides ``forge authentication login`` for storing credentials in
4
+ ``~/.forge/credentials.yaml``, ``forge authentication status`` to check
5
+ credential status, ``forge authentication logout`` to remove stored
6
+ credentials, and ``forge authentication profiles`` to list saved profiles.
7
+
8
+ Usage:
9
+ forge authentication login # Credential selection menu
10
+ forge authentication login -c anthropic-api # Single credential
11
+ forge authentication login -c anthropic-api --profile work
12
+ forge authentication status # Dual-view status
13
+ forge authentication logout --profile default # Remove stored credentials
14
+ forge authentication profiles # List saved profiles
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+
22
+ import click
23
+
24
+ from forge.core.auth.capabilities import (
25
+ CREDENTIALS,
26
+ RETIRED_NAMES,
27
+ Credential,
28
+ EnvVar,
29
+ )
30
+ from forge.core.auth.credentials_file import (
31
+ CredentialVersionError,
32
+ delete_profile,
33
+ list_profiles,
34
+ load_profile,
35
+ resolve_profile,
36
+ save_profile,
37
+ )
38
+
39
+ _log = logging.getLogger(__name__)
40
+
41
+
42
+ def _mask_value(value: str) -> str:
43
+ """Mask all but first/last 4 chars of a secret value."""
44
+ if len(value) <= 8:
45
+ return "****"
46
+ return value[:4] + "…" + value[-4:]
47
+
48
+
49
+ def _resolve_var_source(
50
+ ev: EnvVar,
51
+ file_secrets: dict[str, str],
52
+ ignore_env: bool,
53
+ ) -> tuple[str | None, str]:
54
+ """Resolve a single env var's value and source label.
55
+
56
+ Returns (value_or_None, source_label).
57
+ """
58
+ env_val = os.environ.get(ev.name)
59
+ file_val = file_secrets.get(ev.name)
60
+
61
+ if ignore_env:
62
+ if file_val:
63
+ return file_val, "file"
64
+ if env_val:
65
+ return None, "not configured (env ignored)"
66
+ return None, "not configured"
67
+
68
+ if env_val:
69
+ return env_val, "env"
70
+ if file_val:
71
+ return file_val, "file"
72
+ return None, "not configured"
73
+
74
+
75
+ def _credential_state(
76
+ cred: Credential,
77
+ file_secrets: dict[str, str],
78
+ ignore_env: bool,
79
+ profile_name: str,
80
+ ) -> str:
81
+ """Compute aggregate configuration state for a credential.
82
+
83
+ Returns one of: "configured (env)", "configured (file)", "configured (env+file)",
84
+ "partially configured", "not configured", "not configured (env ignored)".
85
+ """
86
+ sources: set[str] = set()
87
+ any_missing = False
88
+ env_ignored_present = False
89
+
90
+ for ev in cred.env_vars:
91
+ if not ev.required:
92
+ continue
93
+ _, source = _resolve_var_source(ev, file_secrets, ignore_env)
94
+ if source == "not configured":
95
+ any_missing = True
96
+ elif source == "not configured (env ignored)":
97
+ any_missing = True
98
+ env_ignored_present = True
99
+ else:
100
+ sources.add(source)
101
+
102
+ if not sources:
103
+ if env_ignored_present:
104
+ return "not configured (env ignored)"
105
+ return "not configured"
106
+ if any_missing:
107
+ return "partially configured"
108
+
109
+ if sources == {"env"}:
110
+ return "configured (env)"
111
+ if sources == {"file"}:
112
+ return f"configured (file:{profile_name})"
113
+ if sources == {"env", "file"}:
114
+ return "configured (env+file)"
115
+ return "configured"
116
+
117
+
118
+ def _capability_summary(cred: Credential) -> str:
119
+ """One-line capability description for the credential menu."""
120
+ features = ", ".join(cred.unlocks_features)
121
+ if features and cred.note:
122
+ return f"{features} ({cred.note})"
123
+ if features:
124
+ return features
125
+ if cred.note:
126
+ return cred.note
127
+ return cred.name
128
+
129
+
130
+ # ── Click commands ────────────────────────────────────────────────
131
+
132
+
133
+ @click.group()
134
+ def auth() -> None:
135
+ """Manage authentication and credentials.
136
+
137
+ \b
138
+ Examples:
139
+ forge authentication login # Store credentials
140
+ forge authentication status # Check credential sources
141
+ forge authentication profiles # List saved profiles
142
+ """
143
+ pass
144
+
145
+
146
+ @auth.command("login")
147
+ @click.option(
148
+ "--credential",
149
+ "-c",
150
+ "credential",
151
+ type=str,
152
+ default=None,
153
+ help="Credential to configure (e.g. openrouter, anthropic-api, gemini-api)",
154
+ )
155
+ @click.option(
156
+ "--profile",
157
+ default=None,
158
+ help="Profile name to store credentials in (default: 'default' or FORGE_PROFILE)",
159
+ )
160
+ def login(credential: str | None, profile: str | None) -> None:
161
+ """Store credentials for Forge proxy routing and subprocesses.
162
+
163
+ These are for Forge, NOT your Claude Code login (OAuth/Max plan).
164
+ Press Enter to keep the existing value or skip env-provided keys.
165
+
166
+ \b
167
+ Examples:
168
+ forge auth login # Credential selection menu
169
+ forge auth login -c anthropic-api # Single credential
170
+ forge auth login -c openrouter --profile work
171
+ """
172
+ profile_name = resolve_profile(profile)
173
+
174
+ # Validate credential name
175
+ if credential is not None:
176
+ if credential in RETIRED_NAMES:
177
+ click.secho(RETIRED_NAMES[credential], fg="yellow", err=True)
178
+ raise SystemExit(1)
179
+ if credential not in CREDENTIALS:
180
+ click.secho(f"Unknown credential '{credential}'.", fg="red", err=True)
181
+ click.echo(f"Available: {', '.join(CREDENTIALS)}", err=True)
182
+ raise SystemExit(1)
183
+
184
+ ignore_env = _get_auth_ignore_env()
185
+
186
+ try:
187
+ existing = load_profile(profile_name)
188
+ except CredentialVersionError as e:
189
+ click.secho(f"✗ {e}", fg="red")
190
+ raise SystemExit(1)
191
+ except ValueError:
192
+ existing = {}
193
+ click.secho("⚠︎ Existing credentials file is corrupt -- starting fresh.", fg="yellow")
194
+
195
+ # Select which credentials to configure
196
+ if credential is not None:
197
+ to_configure = [CREDENTIALS[credential]]
198
+ else:
199
+ to_configure = _credential_menu(existing, ignore_env, profile_name)
200
+ if not to_configure:
201
+ return
202
+
203
+ # Prompt for each credential's env vars
204
+ collected: dict[str, str] = {}
205
+
206
+ click.echo(f"\nConfiguring credentials for profile '{profile_name}'")
207
+ click.echo("-" * 40)
208
+
209
+ for cred in to_configure:
210
+ _prompt_credential(cred, existing, collected, ignore_env)
211
+
212
+ if collected:
213
+ path = save_profile(profile_name, collected, merge=True)
214
+ click.echo()
215
+ click.secho(
216
+ f"✓ Credentials saved to {path} (profile: {profile_name})",
217
+ fg="green",
218
+ )
219
+ click.echo("Tip: Use 'forge auth status' to verify.")
220
+ else:
221
+ click.echo("\nNo credentials to save.")
222
+
223
+
224
+ def _credential_menu(
225
+ file_secrets: dict[str, str],
226
+ ignore_env: bool,
227
+ profile_name: str,
228
+ ) -> list[Credential]:
229
+ """Show numbered credential selection menu. Returns selected credentials."""
230
+ click.echo("\nForge credentials")
231
+ click.echo("These are for Forge proxy routing and subprocesses, NOT your Claude Code login.")
232
+ click.echo("Claude Code authenticates separately (OAuth, Max plan, etc.).\n")
233
+
234
+ cred_list = list(CREDENTIALS.values())
235
+ for i, cred in enumerate(cred_list, 1):
236
+ state = _credential_state(cred, file_secrets, ignore_env, profile_name)
237
+ marker = "*" if state.startswith("configured") else "-"
238
+ summary = _capability_summary(cred)
239
+ click.echo(f" [{i}] {cred.name:<18} {marker} {state:<28} {summary}")
240
+
241
+ click.echo()
242
+ raw = click.prompt(
243
+ f"Select credentials [1-{len(cred_list)}, comma-separated, or 'all']",
244
+ default="all",
245
+ show_default=True,
246
+ )
247
+
248
+ if raw.strip().lower() == "all":
249
+ return cred_list
250
+
251
+ selected: list[Credential] = []
252
+ for part in raw.split(","):
253
+ part = part.strip()
254
+ try:
255
+ idx = int(part) - 1
256
+ if 0 <= idx < len(cred_list):
257
+ selected.append(cred_list[idx])
258
+ except ValueError:
259
+ click.secho(f"Ignoring invalid selection: {part}", fg="yellow")
260
+
261
+ return selected
262
+
263
+
264
+ def _prompt_credential(
265
+ cred: Credential,
266
+ existing: dict[str, str],
267
+ collected: dict[str, str],
268
+ ignore_env: bool,
269
+ ) -> None:
270
+ """Prompt for a single credential's env vars."""
271
+ header = f"\n{cred.name}"
272
+ if cred.note:
273
+ header += f": {cred.note}"
274
+ click.echo(header)
275
+
276
+ if cred.not_needed_for:
277
+ click.echo()
278
+ for item in cred.not_needed_for:
279
+ click.echo(f" NOT needed for: {item}")
280
+ click.echo()
281
+
282
+ for ev in cred.env_vars:
283
+ _prompt_env_var(ev, existing, collected, ignore_env)
284
+
285
+
286
+ def _prompt_env_var(
287
+ ev: EnvVar,
288
+ existing: dict[str, str],
289
+ collected: dict[str, str],
290
+ ignore_env: bool,
291
+ ) -> None:
292
+ """Prompt for a single env var with env-aware skip behavior."""
293
+ current = existing.get(ev.name, "")
294
+ raw_env_value = os.environ.get(ev.name)
295
+ env_value = None if ignore_env else raw_env_value
296
+
297
+ if ignore_env and raw_env_value:
298
+ display = _mask_value(raw_env_value) if ev.secret else raw_env_value
299
+ click.echo(f" {ev.name}: set in environment ({display}) but auth_ignore_env is active.")
300
+ click.echo(" Enter a value for the credential file, or press Enter to skip.")
301
+ prompt_text = f" {ev.name} [skip]"
302
+ elif env_value:
303
+ display = _mask_value(env_value) if ev.secret else env_value
304
+ click.echo(f" {ev.name}: already set via environment variable ({display})")
305
+ click.echo(" Storing in credential file is optional (env var takes precedence).")
306
+ prompt_text = f" {ev.name} [skip]"
307
+ elif current:
308
+ default_display = _mask_value(current) if ev.secret else current
309
+ prompt_text = f" {ev.name} [{default_display}]"
310
+ elif ev.default_value:
311
+ click.echo(f" {ev.name}: default is {ev.default_value}")
312
+ prompt_text = f" {ev.name} [skip]"
313
+ else:
314
+ prompt_text = f" {ev.name}"
315
+
316
+ value = click.prompt(
317
+ prompt_text,
318
+ default="",
319
+ show_default=False,
320
+ hide_input=ev.secret,
321
+ )
322
+
323
+ if value:
324
+ collected[ev.name] = value
325
+ elif current:
326
+ collected[ev.name] = current
327
+
328
+
329
+ @auth.command("status")
330
+ @click.option(
331
+ "--profile",
332
+ default=None,
333
+ help="Profile to check (default: 'default' or FORGE_PROFILE)",
334
+ )
335
+ def status(profile: str | None) -> None:
336
+ """Show credential status with capability summary and source details.
337
+
338
+ \b
339
+ Examples:
340
+ forge authentication status
341
+ forge authentication status --profile work
342
+ """
343
+ profile_name = resolve_profile(profile)
344
+
345
+ ignore_env = _get_auth_ignore_env()
346
+
347
+ try:
348
+ file_secrets = load_profile(profile_name)
349
+ except CredentialVersionError as e:
350
+ click.secho(f"✗ {e}", fg="red")
351
+ raise SystemExit(1)
352
+ except ValueError:
353
+ file_secrets = {}
354
+ click.secho("⚠︎ Credentials file is corrupt -- file-based values unavailable.", fg="yellow")
355
+ click.echo("Tip: Run 'forge auth login' to recreate the file.")
356
+
357
+ click.echo(f"\nCredential status (profile: {profile_name})")
358
+ click.echo("=" * 50)
359
+
360
+ # Section 1: Capability summary
361
+ configured: list[str] = []
362
+ not_configured: list[str] = []
363
+
364
+ for cred in CREDENTIALS.values():
365
+ state = _credential_state(cred, file_secrets, ignore_env, profile_name)
366
+ summary = _capability_summary(cred)
367
+ if state.startswith("configured"):
368
+ # Find primary source for display
369
+ primary_source = state.split("(", 1)[1].rstrip(")") if "(" in state else ""
370
+ configured.append(f" * {cred.name:<18} {summary} ({primary_source})")
371
+ else:
372
+ not_configured.append(f" - {cred.name:<18} {summary} ({state})")
373
+
374
+ if configured:
375
+ click.echo("\nConfigured capabilities:")
376
+ for line in configured:
377
+ click.secho(line, fg="green")
378
+
379
+ if not_configured:
380
+ click.echo("\nNot configured (set up if needed):")
381
+ for line in not_configured:
382
+ click.echo(line)
383
+
384
+ # Section 2: Credential details
385
+ click.echo("\nCredential details:")
386
+
387
+ for cred in CREDENTIALS.values():
388
+ click.echo(f"\n {cred.name}")
389
+
390
+ for ev in cred.env_vars:
391
+ value, source = _resolve_var_source(ev, file_secrets, ignore_env)
392
+ if value:
393
+ display = _mask_value(value) if ev.secret else value
394
+ source_label = f"file:{profile_name}" if source == "file" else source
395
+ click.secho(f" * {ev.name} = {display} ({source_label})", fg="green")
396
+ elif ev.default_value and source == "not configured":
397
+ click.echo(f" - {ev.name} = {ev.default_value} (default)")
398
+ else:
399
+ click.echo(f" - {ev.name} {source}")
400
+
401
+ click.echo()
402
+
403
+
404
+ @auth.command("logout")
405
+ @click.option(
406
+ "--profile",
407
+ default=None,
408
+ help="Profile to remove credentials from (default: 'default' or FORGE_PROFILE)",
409
+ )
410
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
411
+ @click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
412
+ def logout(profile: str | None, yes: bool, force: bool) -> None:
413
+ """Remove stored credentials for a profile.
414
+
415
+ Deletes the profile from ~/.forge/credentials.yaml.
416
+ Environment variables are not affected.
417
+
418
+ \b
419
+ Examples:
420
+ forge authentication logout
421
+ forge authentication logout --profile work
422
+ forge authentication logout -y # Skip confirmation
423
+ """
424
+ yes = yes or force
425
+ profile_name = resolve_profile(profile)
426
+
427
+ if not yes:
428
+ if not click.confirm(f"Remove stored credentials for profile '{profile_name}'?"):
429
+ click.echo("Aborted.")
430
+ return
431
+
432
+ if delete_profile(profile_name):
433
+ click.secho(f"✓ Removed profile '{profile_name}'", fg="green")
434
+ else:
435
+ click.echo(f"Profile '{profile_name}' not found (nothing to remove).")
436
+
437
+
438
+ @auth.command("profiles")
439
+ def profiles_cmd() -> None:
440
+ """List saved credential profiles.
441
+
442
+ \b
443
+ Examples:
444
+ forge authentication profiles
445
+ """
446
+ try:
447
+ profile_names = list_profiles()
448
+ except CredentialVersionError as e:
449
+ click.secho(f"✗ {e}", fg="red")
450
+ raise SystemExit(1)
451
+ except ValueError as e:
452
+ click.secho(f"Error reading credentials file: {e}", fg="red")
453
+ click.echo("\nTip: Run 'forge auth login' to recreate the file.")
454
+ raise SystemExit(1)
455
+
456
+ if not profile_names:
457
+ click.echo("No profiles found.")
458
+ click.echo("\nTip: Run 'forge auth login' to create one.")
459
+ return
460
+
461
+ active = resolve_profile()
462
+
463
+ click.echo(f"\nSaved profiles ({len(profile_names)}):")
464
+ click.echo("-" * 30)
465
+
466
+ for name in profile_names:
467
+ secrets = load_profile(name)
468
+ key_count = len(secrets)
469
+ marker = " ← active" if name == active else ""
470
+ click.echo(f" {name} ({key_count} keys){marker}")
471
+
472
+ click.echo()
473
+
474
+
475
+ def _get_auth_ignore_env() -> bool:
476
+ """Read auth_ignore_env from runtime config."""
477
+ try:
478
+ from forge.runtime_config import get_runtime_config
479
+
480
+ return get_runtime_config().auth_ignore_env
481
+ except Exception as e:
482
+ _log.debug("Could not read auth_ignore_env; using environment credentials: %s", e)
483
+ return False