forgecommandline 0.8.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 (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/keys.py ADDED
@@ -0,0 +1,152 @@
1
+ """Round-robin key pools with per-key cooldowns that survive restarts."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import hashlib
6
+ import json
7
+ import pathlib
8
+ import threading
9
+ import time
10
+
11
+ from .config import HOME_DIR
12
+
13
+
14
+ def tail(key: str) -> str:
15
+ """Only ever show/log the last 4 characters of a key."""
16
+ return "..." + key[-4:]
17
+
18
+
19
+ def key_id(provider: str, key: str) -> str:
20
+ # Keys are never written to disk; only a short hash identifies them.
21
+ return f"{provider}:{hashlib.sha256(key.encode()).hexdigest()[:12]}"
22
+
23
+
24
+ def seconds_until_utc_midnight() -> int:
25
+ now = dt.datetime.now(dt.timezone.utc)
26
+ nxt = (now + dt.timedelta(days=1)).replace(hour=0, minute=0, second=5, microsecond=0)
27
+ return int((nxt - now).total_seconds())
28
+
29
+
30
+ class CooldownStore:
31
+ """Cooldowns and daily usage counts, persisted to ~/.forge/state.json."""
32
+
33
+ def __init__(self, path: pathlib.Path | None = None):
34
+ self.path = path or HOME_DIR / "state.json"
35
+ self._lock = threading.Lock()
36
+ self.until: dict[str, float] = {}
37
+ self.usage: dict[str, dict[str, int]] = {}
38
+ try:
39
+ data = json.loads(self.path.read_text(encoding="utf-8"))
40
+ self.until = {k: v for k, v in data.get("until", {}).items() if v > time.time()}
41
+ today = dt.date.today().isoformat()
42
+ self.usage = {today: data.get("usage", {}).get(today, {})}
43
+ except (OSError, ValueError):
44
+ pass
45
+
46
+ def _save(self) -> None:
47
+ try:
48
+ self.path.parent.mkdir(parents=True, exist_ok=True)
49
+ self.path.write_text(json.dumps({"until": self.until, "usage": self.usage}), encoding="utf-8")
50
+ except OSError:
51
+ pass
52
+
53
+ def available(self, kid: str) -> bool:
54
+ return time.time() >= self.until.get(kid, 0)
55
+
56
+ def bench(self, kid: str, seconds: float) -> None:
57
+ with self._lock:
58
+ self.until[kid] = max(self.until.get(kid, 0), time.time() + seconds)
59
+ self._save()
60
+
61
+ def count(self, kid: str, ok: bool) -> None:
62
+ with self._lock:
63
+ today = dt.date.today().isoformat()
64
+ day = self.usage.setdefault(today, {})
65
+ day[kid + (":ok" if ok else ":fail")] = day.get(kid + (":ok" if ok else ":fail"), 0) + 1
66
+ self._save()
67
+
68
+ def remaining(self, kid: str) -> float:
69
+ return max(0.0, self.until.get(kid, 0) - time.time())
70
+
71
+
72
+ class KeyPool:
73
+ def __init__(self, provider: str, keys: list[str], store: CooldownStore):
74
+ self.provider = provider
75
+ self.keys = keys
76
+ self.store = store
77
+ self._idx = 0
78
+ self._lock = threading.Lock()
79
+
80
+ def kid(self, key: str) -> str:
81
+ return key_id(self.provider, key)
82
+
83
+ def usable(self, key: str, model: str | None = None) -> bool:
84
+ """Key not resting, and (when a model is given) not resting for that model either.
85
+ Most free tiers (NVIDIA, Groq) count limits per model, so a 429 on one model
86
+ leaves the same key free for the others."""
87
+ kid = self.kid(key)
88
+ return self.store.available(kid) and (model is None or self.store.available(f"{kid}|{model}"))
89
+
90
+ def next_key(self, model: str | None = None) -> str | None:
91
+ """Next usable key, round-robin. None if every key is benched (for this model)."""
92
+ with self._lock:
93
+ for _ in range(len(self.keys)):
94
+ key = self.keys[self._idx % len(self.keys)]
95
+ self._idx += 1
96
+ if self.usable(key, model):
97
+ return key
98
+ return None
99
+
100
+ def any_available(self, model: str | None = None) -> bool:
101
+ return any(self.usable(k, model) for k in self.keys)
102
+
103
+ def wait_for(self, model: str | None = None) -> float:
104
+ """Seconds until some key is usable again (for this model); 0 if one is now."""
105
+ waits = []
106
+ for k in self.keys:
107
+ kid = self.kid(k)
108
+ w = self.store.remaining(kid)
109
+ if model:
110
+ w = max(w, self.store.remaining(f"{kid}|{model}"))
111
+ waits.append(w)
112
+ return min(waits) if waits else 0.0
113
+
114
+ def bench(self, key: str, seconds: float, model: str | None = None) -> None:
115
+ self.store.bench(self.kid(key) + (f"|{model}" if model else ""), seconds)
116
+
117
+ def model_benches(self) -> dict[str, list[float]]:
118
+ """model -> seconds left, one entry per key resting on that model."""
119
+ out: dict[str, list[float]] = {}
120
+ prefixes = {self.kid(k) + "|": k for k in self.keys}
121
+ for kid in list(self.store.until):
122
+ for pre in prefixes:
123
+ if kid.startswith(pre):
124
+ rem = self.store.remaining(kid)
125
+ if rem > 0:
126
+ out.setdefault(kid[len(pre):], []).append(rem)
127
+ return out
128
+
129
+ def status_rows(self) -> list[tuple[str, bool, str, int, int]]:
130
+ """(key tail, ready?, state text, ok today, failed today) per key."""
131
+ today = dt.date.today().isoformat()
132
+ day = self.store.usage.get(today, {})
133
+ rows = []
134
+ for k in self.keys:
135
+ kid = self.kid(k)
136
+ rem = self.store.remaining(kid)
137
+ state = "ready" if rem <= 0 else (f"benched {int(rem // 3600)}h{int(rem % 3600 // 60)}m" if rem >= 3600
138
+ else f"benched {int(rem // 60)}m{int(rem % 60)}s")
139
+ rows.append((tail(k), rem <= 0, state, day.get(kid + ":ok", 0), day.get(kid + ":fail", 0)))
140
+ return rows
141
+
142
+ def status_lines(self) -> list[str]:
143
+ today = dt.date.today().isoformat()
144
+ day = self.store.usage.get(today, {})
145
+ out = []
146
+ for k in self.keys:
147
+ kid = self.kid(k)
148
+ rem = self.store.remaining(kid)
149
+ state = "ready" if rem <= 0 else f"benched {int(rem // 60)}m{int(rem % 60)}s"
150
+ out.append(f" {self.provider:<11} {tail(k)} {state:<16} "
151
+ f"ok={day.get(kid + ':ok', 0)} fail={day.get(kid + ':fail', 0)}")
152
+ return out
forgecli/keyvault.py ADDED
@@ -0,0 +1,320 @@
1
+ """API keys (NEXT 7): spot them, mask them, store them, test them, and keep them away from models.
2
+
3
+ - detect_provider / candidates: which provider a key belongs to, from its prefix.
4
+ - redact: replace keys pasted into text (known prefixes, plus long secret-looking tokens when the
5
+ text talks about a key/token) before that text reaches a model, a session file or input history.
6
+ - KeyStore: unlimited keys per provider plus custom OpenAI-compatible providers. Secrets live in the
7
+ OS keychain (`keyring`: Windows Credential Manager, macOS Keychain, Secret Service) when it is
8
+ installed; otherwise in ~/.forge/keys.json (owner-only permissions where the OS supports it).
9
+ - test_key / identify: a live check against the provider (models list, else a 1-token call).
10
+ Keys are only ever shown as their last 4 characters.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import concurrent.futures as cf
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import pathlib
19
+ import re
20
+ import urllib.error
21
+ import urllib.request
22
+
23
+ # Prefix -> provider names it can belong to (the first is the most likely). Longest prefix first.
24
+ # Checked against each provider's key format as of 2026-11; a bare "sk-" is shared by several
25
+ # providers, so those keys are identified with a live test (identify()).
26
+ PREFIXES: list[tuple[str, list[str]]] = [
27
+ ("github_pat_", ["github"]), ("ghp_", ["github"]), ("gho_", ["github"]),
28
+ ("sk-ant-", ["anthropic"]), ("sk-or-", ["openrouter"]), ("sk-proj-", ["openai"]),
29
+ ("sk-svcacct-", ["openai"]), ("nvapi-", ["nvidia"]), ("gsk_", ["groq"]), ("AIza", ["gemini"]),
30
+ ("hf_", ["huggingface"]), ("tgp_v1_", ["together"]),
31
+ ("sk-", ["openai", "deepseek", "siliconflow"]),
32
+ ]
33
+ PREFIXES.sort(key=lambda p: -len(p[0]))
34
+ _UNVERIFIED_PREFIXES = ("csk-", "xai-")
35
+ _KNOWN = re.compile(r"(?<![A-Za-z0-9_-])(?:" +
36
+ "|".join(re.escape(p) for p in [*(p for p, _ in PREFIXES), *_UNVERIFIED_PREFIXES]) +
37
+ r")[A-Za-z0-9_\-]{16,}")
38
+ # Keys without a known prefix (Mistral, Cohere, SambaNova…): long mixed tokens, only redacted when
39
+ # the text is about a key, so code hashes and ids in ordinary messages are left alone.
40
+ _LOOSE = re.compile(r"(?<![A-Za-z0-9_\-/.])[A-Za-z0-9][A-Za-z0-9_\-]{30,}(?![A-Za-z0-9_\-/.])")
41
+ _ABOUT_KEYS = re.compile(r"\b(api|key|keys|token|secret|credential)s?\b", re.I)
42
+
43
+ # Shown by the setup walkthrough: free tier?, what it's good at, where to get a key.
44
+ SIGNUP: dict[str, tuple[str, str, str]] = {
45
+ "groq": ("free", "very fast replies", "https://console.groq.com/keys"),
46
+ "cerebras": ("free", "fastest large models", "https://cloud.cerebras.ai/platform"),
47
+ "gemini": ("free", "long context, vision", "https://aistudio.google.com/apikey"),
48
+ "openrouter": ("free models", "many models behind one key", "https://openrouter.ai/keys"),
49
+ "nvidia": ("free credits", "big open models", "https://build.nvidia.com/settings/api-keys"),
50
+ "github": ("free", "GPT-4.1 and others via GitHub Models", "https://github.com/settings/tokens"),
51
+ "mistral": ("free tier", "coding (Codestral, Devstral)", "https://console.mistral.ai/api-keys"),
52
+ "sambanova": ("free", "fast Llama/DeepSeek", "https://cloud.sambanova.ai/apis"),
53
+ "huggingface": ("free credits", "open models router", "https://huggingface.co/settings/tokens"),
54
+ "cohere": ("trial", "Command A", "https://dashboard.cohere.com/api-keys"),
55
+ "deepseek": ("paid, cheap", "strong coding/reasoning", "https://platform.deepseek.com/api_keys"),
56
+ "openai": ("paid", "GPT models", "https://platform.openai.com/api-keys"),
57
+ }
58
+
59
+ SERVICE = "forge-cli"
60
+ ANON = "anonymous" # same as config.ANON_KEY: keyless providers' placeholder
61
+ UA = "forge-cli/0.5"
62
+
63
+
64
+ def default_path() -> pathlib.Path:
65
+ return pathlib.Path(os.environ.get("FORGE_KEYS_FILE") or pathlib.Path.home() / ".forge" / "keys.json")
66
+
67
+
68
+ def candidates(key: str) -> list[str]:
69
+ key = key.strip()
70
+ for prefix, names in PREFIXES:
71
+ if key.startswith(prefix):
72
+ return list(names)
73
+ return []
74
+
75
+
76
+ def detect_provider(key: str) -> str | None:
77
+ """The provider a key surely belongs to; None when unknown or shared (bare "sk-")."""
78
+ c = candidates(key)
79
+ return c[0] if len(c) == 1 else None
80
+
81
+
82
+ def mask(key: str) -> str:
83
+ """Only ever show the last 4 characters."""
84
+ return "…" + key[-4:] if len(key) > 8 else "…"
85
+
86
+
87
+ def _secret_like(tok: str) -> bool:
88
+ if re.fullmatch(r"[0-9a-fA-F\-]+", tok): # git hashes, uuids, checksums
89
+ return False
90
+ kinds = sum(bool(re.search(rx, tok)) for rx in (r"[a-z]", r"[A-Z]", r"[0-9]"))
91
+ return kinds >= 3 or (kinds == 2 and len(tok) >= 40)
92
+
93
+
94
+ def find_keys(text: str) -> list[str]:
95
+ text = text or ""
96
+ found = [m.group(0) for m in _KNOWN.finditer(text)]
97
+ if _ABOUT_KEYS.search(text):
98
+ found += [m.group(0) for m in _LOOSE.finditer(text)
99
+ if _secret_like(m.group(0)) and not any(m.group(0) in f or f in m.group(0) for f in found)]
100
+ return list(dict.fromkeys(found))
101
+
102
+
103
+ def redact(text: str) -> tuple[str, list[str]]:
104
+ """Text with every key replaced by a masked placeholder, plus the keys found."""
105
+ found = find_keys(text)
106
+ for k in sorted(found, key=len, reverse=True):
107
+ text = text.replace(k, f"[{detect_provider(k) or 'api'} key {mask(k)} saved, hidden from the model]")
108
+ return text, found
109
+
110
+
111
+ # --- live checks -----------------------------------------------------------------------------
112
+
113
+ def _request(url: str, key: str, body: dict | None, timeout: float) -> int:
114
+ headers = {"User-Agent": UA, "Accept": "application/json"}
115
+ if key:
116
+ headers["Authorization"] = f"Bearer {key}"
117
+ data = None
118
+ if body is not None:
119
+ data = json.dumps(body).encode()
120
+ headers["Content-Type"] = "application/json"
121
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST" if body is not None else "GET")
122
+ try:
123
+ with urllib.request.urlopen(req, timeout=timeout) as r:
124
+ return r.status
125
+ except urllib.error.HTTPError as e:
126
+ return e.code
127
+
128
+
129
+ def test_key(base_url: str, key: str, model: str = "", timeout: float = 8.0) -> tuple[bool, str]:
130
+ """(works, reason). Lists models; where a provider has no models list, sends a 1-token request."""
131
+ base = base_url.rstrip("/")
132
+ try:
133
+ code = _request(base + "/models", key, None, timeout)
134
+ if code in (404, 405) and model:
135
+ code = _request(base + "/chat/completions", key, {
136
+ "model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]}, timeout)
137
+ except (urllib.error.URLError, OSError, ValueError) as e:
138
+ return False, f"could not reach {base} ({getattr(e, 'reason', e)})"
139
+ if 200 <= code < 300:
140
+ return True, "works"
141
+ if code == 429:
142
+ return True, "works (rate-limited right now)"
143
+ if code in (401, 403):
144
+ return False, f"rejected by the provider ({code}): wrong or revoked key"
145
+ if code == 402:
146
+ return False, "accepted but the account has no credit (402)"
147
+ return False, f"unexpected answer {code} from {base}"
148
+
149
+
150
+ def identify(key: str, endpoints: dict[str, tuple[str, str]], names: list[str] | None = None,
151
+ timeout: float = 6.0) -> str | None:
152
+ """Find which provider accepts a key by testing it against each candidate at once.
153
+ endpoints: name -> (base_url, model). Returns the first candidate (in order) that works."""
154
+ names = [n for n in (names or list(endpoints)) if n in endpoints]
155
+ if not names:
156
+ return None
157
+ with cf.ThreadPoolExecutor(max_workers=min(8, len(names))) as pool:
158
+ futures = {n: pool.submit(test_key, *endpoints[n][:1], key, endpoints[n][1], timeout) for n in names}
159
+ results = {n: f.result()[0] for n, f in futures.items()}
160
+ return next((n for n in names if results[n]), None)
161
+
162
+
163
+ # --- storage ---------------------------------------------------------------------------------
164
+
165
+ def _keyring():
166
+ if os.environ.get("FORGE_KEYRING", "1").strip().lower() in ("0", "off", "false", "no"):
167
+ return None
168
+ try:
169
+ import keyring # type: ignore
170
+ from keyring.backends import fail # type: ignore
171
+ kr = keyring.get_keyring()
172
+ if isinstance(kr, fail.Keyring) or "null" in type(kr).__module__:
173
+ return None
174
+ return keyring
175
+ except Exception:
176
+ return None
177
+
178
+
179
+ def fingerprint(key: str) -> str:
180
+ """Stable, non-reversible identifier for per-key usage records."""
181
+ return "sha256-" + hashlib.sha256(key.encode()).hexdigest()[:12]
182
+
183
+
184
+ def _kid(key: str) -> str:
185
+ return hashlib.sha256(key.encode()).hexdigest()[:12]
186
+
187
+
188
+ UNFILED = "_unfiled"
189
+
190
+
191
+ class KeyStore:
192
+ """{"version": 2, "keys": {provider: [{"id", "tail", "secret"?}]}, "custom": {name: {...}}}
193
+ Secrets go to the OS keychain when available ("secret" is then absent from the file)."""
194
+
195
+ def __init__(self, path: pathlib.Path | str | None = None, backend: str | None = None):
196
+ self.path = pathlib.Path(path) if path else default_path()
197
+ if backend is None:
198
+ backend = "file" if path else "auto"
199
+ self.kr = _keyring() if backend in ("auto", "keyring") else None
200
+ self.backend = "keyring" if self.kr else "file"
201
+
202
+ # raw file ---------------------------------------------------------------
203
+ def _read(self) -> dict:
204
+ try:
205
+ data = json.loads(self.path.read_text(encoding="utf-8"))
206
+ except (OSError, ValueError):
207
+ data = {}
208
+ if not isinstance(data, dict):
209
+ data = {}
210
+ if "version" not in data: # v1: {"groq": ["gsk_…"]}
211
+ data = {"version": 2, "custom": {},
212
+ "keys": {p: [{"id": _kid(k), "tail": k[-4:], "secret": k} for k in ks if isinstance(k, str)]
213
+ for p, ks in data.items() if isinstance(ks, list)}}
214
+ data.setdefault("keys", {})
215
+ data.setdefault("custom", {})
216
+ return data
217
+
218
+ def _write(self, data: dict) -> None:
219
+ self.path.parent.mkdir(parents=True, exist_ok=True)
220
+ tmp = self.path.with_suffix(".tmp")
221
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
222
+ try:
223
+ os.chmod(tmp, 0o600)
224
+ except OSError:
225
+ pass
226
+ tmp.replace(self.path)
227
+
228
+ def _secret(self, provider: str, row: dict) -> str:
229
+ if row.get("secret"):
230
+ return row["secret"]
231
+ if self.kr:
232
+ try:
233
+ return self.kr.get_password(SERVICE, f"{provider}:{row['id']}") or ""
234
+ except Exception:
235
+ return ""
236
+ return ""
237
+
238
+ # api --------------------------------------------------------------------
239
+ def load(self) -> dict[str, list[str]]:
240
+ """provider -> full keys (unfiled keys excluded)."""
241
+ out = {}
242
+ for p, rows in self._read()["keys"].items():
243
+ if p == UNFILED:
244
+ continue
245
+ keys = [s for s in (self._secret(p, r) for r in rows) if s]
246
+ if keys:
247
+ out[p] = keys
248
+ return out
249
+
250
+ def add(self, key: str, provider: str | None = None, guesses: list[str] | None = None) -> str | None:
251
+ """Store a key under its provider; returns that provider, or None when it was filed as
252
+ unidentified (see unfiled() / file())."""
253
+ key = key.strip()
254
+ provider = provider or detect_provider(key)
255
+ bucket = provider or UNFILED
256
+ data = self._read()
257
+ rows = data["keys"].setdefault(bucket, [])
258
+ kid = _kid(key)
259
+ if not any(r["id"] == kid for r in rows):
260
+ row = {"id": kid, "tail": key[-4:]}
261
+ if not provider:
262
+ row["guesses"] = guesses if guesses is not None else candidates(key)
263
+ stored = False
264
+ if self.kr:
265
+ try:
266
+ self.kr.set_password(SERVICE, f"{bucket}:{kid}", key)
267
+ stored = True
268
+ except Exception:
269
+ stored = False
270
+ if not stored:
271
+ row["secret"] = key
272
+ rows.append(row)
273
+ self._write(data)
274
+ return provider
275
+
276
+ def remove(self, provider: str, index: int) -> bool:
277
+ data = self._read()
278
+ rows = data["keys"].get(provider, [])
279
+ if not 0 <= index < len(rows):
280
+ return False
281
+ row = rows.pop(index)
282
+ if self.kr and not row.get("secret"):
283
+ try:
284
+ self.kr.delete_password(SERVICE, f"{provider}:{row['id']}")
285
+ except Exception:
286
+ pass
287
+ if not rows:
288
+ data["keys"].pop(provider, None)
289
+ self._write(data)
290
+ return True
291
+
292
+ def unfiled(self) -> list[tuple[str, list[str]]]:
293
+ """(key, guesses) for keys whose provider is not known yet."""
294
+ return [(self._secret(UNFILED, r), r.get("guesses", [])) for r in self._read()["keys"].get(UNFILED, [])]
295
+
296
+ def file(self, index: int, provider: str) -> str | None:
297
+ """Move unfiled key #index to provider; returns the key."""
298
+ items = self.unfiled()
299
+ if not 0 <= index < len(items):
300
+ return None
301
+ key = items[index][0]
302
+ self.remove(UNFILED, index)
303
+ self.add(key, provider)
304
+ return key
305
+
306
+ def custom(self) -> dict[str, dict]:
307
+ return dict(self._read()["custom"])
308
+
309
+ def add_custom(self, name: str, base_url: str, model: str) -> None:
310
+ data = self._read()
311
+ data["custom"][name] = {"base_url": base_url.rstrip("/"), "model": model}
312
+ self._write(data)
313
+
314
+ def rows(self) -> list[tuple[str, int, str]]:
315
+ """(provider, index, masked) for display; never the key itself."""
316
+ return [(p, i, "…" + r.get("tail", "")) for p, rs in sorted(self._read()["keys"].items())
317
+ for i, r in enumerate(rs)]
318
+
319
+ def listing(self) -> list[str]:
320
+ return [f"{p} #{i}: {m}" for p, i, m in self.rows()]