@trac3er/oh-my-god 1.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 (229) hide show
  1. package/.claude-plugin/marketplace.json +36 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.claude-plugin/scripts/install.sh +49 -0
  4. package/.claude-plugin/scripts/uninstall.sh +80 -0
  5. package/.claude-plugin/scripts/update.sh +84 -0
  6. package/.mcp.json +20 -0
  7. package/LICENSE +21 -0
  8. package/OMG-setup.sh +1093 -0
  9. package/README.md +335 -0
  10. package/THIRD_PARTY_NOTICES.md +24 -0
  11. package/UPSTREAM_DIFF.md +20 -0
  12. package/agents/__init__.py +1 -0
  13. package/agents/_model_roles.yaml +26 -0
  14. package/agents/designer.md +67 -0
  15. package/agents/explore.md +60 -0
  16. package/agents/model_roles.py +196 -0
  17. package/agents/omg-api-builder.md +23 -0
  18. package/agents/omg-architect-mode.md +43 -0
  19. package/agents/omg-architect.md +13 -0
  20. package/agents/omg-backend-engineer.md +43 -0
  21. package/agents/omg-critic.md +16 -0
  22. package/agents/omg-database-engineer.md +43 -0
  23. package/agents/omg-escalation-router.md +17 -0
  24. package/agents/omg-executor.md +12 -0
  25. package/agents/omg-frontend-designer.md +42 -0
  26. package/agents/omg-implement-mode.md +50 -0
  27. package/agents/omg-infra-engineer.md +43 -0
  28. package/agents/omg-qa-tester.md +16 -0
  29. package/agents/omg-research-mode.md +43 -0
  30. package/agents/omg-security-auditor.md +43 -0
  31. package/agents/omg-testing-engineer.md +43 -0
  32. package/agents/plan.md +80 -0
  33. package/agents/quick_task.md +64 -0
  34. package/agents/reviewer.md +83 -0
  35. package/agents/task.md +71 -0
  36. package/commands/OMG:ccg.md +22 -0
  37. package/commands/OMG:compat.md +57 -0
  38. package/commands/OMG:crazy.md +125 -0
  39. package/commands/OMG:domain-init.md +11 -0
  40. package/commands/OMG:escalate.md +52 -0
  41. package/commands/OMG:health-check.md +45 -0
  42. package/commands/OMG:init.md +134 -0
  43. package/commands/OMG:mode.md +44 -0
  44. package/commands/OMG:project-init.md +11 -0
  45. package/commands/OMG:ralph-start.md +43 -0
  46. package/commands/OMG:ralph-stop.md +23 -0
  47. package/commands/OMG:teams.md +39 -0
  48. package/commands/ai-commit.md +113 -0
  49. package/commands/ccg.md +9 -0
  50. package/commands/create-agent.md +183 -0
  51. package/commands/omc-teams.md +9 -0
  52. package/commands/session-branch.md +85 -0
  53. package/commands/session-fork.md +53 -0
  54. package/commands/session-merge.md +134 -0
  55. package/commands/theme.md +44 -0
  56. package/config/lsp_languages.yaml +324 -0
  57. package/config/themes/catppuccin-frappe.yaml +14 -0
  58. package/config/themes/catppuccin-latte.yaml +14 -0
  59. package/config/themes/catppuccin-macchiato.yaml +14 -0
  60. package/config/themes/catppuccin-mocha.yaml +14 -0
  61. package/config/themes/dracula.yaml +14 -0
  62. package/config/themes/gruvbox-dark.yaml +14 -0
  63. package/config/themes/nord.yaml +14 -0
  64. package/config/themes/one-dark.yaml +14 -0
  65. package/config/themes/solarized-dark.yaml +14 -0
  66. package/config/themes/tokyo-night.yaml +14 -0
  67. package/control_plane/__init__.py +2 -0
  68. package/control_plane/openapi.yaml +109 -0
  69. package/control_plane/server.py +107 -0
  70. package/control_plane/service.py +148 -0
  71. package/crates/omg-natives/Cargo.toml +17 -0
  72. package/crates/omg-natives/src/clipboard.rs +5 -0
  73. package/crates/omg-natives/src/glob.rs +15 -0
  74. package/crates/omg-natives/src/grep.rs +15 -0
  75. package/crates/omg-natives/src/highlight.rs +15 -0
  76. package/crates/omg-natives/src/html.rs +14 -0
  77. package/crates/omg-natives/src/image.rs +5 -0
  78. package/crates/omg-natives/src/keys.rs +5 -0
  79. package/crates/omg-natives/src/lib.rs +36 -0
  80. package/crates/omg-natives/src/prof.rs +5 -0
  81. package/crates/omg-natives/src/ps.rs +5 -0
  82. package/crates/omg-natives/src/shell.rs +5 -0
  83. package/crates/omg-natives/src/task.rs +5 -0
  84. package/crates/omg-natives/src/text.rs +14 -0
  85. package/hooks/_agent_registry.py +421 -0
  86. package/hooks/_budget.py +31 -0
  87. package/hooks/_common.py +476 -0
  88. package/hooks/_learnings.py +126 -0
  89. package/hooks/_memory.py +103 -0
  90. package/hooks/circuit-breaker.py +270 -0
  91. package/hooks/config-guard.py +163 -0
  92. package/hooks/context_pressure.py +53 -0
  93. package/hooks/credential_store.py +801 -0
  94. package/hooks/fetch-rate-limits.py +212 -0
  95. package/hooks/firewall.py +48 -0
  96. package/hooks/hashline-formatter-bridge.py +224 -0
  97. package/hooks/hashline-injector.py +273 -0
  98. package/hooks/hashline-validator.py +216 -0
  99. package/hooks/idle-detector.py +95 -0
  100. package/hooks/intentgate-keyword-detector.py +188 -0
  101. package/hooks/magic-keyword-router.py +195 -0
  102. package/hooks/policy_engine.py +310 -0
  103. package/hooks/post-tool-failure.py +19 -0
  104. package/hooks/post-write.py +199 -0
  105. package/hooks/pre-compact.py +204 -0
  106. package/hooks/pre-tool-inject.py +98 -0
  107. package/hooks/prompt-enhancer.py +672 -0
  108. package/hooks/quality-runner.py +191 -0
  109. package/hooks/secret-guard.py +47 -0
  110. package/hooks/session-end-capture.py +137 -0
  111. package/hooks/session-start.py +275 -0
  112. package/hooks/shadow_manager.py +297 -0
  113. package/hooks/state_migration.py +209 -0
  114. package/hooks/stop-gate.py +7 -0
  115. package/hooks/stop_dispatcher.py +929 -0
  116. package/hooks/test-validator.py +138 -0
  117. package/hooks/todo-state-tracker.py +114 -0
  118. package/hooks/tool-ledger.py +126 -0
  119. package/hooks/trust_review.py +524 -0
  120. package/install.sh +9 -0
  121. package/omg_natives/__init__.py +186 -0
  122. package/omg_natives/_bindings.py +165 -0
  123. package/omg_natives/clipboard.py +36 -0
  124. package/omg_natives/glob.py +42 -0
  125. package/omg_natives/grep.py +61 -0
  126. package/omg_natives/highlight.py +54 -0
  127. package/omg_natives/html.py +157 -0
  128. package/omg_natives/image.py +51 -0
  129. package/omg_natives/keys.py +46 -0
  130. package/omg_natives/prof.py +39 -0
  131. package/omg_natives/ps.py +93 -0
  132. package/omg_natives/shell.py +58 -0
  133. package/omg_natives/task.py +41 -0
  134. package/omg_natives/text.py +50 -0
  135. package/package.json +26 -0
  136. package/plugins/README.md +82 -0
  137. package/plugins/advanced/commands/OMG:code-review.md +114 -0
  138. package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
  139. package/plugins/advanced/commands/OMG:handoff.md +115 -0
  140. package/plugins/advanced/commands/OMG:learn.md +110 -0
  141. package/plugins/advanced/commands/OMG:maintainer.md +31 -0
  142. package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
  143. package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
  144. package/plugins/advanced/commands/OMG:security-review.md +119 -0
  145. package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
  146. package/plugins/advanced/commands/OMG:ship.md +46 -0
  147. package/plugins/advanced/plugin.json +96 -0
  148. package/plugins/core/plugin.json +82 -0
  149. package/pytest.ini +5 -0
  150. package/registry/__init__.py +1 -0
  151. package/registry/verify_artifact.py +90 -0
  152. package/rules/contextual/architect-mode.md +9 -0
  153. package/rules/contextual/big-picture.md +20 -0
  154. package/rules/contextual/code-hygiene.md +26 -0
  155. package/rules/contextual/context-management.md +19 -0
  156. package/rules/contextual/context-minimization.md +32 -0
  157. package/rules/contextual/ddd-sdd.md +28 -0
  158. package/rules/contextual/dependency-safety.md +16 -0
  159. package/rules/contextual/doc-check.md +13 -0
  160. package/rules/contextual/implement-mode.md +9 -0
  161. package/rules/contextual/infra-safety.md +14 -0
  162. package/rules/contextual/outside-in.md +13 -0
  163. package/rules/contextual/persistent-mode.md +24 -0
  164. package/rules/contextual/research-mode.md +9 -0
  165. package/rules/contextual/security-domains.md +25 -0
  166. package/rules/contextual/vision-detection.md +27 -0
  167. package/rules/contextual/web-search.md +25 -0
  168. package/rules/contextual/write-verify.md +23 -0
  169. package/rules/core/00-truth.md +20 -0
  170. package/rules/core/01-surgical.md +19 -0
  171. package/rules/core/02-circuit-breaker.md +22 -0
  172. package/rules/core/03-ensemble.md +28 -0
  173. package/rules/core/04-testing.md +30 -0
  174. package/runtime/__init__.py +32 -0
  175. package/runtime/adapters/__init__.py +13 -0
  176. package/runtime/adapters/claude.py +60 -0
  177. package/runtime/adapters/gpt.py +53 -0
  178. package/runtime/adapters/local.py +53 -0
  179. package/runtime/business_workflow.py +220 -0
  180. package/runtime/compat.py +1299 -0
  181. package/runtime/custom_agent_loader.py +366 -0
  182. package/runtime/dispatcher.py +47 -0
  183. package/runtime/ecosystem.py +371 -0
  184. package/runtime/legacy_compat.py +7 -0
  185. package/runtime/omc_compat.py +7 -0
  186. package/runtime/omc_contract_snapshot.json +916 -0
  187. package/runtime/omg_compat_contract_snapshot.json +916 -0
  188. package/runtime/subagent_dispatcher.py +362 -0
  189. package/runtime/team_router.py +838 -0
  190. package/scripts/check-omc-contract-snapshot.py +12 -0
  191. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  192. package/scripts/check-omg-standalone-clean.py +102 -0
  193. package/scripts/legacy_to_omg_migrate.py +29 -0
  194. package/scripts/migrate-omc.py +464 -0
  195. package/scripts/omc_to_omg_migrate.py +12 -0
  196. package/scripts/omg.py +493 -0
  197. package/scripts/settings-merge.py +224 -0
  198. package/scripts/verify-no-omc.sh +5 -0
  199. package/scripts/verify-standalone.sh +21 -0
  200. package/templates/idea.yml +30 -0
  201. package/templates/policy.yaml +15 -0
  202. package/templates/profile.yaml +25 -0
  203. package/templates/runtime.yaml +12 -0
  204. package/templates/working-memory.md +17 -0
  205. package/tools/__init__.py +2 -0
  206. package/tools/browser_consent.py +289 -0
  207. package/tools/browser_stealth.py +481 -0
  208. package/tools/browser_tool.py +448 -0
  209. package/tools/changelog_generator.py +268 -0
  210. package/tools/commit_splitter.py +361 -0
  211. package/tools/config_discovery.py +151 -0
  212. package/tools/config_merger.py +449 -0
  213. package/tools/git_inspector.py +298 -0
  214. package/tools/lsp_client.py +275 -0
  215. package/tools/lsp_discovery.py +231 -0
  216. package/tools/lsp_operations.py +392 -0
  217. package/tools/python_repl.py +656 -0
  218. package/tools/python_sandbox.py +609 -0
  219. package/tools/search_providers/__init__.py +77 -0
  220. package/tools/search_providers/brave.py +115 -0
  221. package/tools/search_providers/exa.py +116 -0
  222. package/tools/search_providers/jina.py +104 -0
  223. package/tools/search_providers/perplexity.py +139 -0
  224. package/tools/search_providers/synthetic.py +74 -0
  225. package/tools/session_snapshot.py +736 -0
  226. package/tools/ssh_manager.py +912 -0
  227. package/tools/theme_engine.py +294 -0
  228. package/tools/theme_selector.py +137 -0
  229. package/tools/web_search.py +622 -0
@@ -0,0 +1,801 @@
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 ref: .sisyphus/credential-store-design.md
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import base64
17
+ import gc
18
+ import getpass
19
+ import json
20
+ import os
21
+ import sys
22
+ from datetime import datetime, timezone
23
+ from typing import Any
24
+
25
+ # --- Ensure hooks dir is on sys.path for _common imports ---
26
+ HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
27
+ if HOOKS_DIR not in sys.path:
28
+ sys.path.insert(0, HOOKS_DIR)
29
+
30
+ from _common import (
31
+ atomic_json_write,
32
+ get_feature_flag,
33
+ get_project_dir,
34
+ setup_crash_handler,
35
+ )
36
+
37
+ # Fail-closed: security-critical module
38
+ setup_crash_handler("credential_store", fail_closed=True)
39
+
40
+ # --- Lazy-loaded cryptography imports ---
41
+ _Fernet = None
42
+ _PBKDF2HMAC = None
43
+ _hashes = None
44
+
45
+
46
+ def _ensure_crypto():
47
+ """Lazily import cryptography; fail with clear message if missing."""
48
+ global _Fernet, _PBKDF2HMAC, _hashes
49
+ if _Fernet is not None:
50
+ return
51
+ try:
52
+ from cryptography.fernet import Fernet
53
+ from cryptography.hazmat.primitives import hashes
54
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
55
+
56
+ _Fernet = Fernet
57
+ _PBKDF2HMAC = PBKDF2HMAC
58
+ _hashes = hashes
59
+ except ImportError:
60
+ print(
61
+ "Error: 'cryptography' library required. Install with: pip install cryptography",
62
+ file=sys.stderr,
63
+ )
64
+ sys.exit(1)
65
+
66
+
67
+ # --- Constants ---
68
+ CREDENTIALS_ENC = "credentials.enc"
69
+ CREDENTIALS_META = "credentials.meta"
70
+ STATE_DIR = os.path.join(".omg", "state")
71
+ KDF_ITERATIONS = 600_000
72
+ SALT_BYTES = 16
73
+ MIN_PASSPHRASE_LEN = 8
74
+
75
+ # Default empty store schema
76
+ _EMPTY_STORE = {"version": 1, "providers": {}}
77
+
78
+
79
+ # =============================================================================
80
+ # Core Crypto Functions
81
+ # =============================================================================
82
+
83
+
84
+ def derive_key(passphrase: bytes, salt: bytes, kdf_config: dict | None = None) -> bytes:
85
+ """Derive a Fernet key from passphrase using PBKDF2HMAC.
86
+
87
+ Args:
88
+ passphrase: Raw passphrase bytes
89
+ salt: 16-byte random salt
90
+ kdf_config: Optional dict with 'iterations' override
91
+
92
+ Returns:
93
+ URL-safe base64-encoded 32-byte key suitable for Fernet
94
+ """
95
+ _ensure_crypto()
96
+ iterations = KDF_ITERATIONS
97
+ if kdf_config and "iterations" in kdf_config:
98
+ iterations = int(kdf_config["iterations"])
99
+
100
+ kdf = _PBKDF2HMAC(
101
+ algorithm=_hashes.SHA256(),
102
+ length=32,
103
+ salt=salt,
104
+ iterations=iterations,
105
+ )
106
+ return base64.urlsafe_b64encode(kdf.derive(passphrase))
107
+
108
+
109
+ def encrypt_store(data: dict, key: bytes) -> bytes:
110
+ """Encrypt credential store payload with Fernet.
111
+
112
+ Args:
113
+ data: Credential store dict to encrypt
114
+ key: Fernet key (from derive_key)
115
+
116
+ Returns:
117
+ Fernet token bytes
118
+ """
119
+ _ensure_crypto()
120
+ f = _Fernet(key)
121
+ payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
122
+ return f.encrypt(payload)
123
+
124
+
125
+ def decrypt_store(token: bytes, key: bytes) -> dict:
126
+ """Decrypt credential store payload.
127
+
128
+ Args:
129
+ token: Fernet token bytes
130
+ key: Fernet key (from derive_key)
131
+
132
+ Returns:
133
+ Decrypted credential store dict
134
+
135
+ Raises:
136
+ ValueError: If passphrase is wrong (Fernet InvalidToken)
137
+ """
138
+ _ensure_crypto()
139
+ from cryptography.fernet import InvalidToken
140
+
141
+ f = _Fernet(key)
142
+ try:
143
+ plaintext = f.decrypt(token)
144
+ except InvalidToken:
145
+ raise ValueError("Decryption failed: wrong passphrase or corrupted store")
146
+ return json.loads(plaintext.decode("utf-8"))
147
+
148
+
149
+ # =============================================================================
150
+ # Store I/O
151
+ # =============================================================================
152
+
153
+
154
+ def _get_store_paths(project_dir: str | None = None) -> tuple[str, str]:
155
+ """Return (enc_path, meta_path) for the credential store."""
156
+ pdir = project_dir or get_project_dir()
157
+ state_dir = os.path.join(pdir, STATE_DIR)
158
+ return (
159
+ os.path.join(state_dir, CREDENTIALS_ENC),
160
+ os.path.join(state_dir, CREDENTIALS_META),
161
+ )
162
+
163
+
164
+ def _load_meta(meta_path: str) -> dict:
165
+ """Load metadata file or return default."""
166
+ if not os.path.exists(meta_path):
167
+ return {}
168
+ try:
169
+ with open(meta_path, "r", encoding="utf-8") as f:
170
+ return json.load(f)
171
+ except (json.JSONDecodeError, OSError):
172
+ return {}
173
+
174
+
175
+ def _save_meta(meta_path: str, meta: dict) -> None:
176
+ """Save metadata via atomic write."""
177
+ atomic_json_write(meta_path, meta)
178
+
179
+
180
+ def _create_new_meta(salt: bytes) -> dict:
181
+ """Create initial metadata structure."""
182
+ return {
183
+ "version": 1,
184
+ "kdf": "pbkdf2-sha256",
185
+ "kdf_params": {
186
+ "iterations": KDF_ITERATIONS,
187
+ "salt_b64": base64.b64encode(salt).decode("ascii"),
188
+ },
189
+ "created_at": datetime.now(timezone.utc).isoformat(),
190
+ "updated_at": datetime.now(timezone.utc).isoformat(),
191
+ "providers": [],
192
+ }
193
+
194
+
195
+ def load_store(passphrase: str, project_dir: str | None = None) -> dict:
196
+ """Load and decrypt the credential store. Creates new if missing.
197
+
198
+ Args:
199
+ passphrase: User passphrase string
200
+ project_dir: Optional project directory override
201
+
202
+ Returns:
203
+ Decrypted store dict
204
+ """
205
+ enc_path, meta_path = _get_store_paths(project_dir)
206
+ passphrase_bytes = passphrase.encode("utf-8")
207
+
208
+ if not os.path.exists(enc_path):
209
+ # New store — return empty
210
+ return dict(_EMPTY_STORE)
211
+
212
+ meta = _load_meta(meta_path)
213
+ if not meta:
214
+ raise ValueError("Metadata file missing or corrupted; cannot derive key")
215
+
216
+ salt = base64.b64decode(meta["kdf_params"]["salt_b64"])
217
+ kdf_config = meta.get("kdf_params", {})
218
+ key = derive_key(passphrase_bytes, salt, kdf_config)
219
+
220
+ with open(enc_path, "rb") as f:
221
+ token = f.read()
222
+
223
+ store = decrypt_store(token, key)
224
+
225
+ # Best-effort memory cleanup
226
+ del passphrase_bytes
227
+ del key
228
+ gc.collect()
229
+
230
+ return store
231
+
232
+
233
+ def save_store(data: dict, passphrase: str, project_dir: str | None = None) -> None:
234
+ """Encrypt and atomically write the credential store.
235
+
236
+ Args:
237
+ data: Credential store dict to save
238
+ passphrase: User passphrase string
239
+ project_dir: Optional project directory override
240
+ """
241
+ enc_path, meta_path = _get_store_paths(project_dir)
242
+ passphrase_bytes = passphrase.encode("utf-8")
243
+
244
+ # Ensure state directory exists
245
+ state_dir = os.path.dirname(enc_path)
246
+ os.makedirs(state_dir, exist_ok=True)
247
+
248
+ meta = _load_meta(meta_path)
249
+
250
+ if not meta:
251
+ # First save — create new salt and metadata
252
+ salt = os.urandom(SALT_BYTES)
253
+ meta = _create_new_meta(salt)
254
+ else:
255
+ salt = base64.b64decode(meta["kdf_params"]["salt_b64"])
256
+
257
+ kdf_config = meta.get("kdf_params", {})
258
+ key = derive_key(passphrase_bytes, salt, kdf_config)
259
+ token = encrypt_store(data, key)
260
+
261
+ # Atomic write for encrypted store (temp + rename)
262
+ tmp_path = enc_path + ".tmp"
263
+ with open(tmp_path, "wb") as f:
264
+ f.write(token)
265
+ os.rename(tmp_path, enc_path)
266
+
267
+ # Update metadata (provider list only — no keys)
268
+ meta["updated_at"] = datetime.now(timezone.utc).isoformat()
269
+ meta["providers"] = sorted(data.get("providers", {}).keys())
270
+ _save_meta(meta_path, meta)
271
+
272
+ # Best-effort memory cleanup
273
+ del passphrase_bytes
274
+ del key
275
+ del token
276
+ gc.collect()
277
+
278
+
279
+ # =============================================================================
280
+ # Credential Operations
281
+ # =============================================================================
282
+
283
+
284
+ def add_credential(
285
+ provider: str,
286
+ key: str,
287
+ passphrase: str,
288
+ label: str | None = None,
289
+ project_dir: str | None = None,
290
+ ) -> None:
291
+ """Add an API key for a provider.
292
+
293
+ Args:
294
+ provider: Provider name (lowercase, alphanumeric + hyphens)
295
+ key: API key value (NEVER logged)
296
+ passphrase: User passphrase
297
+ label: Optional human-readable label
298
+ project_dir: Optional project directory
299
+ """
300
+ store = load_store(passphrase, project_dir)
301
+
302
+ if "providers" not in store:
303
+ store["providers"] = {}
304
+
305
+ if provider not in store["providers"]:
306
+ store["providers"][provider] = {
307
+ "keys": [],
308
+ "active_index": 0,
309
+ "rotation_policy": "round-robin",
310
+ }
311
+
312
+ provider_data = store["providers"][provider]
313
+ existing_keys = provider_data["keys"]
314
+
315
+ # Duplicate detection: compare last 8 chars only (avoid logging full key)
316
+ key_suffix = key[-8:] if len(key) >= 8 else key
317
+ for i, existing in enumerate(existing_keys):
318
+ existing_suffix = existing["key"][-8:] if len(existing["key"]) >= 8 else existing["key"]
319
+ if existing_suffix == key_suffix:
320
+ print(
321
+ f"Warning: Key ending in ...{key_suffix[-4:]} may already exist at index {i} for {provider}",
322
+ file=sys.stderr,
323
+ )
324
+ break
325
+
326
+ index = len(existing_keys)
327
+ if label is None:
328
+ label = f"key-{index}"
329
+
330
+ existing_keys.append(
331
+ {
332
+ "key": key,
333
+ "label": label,
334
+ "added": datetime.now(timezone.utc).isoformat(),
335
+ "last_used": None,
336
+ "usage_count": 0,
337
+ }
338
+ )
339
+
340
+ # First key sets active_index
341
+ if index == 0:
342
+ provider_data["active_index"] = 0
343
+
344
+ save_store(store, passphrase, project_dir)
345
+ print(f"Added key '{label}' for provider '{provider}' at index {index}")
346
+
347
+
348
+ def list_credentials(
349
+ passphrase: str | None = None,
350
+ provider_filter: str | None = None,
351
+ project_dir: str | None = None,
352
+ ) -> dict[str, int]:
353
+ """List providers and key metadata.
354
+
355
+ Without passphrase: reads metadata only (provider names).
356
+ With passphrase: shows labels and usage stats (never keys).
357
+
358
+ Args:
359
+ passphrase: Optional passphrase for detailed view
360
+ provider_filter: Optional provider name to filter
361
+ project_dir: Optional project directory
362
+
363
+ Returns:
364
+ Dict of provider name → key count
365
+ """
366
+ _, meta_path = _get_store_paths(project_dir)
367
+ meta = _load_meta(meta_path)
368
+
369
+ if not meta or not meta.get("providers"):
370
+ print("No credentials configured.")
371
+ return {}
372
+
373
+ if passphrase and provider_filter:
374
+ # Detailed view for specific provider
375
+ store = load_store(passphrase, project_dir)
376
+ providers = store.get("providers", {})
377
+
378
+ if provider_filter not in providers:
379
+ print(f"Provider '{provider_filter}' not found.")
380
+ return {}
381
+
382
+ pdata = providers[provider_filter]
383
+ active_idx = pdata.get("active_index", 0)
384
+ policy = pdata.get("rotation_policy", "round-robin")
385
+ keys = pdata.get("keys", [])
386
+
387
+ print(f"Provider: {provider_filter} (rotation: {policy})")
388
+ for i, k in enumerate(keys):
389
+ active_marker = " [ACTIVE]" if i == active_idx else ""
390
+ last_used = k.get("last_used") or "never"
391
+ if last_used != "never":
392
+ last_used = last_used[:10] # Date only
393
+ added = (k.get("added") or "")[:10]
394
+ usage = k.get("usage_count", 0)
395
+ lbl = k.get("label", f"key-{i}")
396
+ print(f" [{i}] {lbl:<12} added={added} last_used={last_used} usage={usage}{active_marker}")
397
+
398
+ return {provider_filter: len(keys)}
399
+
400
+ # Summary view from metadata only
401
+ result = {}
402
+ if passphrase:
403
+ # Can decrypt to get key counts
404
+ store = load_store(passphrase, project_dir)
405
+ providers = store.get("providers", {})
406
+ for name in sorted(providers.keys()):
407
+ pdata = providers[name]
408
+ count = len(pdata.get("keys", []))
409
+ active = pdata.get("active_index", 0)
410
+ print(f"Provider: {name} ({count} keys, active: #{active})")
411
+ result[name] = count
412
+ else:
413
+ # Metadata only (no decryption)
414
+ for name in sorted(meta.get("providers", [])):
415
+ print(f"Provider: {name}")
416
+ result[name] = -1 # Count unknown without decryption
417
+
418
+ return result
419
+
420
+
421
+ def remove_credential(
422
+ provider: str,
423
+ index: int | None = None,
424
+ passphrase: str | None = None,
425
+ project_dir: str | None = None,
426
+ confirm: bool = True,
427
+ ) -> None:
428
+ """Remove a key or entire provider.
429
+
430
+ Args:
431
+ provider: Provider name
432
+ index: Key index to remove (None = remove entire provider)
433
+ passphrase: User passphrase
434
+ project_dir: Optional project directory
435
+ confirm: Whether to prompt for confirmation
436
+ """
437
+ if passphrase is None:
438
+ passphrase = _get_passphrase()
439
+
440
+ store = load_store(passphrase, project_dir)
441
+ providers = store.get("providers", {})
442
+
443
+ if provider not in providers:
444
+ print(f"Error: Provider '{provider}' not found.", file=sys.stderr)
445
+ sys.exit(1)
446
+
447
+ if index is not None:
448
+ # Remove specific key
449
+ keys = providers[provider].get("keys", [])
450
+ if index < 0 or index >= len(keys):
451
+ print(f"Error: Index {index} out of range (0-{len(keys) - 1}).", file=sys.stderr)
452
+ sys.exit(1)
453
+
454
+ lbl = keys[index].get("label", f"key-{index}")
455
+ if confirm:
456
+ answer = input(f"Remove key #{index} ('{lbl}') from {provider}? [y/N] ")
457
+ if answer.lower() not in ("y", "yes"):
458
+ print("Cancelled.")
459
+ return
460
+
461
+ keys.pop(index)
462
+
463
+ # Reset active_index if needed
464
+ active_idx = providers[provider].get("active_index", 0)
465
+ if active_idx >= len(keys):
466
+ providers[provider]["active_index"] = 0
467
+
468
+ if not keys:
469
+ # No keys left — remove entire provider
470
+ del providers[provider]
471
+ print(f"Removed last key from '{provider}'; provider removed.")
472
+ else:
473
+ print(f"Removed key #{index} ('{lbl}') from '{provider}'.")
474
+ else:
475
+ # Remove entire provider
476
+ key_count = len(providers[provider].get("keys", []))
477
+ if confirm:
478
+ answer = input(f"Remove provider '{provider}' ({key_count} keys)? [y/N] ")
479
+ if answer.lower() not in ("y", "yes"):
480
+ print("Cancelled.")
481
+ return
482
+
483
+ del providers[provider]
484
+ print(f"Removed provider '{provider}' ({key_count} keys).")
485
+
486
+ save_store(store, passphrase, project_dir)
487
+
488
+
489
+ def rotate_credential(
490
+ provider: str,
491
+ index: int | None = None,
492
+ strategy: str | None = None,
493
+ passphrase: str | None = None,
494
+ project_dir: str | None = None,
495
+ ) -> None:
496
+ """Rotate the active key for a provider.
497
+
498
+ Args:
499
+ provider: Provider name
500
+ index: Specific key index to set as active (None = advance to next)
501
+ strategy: New rotation strategy (round-robin|failover|manual)
502
+ passphrase: User passphrase
503
+ project_dir: Optional project directory
504
+ """
505
+ if passphrase is None:
506
+ passphrase = _get_passphrase()
507
+
508
+ store = load_store(passphrase, project_dir)
509
+ providers = store.get("providers", {})
510
+
511
+ if provider not in providers:
512
+ print(f"Error: Provider '{provider}' not found.", file=sys.stderr)
513
+ sys.exit(1)
514
+
515
+ pdata = providers[provider]
516
+ keys = pdata.get("keys", [])
517
+ if not keys:
518
+ print(f"Error: No keys configured for '{provider}'.", file=sys.stderr)
519
+ sys.exit(1)
520
+
521
+ if strategy is not None:
522
+ valid_strategies = ("round-robin", "failover", "manual")
523
+ if strategy not in valid_strategies:
524
+ print(f"Error: Invalid strategy '{strategy}'. Choose from: {', '.join(valid_strategies)}", file=sys.stderr)
525
+ sys.exit(1)
526
+ pdata["rotation_policy"] = strategy
527
+ print(f"Set rotation strategy for '{provider}' to '{strategy}'.")
528
+
529
+ if index is not None:
530
+ if index < 0 or index >= len(keys):
531
+ print(f"Error: Index {index} out of range (0-{len(keys) - 1}).", file=sys.stderr)
532
+ sys.exit(1)
533
+ pdata["active_index"] = index
534
+ lbl = keys[index].get("label", f"key-{index}")
535
+ print(f"Set active key for '{provider}' to #{index} ('{lbl}').")
536
+ elif strategy is None:
537
+ # Advance to next (round-robin style)
538
+ current = pdata.get("active_index", 0)
539
+ new_idx = (current + 1) % len(keys)
540
+ pdata["active_index"] = new_idx
541
+ lbl = keys[new_idx].get("label", f"key-{new_idx}")
542
+ print(f"Rotated '{provider}' active key to #{new_idx} ('{lbl}').")
543
+
544
+ save_store(store, passphrase, project_dir)
545
+
546
+
547
+ # =============================================================================
548
+ # Runtime API (called by team_router.py in Task 1.9)
549
+ # =============================================================================
550
+
551
+
552
+ def get_active_key(provider: str, project_dir: str | None = None) -> str | None:
553
+ """Get the currently active API key for a provider.
554
+
555
+ Called by runtime/team_router.py (Task 1.9).
556
+ Returns None if feature disabled, provider not found, or no passphrase.
557
+ """
558
+ if not get_feature_flag("MULTI_CREDENTIAL", default=False):
559
+ return None
560
+
561
+ passphrase = os.environ.get("OMG_CREDENTIAL_PASSPHRASE")
562
+ if not passphrase:
563
+ return None
564
+
565
+ try:
566
+ store = load_store(passphrase, project_dir)
567
+ except (ValueError, OSError):
568
+ return None
569
+
570
+ providers = store.get("providers", {})
571
+ if provider not in providers:
572
+ return None
573
+
574
+ pdata = providers[provider]
575
+ keys = pdata.get("keys", [])
576
+ if not keys:
577
+ return None
578
+
579
+ active_idx = pdata.get("active_index", 0)
580
+ # Safety: clamp index
581
+ if active_idx < 0 or active_idx >= len(keys):
582
+ active_idx = 0
583
+
584
+ return keys[active_idx].get("key")
585
+
586
+
587
+ def advance_key(provider: str, project_dir: str | None = None) -> None:
588
+ """Advance to next key for round-robin rotation.
589
+
590
+ Called after successful API call by team_router.py.
591
+ Updates usage_count and last_used on the current key before advancing.
592
+ """
593
+ if not get_feature_flag("MULTI_CREDENTIAL", default=False):
594
+ return
595
+
596
+ passphrase = os.environ.get("OMG_CREDENTIAL_PASSPHRASE")
597
+ if not passphrase:
598
+ return
599
+
600
+ try:
601
+ store = load_store(passphrase, project_dir)
602
+ except (ValueError, OSError):
603
+ return
604
+
605
+ providers = store.get("providers", {})
606
+ if provider not in providers:
607
+ return
608
+
609
+ pdata = providers[provider]
610
+ keys = pdata.get("keys", [])
611
+ if len(keys) <= 1:
612
+ return # Nothing to rotate
613
+
614
+ policy = pdata.get("rotation_policy", "round-robin")
615
+ if policy == "manual":
616
+ return # Don't auto-advance for manual policy
617
+
618
+ active_idx = pdata.get("active_index", 0)
619
+ if 0 <= active_idx < len(keys):
620
+ keys[active_idx]["usage_count"] = keys[active_idx].get("usage_count", 0) + 1
621
+ keys[active_idx]["last_used"] = datetime.now(timezone.utc).isoformat()
622
+
623
+ if policy == "round-robin":
624
+ pdata["active_index"] = (active_idx + 1) % len(keys)
625
+
626
+ # Failover only advances on error, not after success
627
+ try:
628
+ save_store(store, passphrase, project_dir)
629
+ except (ValueError, OSError):
630
+ pass # Best-effort; don't crash the API call
631
+
632
+
633
+ # =============================================================================
634
+ # Passphrase Handling
635
+ # =============================================================================
636
+
637
+
638
+ def _get_passphrase() -> str:
639
+ """Get passphrase from env var or interactive prompt.
640
+
641
+ Resolution order:
642
+ 1. OMG_CREDENTIAL_PASSPHRASE env var
643
+ 2. getpass.getpass() interactive prompt (if TTY)
644
+ """
645
+ env_passphrase = os.environ.get("OMG_CREDENTIAL_PASSPHRASE")
646
+ if env_passphrase:
647
+ return env_passphrase
648
+
649
+ if not sys.stdin.isatty():
650
+ print(
651
+ "Error: No passphrase available. Set OMG_CREDENTIAL_PASSPHRASE env var "
652
+ "for non-interactive use.",
653
+ file=sys.stderr,
654
+ )
655
+ sys.exit(1)
656
+
657
+ passphrase = getpass.getpass("Credential store passphrase: ")
658
+ if len(passphrase) < MIN_PASSPHRASE_LEN:
659
+ print(
660
+ f"Warning: Passphrase is short ({len(passphrase)} chars). "
661
+ f"Recommended minimum: {MIN_PASSPHRASE_LEN} chars.",
662
+ file=sys.stderr,
663
+ )
664
+ return passphrase
665
+
666
+
667
+ # =============================================================================
668
+ # Feature Flag Gate
669
+ # =============================================================================
670
+
671
+
672
+ def _check_feature_flag() -> None:
673
+ """Verify the multi-credential feature flag is enabled."""
674
+ if not get_feature_flag("MULTI_CREDENTIAL", default=False):
675
+ print(
676
+ "Error: Multi-credential store is disabled.\n"
677
+ "Set OMG_MULTI_CREDENTIAL_ENABLED=1 to enable.",
678
+ file=sys.stderr,
679
+ )
680
+ sys.exit(1)
681
+
682
+
683
+ # =============================================================================
684
+ # CLI Interface
685
+ # =============================================================================
686
+
687
+
688
+ def _build_parser() -> argparse.ArgumentParser:
689
+ """Build the CLI argument parser."""
690
+ parser = argparse.ArgumentParser(
691
+ prog="omg-creds",
692
+ description="Multi-credential encrypted store for OMG.",
693
+ epilog=(
694
+ "environment:\n"
695
+ " OMG_MULTI_CREDENTIAL_ENABLED=1 Required to enable credential store\n"
696
+ " OMG_CREDENTIAL_PASSPHRASE Passphrase for non-interactive use\n"
697
+ "\n"
698
+ "examples:\n"
699
+ " %(prog)s add --provider anthropic --key sk-ant-xxx\n"
700
+ " %(prog)s add --provider openai --key sk-proj-xxx --label backup\n"
701
+ " %(prog)s list\n"
702
+ " %(prog)s list --provider anthropic\n"
703
+ " %(prog)s remove --provider anthropic --index 1\n"
704
+ " %(prog)s rotate --provider anthropic\n"
705
+ " %(prog)s rotate --provider openai --strategy failover"
706
+ ),
707
+ formatter_class=argparse.RawDescriptionHelpFormatter,
708
+ )
709
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
710
+
711
+ # add
712
+ add_p = subparsers.add_parser("add", help="Add an API key for a provider")
713
+ add_p.add_argument("--provider", required=True, help="Provider name (e.g., anthropic, openai)")
714
+ add_p.add_argument("--key", required=True, help="API key value")
715
+ add_p.add_argument("--label", default=None, help="Human-readable label (default: key-N)")
716
+
717
+ # list
718
+ list_p = subparsers.add_parser("list", help="List providers and key metadata")
719
+ list_p.add_argument("--provider", default=None, help="Filter to specific provider (requires passphrase)")
720
+
721
+ # remove
722
+ rm_p = subparsers.add_parser("remove", help="Remove a key or provider")
723
+ rm_p.add_argument("--provider", required=True, help="Provider name")
724
+ rm_p.add_argument("--index", type=int, default=None, help="Key index to remove (omit to remove entire provider)")
725
+ rm_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
726
+
727
+ # rotate
728
+ rot_p = subparsers.add_parser("rotate", help="Rotate active key or set rotation strategy")
729
+ rot_p.add_argument("--provider", required=True, help="Provider name")
730
+ rot_p.add_argument("--index", type=int, default=None, help="Set specific key index as active")
731
+ rot_p.add_argument("--strategy", default=None, choices=["round-robin", "failover", "manual"], help="Set rotation strategy")
732
+
733
+ return parser
734
+
735
+
736
+ def main() -> None:
737
+ """CLI entry point."""
738
+ parser = _build_parser()
739
+ args = parser.parse_args()
740
+
741
+ if not args.command:
742
+ parser.print_help()
743
+ sys.exit(0)
744
+
745
+ # Feature flag gate
746
+ _check_feature_flag()
747
+
748
+ if args.command == "add":
749
+ passphrase = _get_passphrase()
750
+ add_credential(
751
+ provider=args.provider.lower().strip(),
752
+ key=args.key,
753
+ passphrase=passphrase,
754
+ label=args.label,
755
+ )
756
+ # Best-effort cleanup
757
+ del passphrase
758
+ gc.collect()
759
+
760
+ elif args.command == "list":
761
+ if args.provider:
762
+ passphrase = _get_passphrase()
763
+ list_credentials(
764
+ passphrase=passphrase,
765
+ provider_filter=args.provider.lower().strip(),
766
+ )
767
+ del passphrase
768
+ gc.collect()
769
+ else:
770
+ # Try without passphrase first (metadata only)
771
+ list_credentials(passphrase=None)
772
+
773
+ elif args.command == "remove":
774
+ passphrase = _get_passphrase()
775
+ remove_credential(
776
+ provider=args.provider.lower().strip(),
777
+ index=args.index,
778
+ passphrase=passphrase,
779
+ confirm=not args.yes,
780
+ )
781
+ del passphrase
782
+ gc.collect()
783
+
784
+ elif args.command == "rotate":
785
+ passphrase = _get_passphrase()
786
+ rotate_credential(
787
+ provider=args.provider.lower().strip(),
788
+ index=args.index,
789
+ strategy=args.strategy,
790
+ passphrase=passphrase,
791
+ )
792
+ del passphrase
793
+ gc.collect()
794
+
795
+ else:
796
+ parser.print_help()
797
+ sys.exit(1)
798
+
799
+
800
+ if __name__ == "__main__":
801
+ main()