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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- 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
|
+
...
|