@trac3er/oh-my-god 2.0.0 → 2.0.2

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 (243) hide show
  1. package/.claude-plugin/marketplace.json +8 -8
  2. package/.claude-plugin/plugin.json +5 -4
  3. package/.claude-plugin/scripts/uninstall.sh +74 -3
  4. package/.claude-plugin/scripts/update.sh +78 -3
  5. package/.coveragerc +26 -0
  6. package/.mcp.json +4 -4
  7. package/CHANGELOG.md +14 -0
  8. package/CODE_OF_CONDUCT.md +27 -0
  9. package/CONTRIBUTING.md +62 -0
  10. package/OMG-setup.sh +1201 -355
  11. package/README.md +77 -56
  12. package/SECURITY.md +25 -0
  13. package/agents/__init__.py +1 -0
  14. package/agents/model_roles.py +196 -0
  15. package/agents/omg-architect-mode.md +3 -5
  16. package/agents/omg-backend-engineer.md +3 -5
  17. package/agents/omg-database-engineer.md +3 -5
  18. package/agents/omg-frontend-designer.md +4 -5
  19. package/agents/omg-implement-mode.md +4 -5
  20. package/agents/omg-infra-engineer.md +3 -5
  21. package/agents/omg-research-mode.md +4 -6
  22. package/agents/omg-security-auditor.md +3 -5
  23. package/agents/omg-testing-engineer.md +3 -5
  24. package/build/lib/yaml.py +321 -0
  25. package/commands/OMG:ai-commit.md +101 -14
  26. package/commands/OMG:arch.md +302 -19
  27. package/commands/OMG:ccg.md +12 -7
  28. package/commands/OMG:compat.md +25 -17
  29. package/commands/OMG:cost.md +173 -13
  30. package/commands/OMG:crazy.md +1 -1
  31. package/commands/OMG:create-agent.md +170 -20
  32. package/commands/OMG:deps.md +235 -17
  33. package/commands/OMG:domain-init.md +1 -1
  34. package/commands/OMG:escalate.md +41 -12
  35. package/commands/OMG:health-check.md +37 -13
  36. package/commands/OMG:init.md +122 -14
  37. package/commands/OMG:project-init.md +1 -1
  38. package/commands/OMG:session-branch.md +76 -9
  39. package/commands/OMG:session-fork.md +42 -5
  40. package/commands/OMG:session-merge.md +124 -8
  41. package/commands/OMG:setup.md +69 -12
  42. package/commands/OMG:stats.md +215 -14
  43. package/commands/OMG:teams.md +19 -10
  44. package/config/lsp_languages.yaml +8 -0
  45. package/hooks/__init__.py +0 -0
  46. package/hooks/_agent_registry.py +423 -0
  47. package/hooks/_analytics.py +291 -0
  48. package/hooks/_budget.py +31 -0
  49. package/hooks/_common.py +569 -0
  50. package/hooks/_compression_optimizer.py +119 -0
  51. package/hooks/_cost_ledger.py +176 -0
  52. package/hooks/_learnings.py +126 -0
  53. package/hooks/_memory.py +103 -0
  54. package/hooks/_protected_context.py +150 -0
  55. package/hooks/_token_counter.py +221 -0
  56. package/hooks/branch_manager.py +236 -0
  57. package/hooks/budget_governor.py +232 -0
  58. package/hooks/circuit-breaker.py +270 -0
  59. package/hooks/compression_feedback.py +254 -0
  60. package/hooks/config-guard.py +216 -0
  61. package/hooks/context_pressure.py +53 -0
  62. package/hooks/credential_store.py +1020 -0
  63. package/hooks/fetch-rate-limits.py +212 -0
  64. package/hooks/firewall.py +48 -0
  65. package/hooks/hashline-formatter-bridge.py +224 -0
  66. package/hooks/hashline-injector.py +273 -0
  67. package/hooks/hashline-validator.py +216 -0
  68. package/hooks/idle-detector.py +95 -0
  69. package/hooks/intentgate-keyword-detector.py +188 -0
  70. package/hooks/magic-keyword-router.py +195 -0
  71. package/hooks/policy_engine.py +505 -0
  72. package/hooks/post-tool-failure.py +19 -0
  73. package/hooks/post-write.py +219 -0
  74. package/hooks/post_write.py +46 -0
  75. package/hooks/pre-compact.py +398 -0
  76. package/hooks/pre-tool-inject.py +98 -0
  77. package/hooks/prompt-enhancer.py +672 -0
  78. package/hooks/quality-runner.py +191 -0
  79. package/hooks/query.py +512 -0
  80. package/hooks/secret-guard.py +61 -0
  81. package/hooks/secret_audit.py +144 -0
  82. package/hooks/session-end-capture.py +137 -0
  83. package/hooks/session-start.py +277 -0
  84. package/hooks/setup_wizard.py +582 -0
  85. package/hooks/shadow_manager.py +297 -0
  86. package/hooks/state_migration.py +225 -0
  87. package/hooks/stop-gate.py +7 -0
  88. package/hooks/stop_dispatcher.py +945 -0
  89. package/hooks/test-validator.py +361 -0
  90. package/hooks/test_generator_hook.py +123 -0
  91. package/hooks/todo-state-tracker.py +114 -0
  92. package/hooks/tool-ledger.py +149 -0
  93. package/hooks/trust_review.py +585 -0
  94. package/hud/omg-hud.mjs +31 -1
  95. package/lab/__init__.py +1 -0
  96. package/lab/pipeline.py +75 -0
  97. package/lab/policies.py +52 -0
  98. package/package.json +7 -18
  99. package/plugins/README.md +33 -61
  100. package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
  101. package/plugins/advanced/commands/OMG:learn.md +1 -1
  102. package/plugins/advanced/commands/OMG:security-review.md +3 -3
  103. package/plugins/advanced/commands/OMG:ship.md +1 -1
  104. package/plugins/advanced/plugin.json +1 -1
  105. package/plugins/core/plugin.json +8 -3
  106. package/plugins/dephealth/__init__.py +0 -0
  107. package/plugins/dephealth/cve_scanner.py +188 -0
  108. package/plugins/dephealth/license_checker.py +135 -0
  109. package/plugins/dephealth/manifest_detector.py +423 -0
  110. package/plugins/dephealth/vuln_analyzer.py +169 -0
  111. package/plugins/testgen/__init__.py +0 -0
  112. package/plugins/testgen/codamosa_engine.py +402 -0
  113. package/plugins/testgen/edge_case_synthesizer.py +184 -0
  114. package/plugins/testgen/framework_detector.py +271 -0
  115. package/plugins/testgen/skeleton_generator.py +219 -0
  116. package/plugins/viz/__init__.py +0 -0
  117. package/plugins/viz/ast_parser.py +139 -0
  118. package/plugins/viz/diagram_generator.py +192 -0
  119. package/plugins/viz/graph_builder.py +444 -0
  120. package/plugins/viz/native_parsers.py +259 -0
  121. package/plugins/viz/regex_parser.py +112 -0
  122. package/pyproject.toml +81 -0
  123. package/rules/contextual/write-verify.md +2 -2
  124. package/rules/core/00-truth.md +1 -1
  125. package/rules/core/01-surgical.md +1 -1
  126. package/rules/core/02-circuit-breaker.md +2 -2
  127. package/rules/core/03-ensemble.md +3 -3
  128. package/rules/core/04-testing.md +3 -3
  129. package/runtime/__init__.py +32 -0
  130. package/runtime/adapters/__init__.py +13 -0
  131. package/runtime/adapters/claude.py +60 -0
  132. package/runtime/adapters/gpt.py +53 -0
  133. package/runtime/adapters/local.py +53 -0
  134. package/runtime/adoption.py +212 -0
  135. package/runtime/business_workflow.py +220 -0
  136. package/runtime/cli_provider.py +85 -0
  137. package/runtime/compat.py +1299 -0
  138. package/runtime/custom_agent_loader.py +366 -0
  139. package/runtime/dispatcher.py +47 -0
  140. package/runtime/ecosystem.py +371 -0
  141. package/runtime/legacy_compat.py +7 -0
  142. package/runtime/mcp_config_writers.py +115 -0
  143. package/runtime/mcp_lifecycle.py +153 -0
  144. package/runtime/mcp_memory_server.py +135 -0
  145. package/runtime/memory_parsers/__init__.py +0 -0
  146. package/runtime/memory_parsers/chatgpt_parser.py +257 -0
  147. package/runtime/memory_parsers/claude_import.py +107 -0
  148. package/runtime/memory_parsers/export.py +97 -0
  149. package/runtime/memory_parsers/gemini_import.py +91 -0
  150. package/runtime/memory_parsers/kimi_import.py +91 -0
  151. package/runtime/memory_store.py +215 -0
  152. package/runtime/omc_compat.py +7 -0
  153. package/runtime/providers/__init__.py +0 -0
  154. package/runtime/providers/codex_provider.py +112 -0
  155. package/runtime/providers/gemini_provider.py +128 -0
  156. package/runtime/providers/kimi_provider.py +151 -0
  157. package/runtime/providers/opencode_provider.py +144 -0
  158. package/runtime/subagent_dispatcher.py +362 -0
  159. package/runtime/team_router.py +1167 -0
  160. package/runtime/tmux_session_manager.py +169 -0
  161. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  162. package/scripts/check-omg-contract-snapshot.py +12 -0
  163. package/scripts/check-omg-public-ready.py +193 -0
  164. package/scripts/check-omg-standalone-clean.py +103 -0
  165. package/scripts/legacy_to_omg_migrate.py +29 -0
  166. package/scripts/migrate-legacy.py +464 -0
  167. package/scripts/omc_to_omg_migrate.py +12 -0
  168. package/scripts/omg.py +492 -0
  169. package/scripts/settings-merge.py +283 -0
  170. package/scripts/verify-standalone.sh +8 -4
  171. package/settings.json +126 -29
  172. package/templates/profile.yaml +1 -1
  173. package/tools/__init__.py +2 -0
  174. package/tools/browser_consent.py +289 -0
  175. package/tools/browser_stealth.py +481 -0
  176. package/tools/browser_tool.py +448 -0
  177. package/tools/changelog_generator.py +347 -0
  178. package/tools/commit_splitter.py +746 -0
  179. package/tools/config_discovery.py +151 -0
  180. package/tools/config_merger.py +449 -0
  181. package/tools/dashboard_generator.py +300 -0
  182. package/tools/git_inspector.py +298 -0
  183. package/tools/lsp_client.py +275 -0
  184. package/tools/lsp_discovery.py +231 -0
  185. package/tools/lsp_operations.py +392 -0
  186. package/tools/pr_generator.py +404 -0
  187. package/tools/python_repl.py +656 -0
  188. package/tools/python_sandbox.py +609 -0
  189. package/tools/search_providers/__init__.py +77 -0
  190. package/tools/search_providers/brave.py +115 -0
  191. package/tools/search_providers/exa.py +116 -0
  192. package/tools/search_providers/jina.py +104 -0
  193. package/tools/search_providers/perplexity.py +139 -0
  194. package/tools/search_providers/synthetic.py +74 -0
  195. package/tools/session_snapshot.py +736 -0
  196. package/tools/ssh_manager.py +912 -0
  197. package/tools/theme_engine.py +294 -0
  198. package/tools/theme_selector.py +137 -0
  199. package/tools/web_search.py +622 -0
  200. package/yaml.py +321 -0
  201. package/.claude-plugin/scripts/install.sh +0 -9
  202. package/bun.lock +0 -23
  203. package/bunfig.toml +0 -3
  204. package/hooks/_budget.ts +0 -1
  205. package/hooks/_common.ts +0 -63
  206. package/hooks/circuit-breaker.ts +0 -101
  207. package/hooks/config-guard.ts +0 -4
  208. package/hooks/firewall.ts +0 -20
  209. package/hooks/policy_engine.ts +0 -156
  210. package/hooks/post-tool-failure.ts +0 -22
  211. package/hooks/post-write.ts +0 -4
  212. package/hooks/pre-tool-inject.ts +0 -4
  213. package/hooks/prompt-enhancer.ts +0 -46
  214. package/hooks/quality-runner.ts +0 -24
  215. package/hooks/secret-guard.ts +0 -4
  216. package/hooks/session-end-capture.ts +0 -19
  217. package/hooks/session-start.ts +0 -19
  218. package/hooks/shadow_manager.ts +0 -81
  219. package/hooks/stop-gate.ts +0 -22
  220. package/hooks/stop_dispatcher.ts +0 -147
  221. package/hooks/test-generator-hook.ts +0 -4
  222. package/hooks/tool-ledger.ts +0 -27
  223. package/hooks/trust_review.ts +0 -175
  224. package/lab/pipeline.ts +0 -75
  225. package/lab/policies.ts +0 -68
  226. package/runtime/common.ts +0 -111
  227. package/runtime/compat.ts +0 -174
  228. package/runtime/dispatcher.ts +0 -25
  229. package/runtime/ecosystem.ts +0 -186
  230. package/runtime/provider_bootstrap.ts +0 -99
  231. package/runtime/provider_smoke.ts +0 -34
  232. package/runtime/release_readiness.ts +0 -186
  233. package/runtime/team_router.ts +0 -144
  234. package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
  235. package/scripts/check-omg-standalone-clean.ts +0 -12
  236. package/scripts/check-runtime-clean.ts +0 -94
  237. package/scripts/omg.ts +0 -352
  238. package/scripts/settings-merge.ts +0 -93
  239. package/tools/commit_splitter.ts +0 -23
  240. package/tools/git_inspector.ts +0 -18
  241. package/tools/session_snapshot.ts +0 -47
  242. package/trac3er-oh-my-god-2.0.0.tgz +0 -0
  243. package/tsconfig.json +0 -15
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env python3
2
+ """OMG Multi-Credential Encrypted Store
3
+
4
+ Fernet-based encrypted credential storage with PBKDF2HMAC key derivation.
5
+ Stores encrypted credentials at .omg/state/credentials.enc with metadata
6
+ at .omg/state/credentials.meta.
7
+
8
+ CLI: python hooks/credential_store.py {add,list,remove,rotate} [options]
9
+
10
+ Feature flag: OMG_MULTI_CREDENTIAL_ENABLED (default off)
11
+ Design note: encrypted credentials live in OMG-managed state under .omg/state
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import base64
17
+ import gc
18
+ import getpass
19
+ import hashlib
20
+ import hmac
21
+ import json
22
+ import os
23
+ import sys
24
+ from datetime import datetime, timezone
25
+ from typing import Any
26
+
27
+ # --- Ensure hooks dir is on sys.path for _common imports ---
28
+ HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
29
+ if HOOKS_DIR not in sys.path:
30
+ sys.path.insert(0, HOOKS_DIR)
31
+
32
+ from _common import (
33
+ atomic_json_write,
34
+ get_feature_flag,
35
+ get_project_dir,
36
+ setup_crash_handler,
37
+ )
38
+
39
+ # Credential storage prefers Fernet, but standalone/no-vendor verification
40
+ # still needs a local authenticated fallback when optional crypto wheels are absent.
41
+ setup_crash_handler("credential_store", fail_closed=True)
42
+
43
+ # --- Lazy-loaded cryptography imports ---
44
+ _Fernet = None
45
+ _InvalidToken = None
46
+ _CRYPTO_BACKEND: str | None = None
47
+
48
+
49
+ def _ensure_crypto():
50
+ """Prefer cryptography, but allow a stdlib fallback in standalone mode."""
51
+ global _Fernet, _InvalidToken, _CRYPTO_BACKEND
52
+ if _CRYPTO_BACKEND is not None:
53
+ return
54
+ try:
55
+ from cryptography.fernet import Fernet, InvalidToken
56
+
57
+ _Fernet = Fernet
58
+ _InvalidToken = InvalidToken
59
+ _CRYPTO_BACKEND = "fernet"
60
+ except ImportError:
61
+ _Fernet = None
62
+ _InvalidToken = ValueError
63
+ _CRYPTO_BACKEND = "stdlib"
64
+
65
+
66
+ # --- Constants ---
67
+ CREDENTIALS_ENC = "credentials.enc"
68
+ CREDENTIALS_META = "credentials.meta"
69
+ STATE_DIR = os.path.join(".omg", "state")
70
+ KDF_ITERATIONS = 600_000
71
+ SALT_BYTES = 16
72
+ MIN_PASSPHRASE_LEN = 8
73
+
74
+ # Default empty store schema
75
+ _EMPTY_STORE = {"version": 1, "providers": {}}
76
+ _STDLIB_TOKEN_PREFIX = b"OMG1"
77
+
78
+
79
+ def _raw_key(key: bytes) -> bytes:
80
+ return base64.urlsafe_b64decode(key)
81
+
82
+
83
+ def _derive_keystream(secret: bytes, nonce: bytes, length: int) -> bytes:
84
+ chunks: list[bytes] = []
85
+ counter = 0
86
+ produced = 0
87
+ while produced < length:
88
+ block = hmac.new(secret, nonce + counter.to_bytes(4, "big"), hashlib.sha256).digest()
89
+ chunks.append(block)
90
+ produced += len(block)
91
+ counter += 1
92
+ return b"".join(chunks)[:length]
93
+
94
+
95
+ def _encrypt_store_stdlib(payload: bytes, key: bytes) -> bytes:
96
+ secret = _raw_key(key)
97
+ nonce = os.urandom(16)
98
+ keystream = _derive_keystream(secret, nonce, len(payload))
99
+ ciphertext = bytes(left ^ right for left, right in zip(payload, keystream))
100
+ digest = hmac.new(secret, _STDLIB_TOKEN_PREFIX + nonce + ciphertext, hashlib.sha256).digest()
101
+ return base64.urlsafe_b64encode(_STDLIB_TOKEN_PREFIX + nonce + digest + ciphertext)
102
+
103
+
104
+ def _decrypt_store_stdlib(token: bytes, key: bytes) -> dict | None:
105
+ try:
106
+ payload = base64.urlsafe_b64decode(token)
107
+ except Exception as exc: # pragma: no cover - invalid base64 still handled as bad token
108
+ raise ValueError("Decryption failed: wrong passphrase or corrupted store") from exc
109
+
110
+ if not payload.startswith(_STDLIB_TOKEN_PREFIX):
111
+ return None
112
+ if len(payload) < len(_STDLIB_TOKEN_PREFIX) + 16 + 32:
113
+ raise ValueError("Decryption failed: wrong passphrase or corrupted store")
114
+
115
+ secret = _raw_key(key)
116
+ prefix_len = len(_STDLIB_TOKEN_PREFIX)
117
+ nonce = payload[prefix_len : prefix_len + 16]
118
+ digest = payload[prefix_len + 16 : prefix_len + 48]
119
+ ciphertext = payload[prefix_len + 48 :]
120
+ expected = hmac.new(secret, _STDLIB_TOKEN_PREFIX + nonce + ciphertext, hashlib.sha256).digest()
121
+ if not hmac.compare_digest(digest, expected):
122
+ raise ValueError("Decryption failed: wrong passphrase or corrupted store")
123
+
124
+ keystream = _derive_keystream(secret, nonce, len(ciphertext))
125
+ plaintext = bytes(left ^ right for left, right in zip(ciphertext, keystream))
126
+ return json.loads(plaintext.decode("utf-8"))
127
+
128
+
129
+ # =============================================================================
130
+ # Core Crypto Functions
131
+ # =============================================================================
132
+
133
+
134
+ def derive_key(passphrase: bytes, salt: bytes, kdf_config: dict | None = None) -> bytes:
135
+ """Derive a 32-byte URL-safe key from passphrase using stdlib PBKDF2.
136
+
137
+ Args:
138
+ passphrase: Raw passphrase bytes
139
+ salt: 16-byte random salt
140
+ kdf_config: Optional dict with 'iterations' override
141
+
142
+ Returns:
143
+ URL-safe base64-encoded 32-byte key suitable for Fernet
144
+ """
145
+ iterations = KDF_ITERATIONS
146
+ if kdf_config and "iterations" in kdf_config:
147
+ iterations = int(kdf_config["iterations"])
148
+
149
+ derived = hashlib.pbkdf2_hmac("sha256", passphrase, salt, iterations, dklen=32)
150
+ return base64.urlsafe_b64encode(derived)
151
+
152
+
153
+ def encrypt_store(data: dict, key: bytes) -> bytes:
154
+ """Encrypt credential store payload with Fernet or the stdlib fallback.
155
+
156
+ Args:
157
+ data: Credential store dict to encrypt
158
+ key: Derived key (from derive_key)
159
+
160
+ Returns:
161
+ Token bytes
162
+ """
163
+ _ensure_crypto()
164
+ payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
165
+ if _CRYPTO_BACKEND == "fernet" and _Fernet is not None:
166
+ return _Fernet(key).encrypt(payload)
167
+ return _encrypt_store_stdlib(payload, key)
168
+
169
+
170
+ def decrypt_store(token: bytes, key: bytes) -> dict:
171
+ """Decrypt credential store payload.
172
+
173
+ Args:
174
+ token: Fernet token bytes
175
+ key: Derived key (from derive_key)
176
+
177
+ Returns:
178
+ Decrypted credential store dict
179
+
180
+ Raises:
181
+ ValueError: If passphrase is wrong or store contents are corrupted
182
+ """
183
+ fallback = _decrypt_store_stdlib(token, key)
184
+ if fallback is not None:
185
+ return fallback
186
+
187
+ _ensure_crypto()
188
+ if _CRYPTO_BACKEND != "fernet" or _Fernet is None or _InvalidToken is None:
189
+ raise ValueError("Decryption failed: wrong passphrase or corrupted store")
190
+
191
+ f = _Fernet(key)
192
+ try:
193
+ plaintext = f.decrypt(token)
194
+ except _InvalidToken:
195
+ raise ValueError("Decryption failed: wrong passphrase or corrupted store")
196
+ return json.loads(plaintext.decode("utf-8"))
197
+
198
+
199
+ # =============================================================================
200
+ # Store I/O
201
+ # =============================================================================
202
+
203
+
204
+ def _get_store_paths(project_dir: str | None = None) -> tuple[str, str]:
205
+ """Return (enc_path, meta_path) for the credential store."""
206
+ pdir = project_dir or get_project_dir()
207
+ state_dir = os.path.join(pdir, STATE_DIR)
208
+ return (
209
+ os.path.join(state_dir, CREDENTIALS_ENC),
210
+ os.path.join(state_dir, CREDENTIALS_META),
211
+ )
212
+
213
+
214
+ def _load_meta(meta_path: str) -> dict:
215
+ """Load metadata file or return default."""
216
+ if not os.path.exists(meta_path):
217
+ return {}
218
+ try:
219
+ with open(meta_path, "r", encoding="utf-8") as f:
220
+ return json.load(f)
221
+ except (json.JSONDecodeError, OSError):
222
+ return {}
223
+
224
+
225
+ def _save_meta(meta_path: str, meta: dict) -> None:
226
+ """Save metadata via atomic write."""
227
+ atomic_json_write(meta_path, meta)
228
+
229
+
230
+ def _create_new_meta(salt: bytes) -> dict:
231
+ """Create initial metadata structure."""
232
+ return {
233
+ "version": 1,
234
+ "kdf": "pbkdf2-sha256",
235
+ "kdf_params": {
236
+ "iterations": KDF_ITERATIONS,
237
+ "salt_b64": base64.b64encode(salt).decode("ascii"),
238
+ },
239
+ "created_at": datetime.now(timezone.utc).isoformat(),
240
+ "updated_at": datetime.now(timezone.utc).isoformat(),
241
+ "providers": [],
242
+ }
243
+
244
+
245
+ def load_store(passphrase: str, project_dir: str | None = None) -> dict:
246
+ """Load and decrypt the credential store. Creates new if missing.
247
+
248
+ Args:
249
+ passphrase: User passphrase string
250
+ project_dir: Optional project directory override
251
+
252
+ Returns:
253
+ Decrypted store dict
254
+ """
255
+ enc_path, meta_path = _get_store_paths(project_dir)
256
+ passphrase_bytes = passphrase.encode("utf-8")
257
+
258
+ if not os.path.exists(enc_path):
259
+ # New store — return fresh empty (deep copy to avoid shared mutation)
260
+ return {"version": _EMPTY_STORE["version"], "providers": {}}
261
+
262
+ meta = _load_meta(meta_path)
263
+ if not meta:
264
+ raise ValueError("Metadata file missing or corrupted; cannot derive key")
265
+
266
+ salt = base64.b64decode(meta["kdf_params"]["salt_b64"])
267
+ kdf_config = meta.get("kdf_params", {})
268
+ key = derive_key(passphrase_bytes, salt, kdf_config)
269
+
270
+ with open(enc_path, "rb") as f:
271
+ token = f.read()
272
+
273
+ store = decrypt_store(token, key)
274
+
275
+ # Best-effort memory cleanup
276
+ del passphrase_bytes
277
+ del key
278
+ gc.collect()
279
+
280
+ return store
281
+
282
+
283
+ def save_store(data: dict, passphrase: str, project_dir: str | None = None) -> None:
284
+ """Encrypt and atomically write the credential store.
285
+
286
+ Args:
287
+ data: Credential store dict to save
288
+ passphrase: User passphrase string
289
+ project_dir: Optional project directory override
290
+ """
291
+ enc_path, meta_path = _get_store_paths(project_dir)
292
+ passphrase_bytes = passphrase.encode("utf-8")
293
+
294
+ # Ensure state directory exists
295
+ state_dir = os.path.dirname(enc_path)
296
+ os.makedirs(state_dir, exist_ok=True)
297
+
298
+ meta = _load_meta(meta_path)
299
+
300
+ if not meta:
301
+ # First save — create new salt and metadata
302
+ salt = os.urandom(SALT_BYTES)
303
+ meta = _create_new_meta(salt)
304
+ else:
305
+ salt = base64.b64decode(meta["kdf_params"]["salt_b64"])
306
+
307
+ kdf_config = meta.get("kdf_params", {})
308
+ key = derive_key(passphrase_bytes, salt, kdf_config)
309
+ token = encrypt_store(data, key)
310
+
311
+ # Atomic write for encrypted store (temp + rename)
312
+ tmp_path = enc_path + ".tmp"
313
+ with open(tmp_path, "wb") as f:
314
+ f.write(token)
315
+ os.rename(tmp_path, enc_path)
316
+
317
+ # Update metadata (provider list only — no keys)
318
+ meta["updated_at"] = datetime.now(timezone.utc).isoformat()
319
+ meta["providers"] = sorted(data.get("providers", {}).keys())
320
+ _save_meta(meta_path, meta)
321
+
322
+ # Best-effort memory cleanup
323
+ del passphrase_bytes
324
+ del key
325
+ del token
326
+ gc.collect()
327
+
328
+
329
+ # =============================================================================
330
+ # Credential Operations
331
+ # =============================================================================
332
+
333
+
334
+ def add_credential(
335
+ provider: str,
336
+ key: str,
337
+ passphrase: str,
338
+ label: str | None = None,
339
+ project_dir: str | None = None,
340
+ expires_at: str | None = None,
341
+ ) -> None:
342
+ """Add an API key for a provider.
343
+
344
+ Args:
345
+ provider: Provider name (lowercase, alphanumeric + hyphens)
346
+ key: API key value (NEVER logged)
347
+ passphrase: User passphrase
348
+ label: Optional human-readable label
349
+ project_dir: Optional project directory
350
+ expires_at: Optional ISO8601 expiry datetime string
351
+ """
352
+ store = load_store(passphrase, project_dir)
353
+
354
+ if "providers" not in store:
355
+ store["providers"] = {}
356
+
357
+ if provider not in store["providers"]:
358
+ store["providers"][provider] = {
359
+ "keys": [],
360
+ "active_index": 0,
361
+ "rotation_policy": "round-robin",
362
+ }
363
+
364
+ provider_data = store["providers"][provider]
365
+ existing_keys = provider_data["keys"]
366
+
367
+ # Duplicate detection: compare last 8 chars only (avoid logging full key)
368
+ key_suffix = key[-8:] if len(key) >= 8 else key
369
+ for i, existing in enumerate(existing_keys):
370
+ existing_suffix = existing["key"][-8:] if len(existing["key"]) >= 8 else existing["key"]
371
+ if existing_suffix == key_suffix:
372
+ print(
373
+ f"Warning: Key ending in ...{key_suffix[-4:]} may already exist at index {i} for {provider}",
374
+ file=sys.stderr,
375
+ )
376
+ break
377
+
378
+ index = len(existing_keys)
379
+ if label is None:
380
+ label = f"key-{index}"
381
+
382
+ key_entry = {
383
+ "key": key,
384
+ "label": label,
385
+ "added": datetime.now(timezone.utc).isoformat(),
386
+ "last_used": None,
387
+ "usage_count": 0,
388
+ }
389
+ if expires_at is not None:
390
+ key_entry["expires_at"] = expires_at
391
+
392
+ existing_keys.append(key_entry)
393
+
394
+ # First key sets active_index
395
+ if index == 0:
396
+ provider_data["active_index"] = 0
397
+
398
+ save_store(store, passphrase, project_dir)
399
+ print(f"Added key '{label}' for provider '{provider}' at index {index}")
400
+
401
+
402
+ def list_credentials(
403
+ passphrase: str | None = None,
404
+ provider_filter: str | None = None,
405
+ project_dir: str | None = None,
406
+ ) -> dict[str, int]:
407
+ """List providers and key metadata.
408
+
409
+ Without passphrase: reads metadata only (provider names).
410
+ With passphrase: shows labels and usage stats (never keys).
411
+
412
+ Args:
413
+ passphrase: Optional passphrase for detailed view
414
+ provider_filter: Optional provider name to filter
415
+ project_dir: Optional project directory
416
+
417
+ Returns:
418
+ Dict of provider name → key count
419
+ """
420
+ _, meta_path = _get_store_paths(project_dir)
421
+ meta = _load_meta(meta_path)
422
+
423
+ if not meta or not meta.get("providers"):
424
+ print("No credentials configured.")
425
+ return {}
426
+
427
+ if passphrase and provider_filter:
428
+ # Detailed view for specific provider
429
+ store = load_store(passphrase, project_dir)
430
+ providers = store.get("providers", {})
431
+
432
+ if provider_filter not in providers:
433
+ print(f"Provider '{provider_filter}' not found.")
434
+ return {}
435
+
436
+ pdata = providers[provider_filter]
437
+ active_idx = pdata.get("active_index", 0)
438
+ policy = pdata.get("rotation_policy", "round-robin")
439
+ keys = pdata.get("keys", [])
440
+
441
+ print(f"Provider: {provider_filter} (rotation: {policy})")
442
+ for i, k in enumerate(keys):
443
+ active_marker = " [ACTIVE]" if i == active_idx else ""
444
+ last_used = k.get("last_used") or "never"
445
+ if last_used != "never":
446
+ last_used = last_used[:10] # Date only
447
+ added = (k.get("added") or "")[:10]
448
+ usage = k.get("usage_count", 0)
449
+ lbl = k.get("label", f"key-{i}")
450
+ print(f" [{i}] {lbl:<12} added={added} last_used={last_used} usage={usage}{active_marker}")
451
+
452
+ return {provider_filter: len(keys)}
453
+
454
+ # Summary view from metadata only
455
+ result = {}
456
+ if passphrase:
457
+ # Can decrypt to get key counts
458
+ store = load_store(passphrase, project_dir)
459
+ providers = store.get("providers", {})
460
+ for name in sorted(providers.keys()):
461
+ pdata = providers[name]
462
+ count = len(pdata.get("keys", []))
463
+ active = pdata.get("active_index", 0)
464
+ print(f"Provider: {name} ({count} keys, active: #{active})")
465
+ result[name] = count
466
+ else:
467
+ # Metadata only (no decryption)
468
+ for name in sorted(meta.get("providers", [])):
469
+ print(f"Provider: {name}")
470
+ result[name] = -1 # Count unknown without decryption
471
+
472
+ return result
473
+
474
+
475
+ def remove_credential(
476
+ provider: str,
477
+ index: int | None = None,
478
+ passphrase: str | None = None,
479
+ project_dir: str | None = None,
480
+ confirm: bool = True,
481
+ ) -> None:
482
+ """Remove a key or entire provider.
483
+
484
+ Args:
485
+ provider: Provider name
486
+ index: Key index to remove (None = remove entire provider)
487
+ passphrase: User passphrase
488
+ project_dir: Optional project directory
489
+ confirm: Whether to prompt for confirmation
490
+ """
491
+ if passphrase is None:
492
+ passphrase = _get_passphrase()
493
+
494
+ store = load_store(passphrase, project_dir)
495
+ providers = store.get("providers", {})
496
+
497
+ if provider not in providers:
498
+ print(f"Error: Provider '{provider}' not found.", file=sys.stderr)
499
+ sys.exit(1)
500
+
501
+ if index is not None:
502
+ # Remove specific key
503
+ keys = providers[provider].get("keys", [])
504
+ if index < 0 or index >= len(keys):
505
+ print(f"Error: Index {index} out of range (0-{len(keys) - 1}).", file=sys.stderr)
506
+ sys.exit(1)
507
+
508
+ lbl = keys[index].get("label", f"key-{index}")
509
+ if confirm:
510
+ answer = input(f"Remove key #{index} ('{lbl}') from {provider}? [y/N] ")
511
+ if answer.lower() not in ("y", "yes"):
512
+ print("Cancelled.")
513
+ return
514
+
515
+ keys.pop(index)
516
+
517
+ # Reset active_index if needed
518
+ active_idx = providers[provider].get("active_index", 0)
519
+ if active_idx >= len(keys):
520
+ providers[provider]["active_index"] = 0
521
+
522
+ if not keys:
523
+ # No keys left — remove entire provider
524
+ del providers[provider]
525
+ print(f"Removed last key from '{provider}'; provider removed.")
526
+ else:
527
+ print(f"Removed key #{index} ('{lbl}') from '{provider}'.")
528
+ else:
529
+ # Remove entire provider
530
+ key_count = len(providers[provider].get("keys", []))
531
+ if confirm:
532
+ answer = input(f"Remove provider '{provider}' ({key_count} keys)? [y/N] ")
533
+ if answer.lower() not in ("y", "yes"):
534
+ print("Cancelled.")
535
+ return
536
+
537
+ del providers[provider]
538
+ print(f"Removed provider '{provider}' ({key_count} keys).")
539
+
540
+ save_store(store, passphrase, project_dir)
541
+
542
+
543
+ def rotate_credential(
544
+ provider: str,
545
+ index: int | None = None,
546
+ strategy: str | None = None,
547
+ passphrase: str | None = None,
548
+ project_dir: str | None = None,
549
+ ) -> None:
550
+ """Rotate the active key for a provider.
551
+
552
+ Args:
553
+ provider: Provider name
554
+ index: Specific key index to set as active (None = advance to next)
555
+ strategy: New rotation strategy (round-robin|failover|manual)
556
+ passphrase: User passphrase
557
+ project_dir: Optional project directory
558
+ """
559
+ if passphrase is None:
560
+ passphrase = _get_passphrase()
561
+
562
+ store = load_store(passphrase, project_dir)
563
+ providers = store.get("providers", {})
564
+
565
+ if provider not in providers:
566
+ print(f"Error: Provider '{provider}' not found.", file=sys.stderr)
567
+ sys.exit(1)
568
+
569
+ pdata = providers[provider]
570
+ keys = pdata.get("keys", [])
571
+ if not keys:
572
+ print(f"Error: No keys configured for '{provider}'.", file=sys.stderr)
573
+ sys.exit(1)
574
+
575
+ if strategy is not None:
576
+ valid_strategies = ("round-robin", "failover", "manual")
577
+ if strategy not in valid_strategies:
578
+ print(f"Error: Invalid strategy '{strategy}'. Choose from: {', '.join(valid_strategies)}", file=sys.stderr)
579
+ sys.exit(1)
580
+ pdata["rotation_policy"] = strategy
581
+ print(f"Set rotation strategy for '{provider}' to '{strategy}'.")
582
+
583
+ if index is not None:
584
+ if index < 0 or index >= len(keys):
585
+ print(f"Error: Index {index} out of range (0-{len(keys) - 1}).", file=sys.stderr)
586
+ sys.exit(1)
587
+ pdata["active_index"] = index
588
+ lbl = keys[index].get("label", f"key-{index}")
589
+ print(f"Set active key for '{provider}' to #{index} ('{lbl}').")
590
+ elif strategy is None:
591
+ # Advance to next (round-robin style)
592
+ current = pdata.get("active_index", 0)
593
+ new_idx = (current + 1) % len(keys)
594
+ pdata["active_index"] = new_idx
595
+ lbl = keys[new_idx].get("label", f"key-{new_idx}")
596
+ print(f"Rotated '{provider}' active key to #{new_idx} ('{lbl}').")
597
+
598
+ save_store(store, passphrase, project_dir)
599
+
600
+
601
+ # =============================================================================
602
+ # Runtime API (called by team_router.py in Task 1.9)
603
+ # =============================================================================
604
+
605
+
606
+ def get_active_key(provider: str, project_dir: str | None = None) -> str | None:
607
+ """Get the currently active API key for a provider.
608
+
609
+ Called by runtime/team_router.py (Task 1.9).
610
+ Returns None if feature disabled, provider not found, or no passphrase.
611
+ """
612
+ if not get_feature_flag("MULTI_CREDENTIAL", default=False):
613
+ return None
614
+
615
+ passphrase = os.environ.get("OMG_CREDENTIAL_PASSPHRASE")
616
+ if not passphrase:
617
+ return None
618
+
619
+ try:
620
+ store = load_store(passphrase, project_dir)
621
+ except (ValueError, OSError):
622
+ return None
623
+
624
+ providers = store.get("providers", {})
625
+ if provider not in providers:
626
+ return None
627
+
628
+ pdata = providers[provider]
629
+ keys = pdata.get("keys", [])
630
+ if not keys:
631
+ return None
632
+
633
+ active_idx = pdata.get("active_index", 0)
634
+ # Safety: clamp index
635
+ if active_idx < 0 or active_idx >= len(keys):
636
+ active_idx = 0
637
+
638
+ key_entry = keys[active_idx]
639
+
640
+ # Advisory expiry check — warn but NEVER block retrieval
641
+ try:
642
+ _warn_if_expired(provider, key_entry)
643
+ except Exception:
644
+ pass # Never let expiry check crash key retrieval
645
+
646
+ return key_entry.get("key")
647
+
648
+
649
+ def advance_key(provider: str, project_dir: str | None = None) -> None:
650
+ """Advance to next key for round-robin rotation.
651
+
652
+ Called after successful API call by team_router.py.
653
+ Updates usage_count and last_used on the current key before advancing.
654
+ """
655
+ if not get_feature_flag("MULTI_CREDENTIAL", default=False):
656
+ return
657
+
658
+ passphrase = os.environ.get("OMG_CREDENTIAL_PASSPHRASE")
659
+ if not passphrase:
660
+ return
661
+
662
+ try:
663
+ store = load_store(passphrase, project_dir)
664
+ except (ValueError, OSError):
665
+ return
666
+
667
+ providers = store.get("providers", {})
668
+ if provider not in providers:
669
+ return
670
+
671
+ pdata = providers[provider]
672
+ keys = pdata.get("keys", [])
673
+ if len(keys) <= 1:
674
+ return # Nothing to rotate
675
+
676
+ policy = pdata.get("rotation_policy", "round-robin")
677
+ if policy == "manual":
678
+ return # Don't auto-advance for manual policy
679
+
680
+ active_idx = pdata.get("active_index", 0)
681
+ if 0 <= active_idx < len(keys):
682
+ keys[active_idx]["usage_count"] = keys[active_idx].get("usage_count", 0) + 1
683
+ keys[active_idx]["last_used"] = datetime.now(timezone.utc).isoformat()
684
+
685
+ if policy == "round-robin":
686
+ pdata["active_index"] = (active_idx + 1) % len(keys)
687
+
688
+ # Failover only advances on error, not after success
689
+ try:
690
+ save_store(store, passphrase, project_dir)
691
+ except (ValueError, OSError):
692
+ pass # Best-effort; don't crash the API call
693
+
694
+
695
+ # =============================================================================
696
+ # Expiry & Rotation Schedule
697
+ # =============================================================================
698
+
699
+ # Default constants
700
+ _DEFAULT_ROTATION_SCHEDULE_DAYS = 90
701
+ _DEFAULT_EXPIRY_WARNING_DAYS = 14
702
+
703
+
704
+ def get_rotation_schedule_days() -> int:
705
+ """Get the configured rotation schedule in days.
706
+
707
+ Resolution order:
708
+ 1. settings.json → _omg.credentials.rotation_schedule_days
709
+ 2. Default: 90 days
710
+ """
711
+ try:
712
+ settings_path = os.path.join(get_project_dir(), "settings.json")
713
+ if os.path.exists(settings_path):
714
+ with open(settings_path, "r", encoding="utf-8") as f:
715
+ settings = json.load(f)
716
+ cred_cfg = settings.get("_omg", {}).get("credentials", {})
717
+ return int(cred_cfg.get("rotation_schedule_days", _DEFAULT_ROTATION_SCHEDULE_DAYS))
718
+ except (json.JSONDecodeError, OSError, TypeError, ValueError):
719
+ pass
720
+ return _DEFAULT_ROTATION_SCHEDULE_DAYS
721
+
722
+
723
+ def _get_expiry_warning_days() -> int:
724
+ """Get the configured expiry warning threshold in days (default: 14)."""
725
+ try:
726
+ settings_path = os.path.join(get_project_dir(), "settings.json")
727
+ if os.path.exists(settings_path):
728
+ with open(settings_path, "r", encoding="utf-8") as f:
729
+ settings = json.load(f)
730
+ cred_cfg = settings.get("_omg", {}).get("credentials", {})
731
+ return int(cred_cfg.get("expiry_warning_days", _DEFAULT_EXPIRY_WARNING_DAYS))
732
+ except (json.JSONDecodeError, OSError, TypeError, ValueError):
733
+ pass
734
+ return _DEFAULT_EXPIRY_WARNING_DAYS
735
+
736
+
737
+ def _parse_expiry(expires_at: str) -> datetime | None:
738
+ """Parse an ISO8601 expires_at string to datetime, or None on failure."""
739
+ try:
740
+ dt = datetime.fromisoformat(expires_at)
741
+ # Ensure timezone-aware
742
+ if dt.tzinfo is None:
743
+ dt = dt.replace(tzinfo=timezone.utc)
744
+ return dt
745
+ except (ValueError, TypeError):
746
+ return None
747
+
748
+
749
+ def check_expiry(project_dir: str) -> list[dict]:
750
+ """Check all credentials for expiry status.
751
+
752
+ Args:
753
+ project_dir: Project directory containing .omg/state/
754
+
755
+ Returns:
756
+ List of dicts with keys:
757
+ - name: provider name
758
+ - expires_at: ISO8601 string
759
+ - days_remaining: int (negative = already expired)
760
+ - status: 'expired' | 'expiring' | 'ok'
761
+
762
+ Credentials without expires_at are omitted from the report.
763
+ """
764
+ passphrase = os.environ.get("OMG_CREDENTIAL_PASSPHRASE")
765
+ if not passphrase:
766
+ return []
767
+
768
+ try:
769
+ store = load_store(passphrase, project_dir)
770
+ except (ValueError, OSError):
771
+ return []
772
+
773
+ providers = store.get("providers", {})
774
+ if not providers:
775
+ return []
776
+
777
+ now = datetime.now(timezone.utc)
778
+ warning_days = _DEFAULT_EXPIRY_WARNING_DAYS
779
+ try:
780
+ warning_days = _get_expiry_warning_days()
781
+ except Exception:
782
+ pass
783
+
784
+ results = []
785
+ for provider_name, pdata in sorted(providers.items()):
786
+ keys = pdata.get("keys", [])
787
+ active_idx = pdata.get("active_index", 0)
788
+
789
+ for i, key_entry in enumerate(keys):
790
+ expires_at_str = key_entry.get("expires_at")
791
+ if not expires_at_str:
792
+ continue
793
+
794
+ expiry_dt = _parse_expiry(expires_at_str)
795
+ if expiry_dt is None:
796
+ continue
797
+
798
+ delta = expiry_dt - now
799
+ days_remaining = int(delta.total_seconds() / 86400)
800
+
801
+ if days_remaining < 0:
802
+ status = "expired"
803
+ elif days_remaining <= warning_days:
804
+ status = "expiring"
805
+ else:
806
+ status = "ok"
807
+
808
+ label = key_entry.get("label", f"key-{i}")
809
+ results.append({
810
+ "name": provider_name,
811
+ "label": label,
812
+ "key_index": i,
813
+ "is_active": i == active_idx,
814
+ "expires_at": expires_at_str,
815
+ "days_remaining": days_remaining,
816
+ "status": status,
817
+ })
818
+
819
+ return results
820
+
821
+
822
+ def _warn_if_expired(provider: str, key_entry: dict) -> None:
823
+ """Print a warning to stderr if a key is expired or expiring. Advisory only."""
824
+ expires_at_str = key_entry.get("expires_at")
825
+ if not expires_at_str:
826
+ return
827
+
828
+ expiry_dt = _parse_expiry(expires_at_str)
829
+ if expiry_dt is None:
830
+ return
831
+
832
+ now = datetime.now(timezone.utc)
833
+ delta = expiry_dt - now
834
+ days_remaining = int(delta.total_seconds() / 86400)
835
+
836
+ if days_remaining < 0:
837
+ label = key_entry.get("label", "unknown")
838
+ print(
839
+ f"Warning: Key '{label}' for provider '{provider}' expired "
840
+ f"{abs(days_remaining)} days ago (expires_at: {expires_at_str})",
841
+ file=sys.stderr,
842
+ )
843
+ elif days_remaining <= _DEFAULT_EXPIRY_WARNING_DAYS:
844
+ label = key_entry.get("label", "unknown")
845
+ print(
846
+ f"Warning: Key '{label}' for provider '{provider}' expiring in "
847
+ f"{days_remaining} days (expires_at: {expires_at_str})",
848
+ file=sys.stderr,
849
+ )
850
+
851
+
852
+ # =============================================================================
853
+ # Passphrase Handling
854
+ # =============================================================================
855
+
856
+
857
+ def _get_passphrase() -> str:
858
+ """Get passphrase from env var or interactive prompt.
859
+
860
+ Resolution order:
861
+ 1. OMG_CREDENTIAL_PASSPHRASE env var
862
+ 2. getpass.getpass() interactive prompt (if TTY)
863
+ """
864
+ env_passphrase = os.environ.get("OMG_CREDENTIAL_PASSPHRASE")
865
+ if env_passphrase:
866
+ return env_passphrase
867
+
868
+ if not sys.stdin.isatty():
869
+ print(
870
+ "Error: No passphrase available. Set OMG_CREDENTIAL_PASSPHRASE env var "
871
+ "for non-interactive use.",
872
+ file=sys.stderr,
873
+ )
874
+ sys.exit(1)
875
+
876
+ passphrase = getpass.getpass("Credential store passphrase: ")
877
+ if len(passphrase) < MIN_PASSPHRASE_LEN:
878
+ print(
879
+ f"Warning: Passphrase is short ({len(passphrase)} chars). "
880
+ f"Recommended minimum: {MIN_PASSPHRASE_LEN} chars.",
881
+ file=sys.stderr,
882
+ )
883
+ return passphrase
884
+
885
+
886
+ # =============================================================================
887
+ # Feature Flag Gate
888
+ # =============================================================================
889
+
890
+
891
+ def _check_feature_flag() -> None:
892
+ """Verify the multi-credential feature flag is enabled."""
893
+ if not get_feature_flag("MULTI_CREDENTIAL", default=False):
894
+ print(
895
+ "Error: Multi-credential store is disabled.\n"
896
+ "Set OMG_MULTI_CREDENTIAL_ENABLED=1 to enable.",
897
+ file=sys.stderr,
898
+ )
899
+ sys.exit(1)
900
+
901
+
902
+ # =============================================================================
903
+ # CLI Interface
904
+ # =============================================================================
905
+
906
+
907
+ def _build_parser() -> argparse.ArgumentParser:
908
+ """Build the CLI argument parser."""
909
+ parser = argparse.ArgumentParser(
910
+ prog="omg-creds",
911
+ description="Multi-credential encrypted store for OMG.",
912
+ epilog=(
913
+ "environment:\n"
914
+ " OMG_MULTI_CREDENTIAL_ENABLED=1 Required to enable credential store\n"
915
+ " OMG_CREDENTIAL_PASSPHRASE Passphrase for non-interactive use\n"
916
+ "\n"
917
+ "examples:\n"
918
+ " %(prog)s add --provider anthropic --key sk-ant-xxx\n"
919
+ " %(prog)s add --provider openai --key sk-proj-xxx --label backup\n"
920
+ " %(prog)s list\n"
921
+ " %(prog)s list --provider anthropic\n"
922
+ " %(prog)s remove --provider anthropic --index 1\n"
923
+ " %(prog)s rotate --provider anthropic\n"
924
+ " %(prog)s rotate --provider openai --strategy failover"
925
+ ),
926
+ formatter_class=argparse.RawDescriptionHelpFormatter,
927
+ )
928
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
929
+
930
+ # add
931
+ add_p = subparsers.add_parser("add", help="Add an API key for a provider")
932
+ add_p.add_argument("--provider", required=True, help="Provider name (e.g., anthropic, openai)")
933
+ add_p.add_argument("--key", required=True, help="API key value")
934
+ add_p.add_argument("--label", default=None, help="Human-readable label (default: key-N)")
935
+
936
+ # list
937
+ list_p = subparsers.add_parser("list", help="List providers and key metadata")
938
+ list_p.add_argument("--provider", default=None, help="Filter to specific provider (requires passphrase)")
939
+
940
+ # remove
941
+ rm_p = subparsers.add_parser("remove", help="Remove a key or provider")
942
+ rm_p.add_argument("--provider", required=True, help="Provider name")
943
+ rm_p.add_argument("--index", type=int, default=None, help="Key index to remove (omit to remove entire provider)")
944
+ rm_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
945
+
946
+ # rotate
947
+ rot_p = subparsers.add_parser("rotate", help="Rotate active key or set rotation strategy")
948
+ rot_p.add_argument("--provider", required=True, help="Provider name")
949
+ rot_p.add_argument("--index", type=int, default=None, help="Set specific key index as active")
950
+ rot_p.add_argument("--strategy", default=None, choices=["round-robin", "failover", "manual"], help="Set rotation strategy")
951
+
952
+ return parser
953
+
954
+
955
+ def main() -> None:
956
+ """CLI entry point."""
957
+ parser = _build_parser()
958
+ args = parser.parse_args()
959
+
960
+ if not args.command:
961
+ parser.print_help()
962
+ sys.exit(0)
963
+
964
+ # Feature flag gate
965
+ _check_feature_flag()
966
+
967
+ if args.command == "add":
968
+ passphrase = _get_passphrase()
969
+ add_credential(
970
+ provider=args.provider.lower().strip(),
971
+ key=args.key,
972
+ passphrase=passphrase,
973
+ label=args.label,
974
+ )
975
+ # Best-effort cleanup
976
+ del passphrase
977
+ gc.collect()
978
+
979
+ elif args.command == "list":
980
+ if args.provider:
981
+ passphrase = _get_passphrase()
982
+ list_credentials(
983
+ passphrase=passphrase,
984
+ provider_filter=args.provider.lower().strip(),
985
+ )
986
+ del passphrase
987
+ gc.collect()
988
+ else:
989
+ # Try without passphrase first (metadata only)
990
+ list_credentials(passphrase=None)
991
+
992
+ elif args.command == "remove":
993
+ passphrase = _get_passphrase()
994
+ remove_credential(
995
+ provider=args.provider.lower().strip(),
996
+ index=args.index,
997
+ passphrase=passphrase,
998
+ confirm=not args.yes,
999
+ )
1000
+ del passphrase
1001
+ gc.collect()
1002
+
1003
+ elif args.command == "rotate":
1004
+ passphrase = _get_passphrase()
1005
+ rotate_credential(
1006
+ provider=args.provider.lower().strip(),
1007
+ index=args.index,
1008
+ strategy=args.strategy,
1009
+ passphrase=passphrase,
1010
+ )
1011
+ del passphrase
1012
+ gc.collect()
1013
+
1014
+ else:
1015
+ parser.print_help()
1016
+ sys.exit(1)
1017
+
1018
+
1019
+ if __name__ == "__main__":
1020
+ main()