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,219 @@
1
+ """Credential registry and capability metadata.
2
+
3
+ Single source of truth for Forge credential definitions. Each credential
4
+ maps to one or more env vars and describes what features it unlocks.
5
+
6
+ Dependency direction: this module imports TEMPLATE_SECRETS from
7
+ template_secrets.py (one-way). template_secrets.py must NOT import
8
+ from this module.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class EnvVar:
18
+ """Metadata for one environment variable within a credential."""
19
+
20
+ name: str
21
+ required: bool = True
22
+ secret: bool = True
23
+ connection_value: bool = False
24
+ default_value: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Credential:
29
+ """A Forge credential with its env vars and capability metadata."""
30
+
31
+ name: str
32
+ env_vars: tuple[EnvVar, ...] = ()
33
+ unlocks_features: tuple[str, ...] = ()
34
+ signup_url: str | None = None
35
+ note: str | None = None
36
+ not_needed_for: tuple[str, ...] | None = None
37
+
38
+
39
+ CREDENTIALS: dict[str, Credential] = {
40
+ "openrouter": Credential(
41
+ name="openrouter",
42
+ env_vars=(
43
+ EnvVar("OPENROUTER_API_KEY"),
44
+ EnvVar(
45
+ "OPENROUTER_BASE_URL",
46
+ required=False,
47
+ secret=False,
48
+ connection_value=True,
49
+ default_value="https://openrouter.ai/api/v1",
50
+ ),
51
+ ),
52
+ unlocks_features=("OpenRouter proxy templates", "OSS workflow model workers"),
53
+ signup_url="https://openrouter.ai/keys",
54
+ note="Routes to Claude, GPT, Gemini, DeepSeek, etc. via OpenRouter",
55
+ ),
56
+ "anthropic-api": Credential(
57
+ name="anthropic-api",
58
+ env_vars=(EnvVar("ANTHROPIC_API_KEY"),),
59
+ unlocks_features=(
60
+ "Forge subprocesses (supervisor, handoff agent)",
61
+ "direct Anthropic panel/debate workers",
62
+ "litellm-anthropic-local proxy",
63
+ ),
64
+ signup_url="https://console.anthropic.com/",
65
+ note="Pay-per-token API key. Not Claude Code login.",
66
+ not_needed_for=(
67
+ "forge session start (uses Claude Code's own auth)",
68
+ "Claude via openrouter-anthropic (uses OPENROUTER_API_KEY)",
69
+ "Claude via litellm-anthropic (uses LITELLM_API_KEY)",
70
+ ),
71
+ ),
72
+ "openai-api": Credential(
73
+ name="openai-api",
74
+ env_vars=(EnvVar("OPENAI_API_KEY"),),
75
+ unlocks_features=("litellm-openai-local proxy",),
76
+ signup_url="https://platform.openai.com/api-keys",
77
+ note="OpenAI API key for local LiteLLM proxy routing",
78
+ ),
79
+ "gemini-api": Credential(
80
+ name="gemini-api",
81
+ env_vars=(EnvVar("GEMINI_API_KEY"),),
82
+ unlocks_features=("litellm-gemini-local proxy",),
83
+ signup_url="https://aistudio.google.com/apikey",
84
+ note="Gemini API key for local LiteLLM proxy routing",
85
+ ),
86
+ "litellm-remote": Credential(
87
+ name="litellm-remote",
88
+ env_vars=(
89
+ EnvVar("LITELLM_API_KEY"),
90
+ EnvVar("LITELLM_BASE_URL", secret=False, connection_value=True),
91
+ ),
92
+ unlocks_features=("Remote LiteLLM proxy templates",),
93
+ note="Shared/internal LiteLLM server (team setups)",
94
+ ),
95
+ }
96
+
97
+ RETIRED_NAMES: dict[str, str] = {
98
+ "anthropic": (
99
+ "Unknown credential 'anthropic'. Did you mean 'anthropic-api'?\n"
100
+ "\n"
101
+ " 'anthropic-api' is for Forge subprocess auth (pay-per-token API key).\n"
102
+ " It is NOT your Claude Code login.\n"
103
+ "\n"
104
+ " Run: forge auth login -c anthropic-api"
105
+ ),
106
+ "litellm-local": (
107
+ "'litellm-local' is not a credential. It's a setup that uses upstream API keys.\n"
108
+ "\n"
109
+ " Configure the providers you need:\n"
110
+ " forge auth login -c gemini-api # for litellm-gemini-local\n"
111
+ " forge auth login -c openai-api # for litellm-openai-local\n"
112
+ " forge auth login -c anthropic-api # for litellm-anthropic-local"
113
+ ),
114
+ }
115
+
116
+
117
+ def credential_for_env_var(var_name: str) -> Credential | None:
118
+ """Find the credential that owns a given env var name."""
119
+ for cred in CREDENTIALS.values():
120
+ if any(ev.name == var_name for ev in cred.env_vars):
121
+ return cred
122
+ return None
123
+
124
+
125
+ def credentials_for_template(template: str) -> list[Credential]:
126
+ """Which credentials does a template need?
127
+
128
+ Bridges TEMPLATE_SECRETS (template -> env var names) to CREDENTIALS
129
+ (credential -> env var metadata) via reverse lookup.
130
+ """
131
+ from forge.core.auth.template_secrets import TEMPLATE_SECRETS
132
+
133
+ required_vars = TEMPLATE_SECRETS.get(template, [])
134
+ if not required_vars:
135
+ return []
136
+
137
+ seen: set[str] = set()
138
+ result: list[Credential] = []
139
+ for var_name in required_vars:
140
+ cred = credential_for_env_var(var_name)
141
+ if cred and cred.name not in seen:
142
+ seen.add(cred.name)
143
+ result.append(cred)
144
+ return result
145
+
146
+
147
+ def format_missing_credential_error(
148
+ credential: Credential,
149
+ *,
150
+ missing_vars: list[str],
151
+ template: str | None = None,
152
+ context: str | None = None,
153
+ extra_hint: str | None = None,
154
+ profile: str | None = None,
155
+ env_ignored: bool = False,
156
+ ) -> str:
157
+ """Build an actionable error message for missing credentials.
158
+
159
+ Includes what failed, which key(s), signup URL, and the exact
160
+ ``forge auth login`` command. Renders ``not_needed_for`` only for
161
+ anthropic-api (where false urgency is common).
162
+ """
163
+ key_word = "key" if len(missing_vars) == 1 else "keys"
164
+ var_list = ", ".join(missing_vars)
165
+
166
+ if context and template:
167
+ header = f"{context} requires {var_list} (template '{template}')."
168
+ elif context:
169
+ header = f"{context} requires {var_list}."
170
+ elif template:
171
+ header = f"Template '{template}' requires {key_word}: {var_list}."
172
+ else:
173
+ header = f"Missing {key_word}: {var_list}."
174
+
175
+ lines = [f"Error: {header}"]
176
+
177
+ if credential.note:
178
+ lines.append(f"\n {credential.note}")
179
+
180
+ if credential.not_needed_for:
181
+ lines.append("")
182
+ lines.append(" NOT needed for:")
183
+ for item in credential.not_needed_for:
184
+ lines.append(f" - {item}")
185
+
186
+ unlocks = credential.unlocks_features
187
+ if unlocks:
188
+ lines.append(f"\n Unlocks: {', '.join(unlocks)}")
189
+
190
+ if credential.signup_url:
191
+ lines.append(f" Get one at {credential.signup_url}")
192
+
193
+ login_cmd = f"forge auth login -c {credential.name}"
194
+ if profile:
195
+ login_cmd += f" --profile {profile}"
196
+ lines.append(f" Tip: Run '{login_cmd}' to configure.")
197
+
198
+ if extra_hint:
199
+ lines.append(f" {extra_hint}")
200
+
201
+ if env_ignored:
202
+ present_in_env = [v for v in missing_vars if _env_has(v)]
203
+ if present_in_env:
204
+ env_list = ", ".join(present_in_env)
205
+ verb = "is" if len(present_in_env) == 1 else "are"
206
+ pronoun = "it" if len(present_in_env) == 1 else "them"
207
+ lines.append(
208
+ f"\n Note: {env_list} {verb} set in env but auth_ignore_env is active."
209
+ f"\n Run 'forge config set auth_ignore_env=false' to use {pronoun}."
210
+ )
211
+
212
+ return "\n".join(lines)
213
+
214
+
215
+ def _env_has(var_name: str) -> bool:
216
+ """Check if an env var is set (for env_ignored diagnostic only)."""
217
+ import os
218
+
219
+ return bool(os.environ.get(var_name))
@@ -0,0 +1,244 @@
1
+ """File-based credential store (~/.forge/credentials.yaml).
2
+
3
+ Provides atomic read/write for the credential file with named profiles.
4
+ The FileSecretsProvider (in secrets.py) reads from this store;
5
+ CLI commands (forge auth login/status/logout) write via these functions.
6
+
7
+ Schema:
8
+ version: 1
9
+ profiles:
10
+ default:
11
+ LITELLM_API_KEY: "sk-..."
12
+ personal:
13
+ ANTHROPIC_API_KEY: "sk-ant-..."
14
+
15
+ Security: file permissions set to 0o600 (owner read/write only).
16
+ Concurrency: advisory file lock on write to prevent concurrent clobber.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import tempfile
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import yaml
28
+
29
+ from forge.core.paths import get_forge_home
30
+ from forge.core.state.lock import file_lock_for_target
31
+
32
+ CREDENTIALS_FILENAME = "credentials.yaml"
33
+ SCHEMA_VERSION = 1
34
+
35
+
36
+ class CredentialVersionError(Exception):
37
+ """Credential file has an incompatible schema version.
38
+
39
+ Distinct from ValueError (YAML corruption) so callers can distinguish
40
+ "safe to overwrite" from "don't touch — upgrade Forge first".
41
+ """
42
+
43
+
44
+ # Profile names: alphanumeric, hyphens, underscores only
45
+ _PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
46
+
47
+
48
+ def get_credentials_path() -> Path:
49
+ """Return path to ~/.forge/credentials.yaml."""
50
+ return get_forge_home() / CREDENTIALS_FILENAME
51
+
52
+
53
+ def resolve_profile(profile: str | None = None) -> str:
54
+ """Resolve active profile name.
55
+
56
+ Args:
57
+ profile: Explicit profile name (from CLI --profile flag).
58
+ If None, falls back to FORGE_PROFILE env var, then "default".
59
+ """
60
+ if profile is not None:
61
+ return profile
62
+ return os.environ.get("FORGE_PROFILE", "default")
63
+
64
+
65
+ def _validate_profile_name(name: str) -> None:
66
+ """Validate profile name contains only safe characters.
67
+
68
+ Raises:
69
+ ValueError: If name contains path separators, spaces, or control chars.
70
+ """
71
+ if not _PROFILE_NAME_RE.match(name):
72
+ raise ValueError(
73
+ f"Invalid profile name '{name}': "
74
+ f"must match [A-Za-z0-9_-] (no spaces, path separators, or special chars)"
75
+ )
76
+
77
+
78
+ def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
79
+ """Load all profiles from the credentials file.
80
+
81
+ Returns:
82
+ Dict mapping profile names to their key-value secrets.
83
+ Returns empty dict if file doesn't exist.
84
+
85
+ Raises:
86
+ ValueError: If file exists but is malformed.
87
+ """
88
+ creds_path = path or get_credentials_path()
89
+ if not creds_path.exists():
90
+ return {}
91
+
92
+ try:
93
+ with open(creds_path, encoding="utf-8") as f:
94
+ data = yaml.safe_load(f)
95
+ except yaml.YAMLError as e:
96
+ raise ValueError(
97
+ f"Corrupt credentials file: {creds_path}\n"
98
+ f"Recovery: mv {creds_path} {creds_path}.corrupt && forge auth login\n"
99
+ f"Parse error: {e}"
100
+ ) from e
101
+
102
+ if data is None:
103
+ return {}
104
+
105
+ if not isinstance(data, dict):
106
+ raise ValueError(f"credentials.yaml must be a YAML mapping, got {type(data).__name__}")
107
+
108
+ version = data.get("version")
109
+ if version is not None and version != SCHEMA_VERSION:
110
+ raise CredentialVersionError(
111
+ f"credentials.yaml has version {version}, but this Forge only supports version {SCHEMA_VERSION}. "
112
+ f"Upgrade Forge or recreate the file with 'forge auth login'."
113
+ )
114
+
115
+ profiles = data.get("profiles", {})
116
+ if not isinstance(profiles, dict):
117
+ raise ValueError("credentials.yaml 'profiles' must be a mapping")
118
+
119
+ # Validate all profile values are flat string dicts
120
+ for name, secrets in profiles.items():
121
+ if not isinstance(secrets, dict):
122
+ raise ValueError(f"Profile '{name}' must be a mapping")
123
+ for k, v in secrets.items():
124
+ if not isinstance(v, str):
125
+ raise ValueError(f"Profile '{name}' key '{k}' must be a string, got {type(v).__name__}")
126
+
127
+ return profiles
128
+
129
+
130
+ def load_profile(profile: str, *, path: Path | None = None) -> dict[str, str]:
131
+ """Load a single profile's secrets.
132
+
133
+ Returns empty dict if the profile or file doesn't exist.
134
+ """
135
+ profiles = load_credentials(path)
136
+ return profiles.get(profile, {})
137
+
138
+
139
+ def save_profile(
140
+ profile: str,
141
+ secrets: dict[str, str],
142
+ *,
143
+ path: Path | None = None,
144
+ merge: bool = True,
145
+ ) -> Path:
146
+ """Save secrets to a profile in the credentials file.
147
+
148
+ Uses advisory file lock, atomic write (tempfile + os.replace),
149
+ and 0o600 permissions.
150
+
151
+ Args:
152
+ profile: Profile name to save to.
153
+ secrets: Key-value pairs to store.
154
+ path: Override credentials file path (for testing).
155
+ merge: If True, merge with existing profile secrets.
156
+ If False, replace the profile entirely.
157
+
158
+ Returns:
159
+ Path to credentials file.
160
+
161
+ Raises:
162
+ ValueError: If profile name is invalid.
163
+ """
164
+ _validate_profile_name(profile)
165
+
166
+ creds_path = path or get_credentials_path()
167
+
168
+ with file_lock_for_target(target_path=creds_path, timeout_s=5.0):
169
+ # Read-modify-write under lock
170
+ try:
171
+ profiles = load_credentials(creds_path)
172
+ except ValueError:
173
+ # Corrupt file — start fresh under lock
174
+ profiles = {}
175
+
176
+ if merge and profile in profiles:
177
+ profiles[profile].update(secrets)
178
+ else:
179
+ profiles[profile] = dict(secrets)
180
+
181
+ _write_credentials(creds_path, profiles)
182
+
183
+ return creds_path
184
+
185
+
186
+ def delete_profile(profile: str, *, path: Path | None = None) -> bool:
187
+ """Delete a profile from the credentials file.
188
+
189
+ Returns True if the profile existed, False otherwise.
190
+
191
+ Raises:
192
+ ValueError: If profile name is invalid.
193
+ """
194
+ _validate_profile_name(profile)
195
+
196
+ creds_path = path or get_credentials_path()
197
+
198
+ with file_lock_for_target(target_path=creds_path, timeout_s=5.0):
199
+ try:
200
+ profiles = load_credentials(creds_path)
201
+ except ValueError:
202
+ return False
203
+
204
+ if profile not in profiles:
205
+ return False
206
+
207
+ del profiles[profile]
208
+ _write_credentials(creds_path, profiles)
209
+
210
+ return True
211
+
212
+
213
+ def list_profiles(path: Path | None = None) -> list[str]:
214
+ """Return sorted list of profile names."""
215
+ profiles = load_credentials(path)
216
+ return sorted(profiles.keys())
217
+
218
+
219
+ def _write_credentials(creds_path: Path, profiles: dict[str, dict[str, str]]) -> None:
220
+ """Atomic write of credentials file with 0o600 permissions."""
221
+ creds_path.parent.mkdir(parents=True, exist_ok=True)
222
+
223
+ data: dict[str, Any] = {
224
+ "version": SCHEMA_VERSION,
225
+ "profiles": profiles,
226
+ }
227
+
228
+ fd, tmp_path = tempfile.mkstemp(
229
+ dir=str(creds_path.parent),
230
+ prefix=f".{creds_path.stem}.",
231
+ suffix=".tmp",
232
+ )
233
+ try:
234
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
235
+ f.write("# Forge Credential Store — managed by `forge auth login`\n\n")
236
+ yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
237
+ os.chmod(tmp_path, 0o600)
238
+ os.replace(tmp_path, str(creds_path))
239
+ except Exception:
240
+ try:
241
+ os.unlink(tmp_path)
242
+ except OSError:
243
+ pass
244
+ raise
@@ -0,0 +1,18 @@
1
+ """Authentication protocols shared across auth and LLM modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+
8
+ @runtime_checkable
9
+ class SecretsProvider(Protocol):
10
+ """Protocol for optional and required secret lookup."""
11
+
12
+ def get(self, key: str, default: Any = None) -> Any:
13
+ """Get a secret value, returning default if not found or empty."""
14
+ ...
15
+
16
+ def require(self, key: str) -> str:
17
+ """Get a required secret value, raising if not found or empty."""
18
+ ...