@0dai-dev/cli 4.3.6 → 4.3.7
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.
- package/README.md +12 -11
- package/bin/0dai.js +127 -30
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +506 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +209 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +38 -10
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +934 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +95 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Guard the capi custom API profile from leaked interactive Claude auth state."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CAPI_HOME = pathlib.Path(os.environ.get("ODAI_CAPI_HOME", "/root/.custom-api")).expanduser()
|
|
14
|
+
PROFILE_JSON = CAPI_HOME / ".claude.json"
|
|
15
|
+
CREDENTIALS_JSON = CAPI_HOME / ".claude" / ".credentials.json"
|
|
16
|
+
BACKUPS_DIR = CAPI_HOME / ".claude" / "backups"
|
|
17
|
+
STATE_FILE = CAPI_HOME / ".0dai-capi-profile-guard.json"
|
|
18
|
+
SETTINGS_JSON = CAPI_HOME / ".claude" / "settings.json"
|
|
19
|
+
ENV_FILE = CAPI_HOME / os.environ.get("ODAI_CAPI_ENV_FILE", "custom-api.env")
|
|
20
|
+
LEGACY_ENV_FILE = CAPI_HOME / "anthropic.env"
|
|
21
|
+
|
|
22
|
+
PROFILE_AUTH_KEYS = ("oauthAccount", "hasAvailableSubscription")
|
|
23
|
+
BACKUP_MARKERS = ("oauthAccount", "claudeAiOauth", "emailAddress", "organizationUuid", "accountUuid")
|
|
24
|
+
SETTINGS_SCHEMA_URL = "https://json.schemastore.org/claude-code-settings.json"
|
|
25
|
+
SETTINGS_MODEL_DEFAULT = "claude-opus-4-6"
|
|
26
|
+
PRESERVED_ENV_KEYS = ("ANTHROPIC_BASE_URL",)
|
|
27
|
+
UPSTREAM_ENV_KEY = "CAPI_UPSTREAM_BASE_URL"
|
|
28
|
+
PROVIDER_KIND_ENV_KEY = "CAPI_PROVIDER_KIND"
|
|
29
|
+
SECRET_ENV_KEY = "CAPI_SECRET_ENV_KEY"
|
|
30
|
+
SETTINGS_ENV_DEFAULTS = {
|
|
31
|
+
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "0",
|
|
32
|
+
"CLAUDE_CODE_EFFORT_LEVEL": "high",
|
|
33
|
+
"CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "80",
|
|
34
|
+
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "64000",
|
|
35
|
+
"CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS": "100000",
|
|
36
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
37
|
+
"DISABLE_TELEMETRY": "1",
|
|
38
|
+
"DISABLE_ERROR_REPORTING": "1",
|
|
39
|
+
"DISABLE_AUTOUPDATER": "1",
|
|
40
|
+
"DISABLE_BUG_COMMAND": "1",
|
|
41
|
+
"DISABLE_COST_WARNINGS": "1",
|
|
42
|
+
"DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1",
|
|
43
|
+
"CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1",
|
|
44
|
+
"BASH_DEFAULT_TIMEOUT_MS": "120000",
|
|
45
|
+
"BASH_MAX_TIMEOUT_MS": "600000",
|
|
46
|
+
"BASH_MAX_OUTPUT_LENGTH": "100000",
|
|
47
|
+
"MAX_THINKING_TOKENS": "31999",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _now() -> str:
|
|
52
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_rel(path: pathlib.Path) -> str:
|
|
56
|
+
try:
|
|
57
|
+
return str(path.relative_to(CAPI_HOME))
|
|
58
|
+
except ValueError:
|
|
59
|
+
return str(path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_json(path: pathlib.Path) -> dict[str, Any]:
|
|
63
|
+
if not _is_file(path):
|
|
64
|
+
return {}
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
except (json.JSONDecodeError, OSError):
|
|
68
|
+
return {}
|
|
69
|
+
return data if isinstance(data, dict) else {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_json(path: pathlib.Path, payload: dict[str, Any]) -> None:
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
path.write_text(
|
|
75
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_guard_state() -> dict[str, Any]:
|
|
81
|
+
return _load_json(STATE_FILE)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _save_guard_state(payload: dict[str, Any]) -> None:
|
|
85
|
+
current = _load_guard_state()
|
|
86
|
+
merged = {**current, **payload, "updated_at": _now()}
|
|
87
|
+
_write_json(STATE_FILE, merged)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_file(path: pathlib.Path) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
return path.is_file()
|
|
93
|
+
except OSError:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_dir(path: pathlib.Path) -> bool:
|
|
98
|
+
try:
|
|
99
|
+
return path.is_dir()
|
|
100
|
+
except OSError:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _profile_auth_keys(path: pathlib.Path) -> tuple[list[str], str | None]:
|
|
105
|
+
if not _is_file(path):
|
|
106
|
+
return [], None
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
109
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
110
|
+
text = ""
|
|
111
|
+
try:
|
|
112
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
if any(marker in text for marker in BACKUP_MARKERS):
|
|
116
|
+
return list(PROFILE_AUTH_KEYS), f"invalid profile json: {exc}"
|
|
117
|
+
return [], None
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
return [], None
|
|
120
|
+
return [key for key in PROFILE_AUTH_KEYS if key in data], None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _backup_files_with_auth() -> list[pathlib.Path]:
|
|
124
|
+
if not _is_dir(BACKUPS_DIR):
|
|
125
|
+
return []
|
|
126
|
+
matches: list[pathlib.Path] = []
|
|
127
|
+
for path in sorted(BACKUPS_DIR.glob(".claude.json.backup.*")):
|
|
128
|
+
try:
|
|
129
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
130
|
+
except OSError:
|
|
131
|
+
continue
|
|
132
|
+
if any(marker in text for marker in BACKUP_MARKERS):
|
|
133
|
+
matches.append(path)
|
|
134
|
+
return matches
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _base_result() -> dict[str, Any]:
|
|
138
|
+
return {
|
|
139
|
+
"clean": True,
|
|
140
|
+
"leaked_auth_detected": False,
|
|
141
|
+
"sanitized": False,
|
|
142
|
+
"removed_files": [],
|
|
143
|
+
"removed_keys": [],
|
|
144
|
+
"warnings": [],
|
|
145
|
+
"error": None,
|
|
146
|
+
"state": "clean",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _env_assignments(path: pathlib.Path) -> dict[str, str]:
|
|
151
|
+
if not _is_file(path):
|
|
152
|
+
return {}
|
|
153
|
+
assignments: dict[str, str] = {}
|
|
154
|
+
try:
|
|
155
|
+
text = path.read_text(encoding="utf-8")
|
|
156
|
+
except OSError:
|
|
157
|
+
return {}
|
|
158
|
+
for raw_line in text.splitlines():
|
|
159
|
+
line = raw_line.strip()
|
|
160
|
+
if not line or line.startswith("#"):
|
|
161
|
+
continue
|
|
162
|
+
if line.startswith("export "):
|
|
163
|
+
line = line[len("export ") :].strip()
|
|
164
|
+
if "=" not in line:
|
|
165
|
+
continue
|
|
166
|
+
key, value = line.split("=", 1)
|
|
167
|
+
key = key.strip()
|
|
168
|
+
if not key:
|
|
169
|
+
continue
|
|
170
|
+
assignments[key] = value.strip().strip('"').strip("'")
|
|
171
|
+
return assignments
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def load_env_contract() -> dict[str, str]:
|
|
175
|
+
assignments = _env_assignments(ENV_FILE)
|
|
176
|
+
if assignments:
|
|
177
|
+
return assignments
|
|
178
|
+
if LEGACY_ENV_FILE != ENV_FILE:
|
|
179
|
+
return _env_assignments(LEGACY_ENV_FILE)
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _provider_profile_env_contract(target: pathlib.Path | None) -> dict[str, str]:
|
|
184
|
+
if target is None:
|
|
185
|
+
return {}
|
|
186
|
+
try:
|
|
187
|
+
try:
|
|
188
|
+
from . import provider_profiles # type: ignore
|
|
189
|
+
except ImportError:
|
|
190
|
+
import provider_profiles # type: ignore
|
|
191
|
+
except ImportError:
|
|
192
|
+
return {}
|
|
193
|
+
try:
|
|
194
|
+
profile = provider_profiles.resolve_profile(pathlib.Path(target).resolve(), "capi")
|
|
195
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
196
|
+
# resolve_profile reads JSON/YAML configs (OSError, ValueError covers
|
|
197
|
+
# JSONDecodeError), looks up dict keys (KeyError), and accesses
|
|
198
|
+
# attributes on the parsed shape (AttributeError/TypeError). Profile
|
|
199
|
+
# lookup is optional — return empty env on any of these.
|
|
200
|
+
return {}
|
|
201
|
+
if not isinstance(profile, dict) or not profile:
|
|
202
|
+
return {}
|
|
203
|
+
try:
|
|
204
|
+
api_key = str(provider_profiles._secret_value(profile) or "").strip()
|
|
205
|
+
secret_key = str(profile.get("secret_env_key") or "").strip()
|
|
206
|
+
provider_kind = str(profile.get("provider_kind") or "").strip()
|
|
207
|
+
upstream = str(
|
|
208
|
+
provider_profiles._invoke_base_url(
|
|
209
|
+
provider_kind,
|
|
210
|
+
str(profile.get("base_url") or ""),
|
|
211
|
+
)
|
|
212
|
+
or ""
|
|
213
|
+
).strip()
|
|
214
|
+
except (AttributeError, KeyError, TypeError, ValueError):
|
|
215
|
+
# Profile is expected to be a dict (.get / attribute access) and
|
|
216
|
+
# _secret_value / _invoke_base_url accept str inputs. The handler
|
|
217
|
+
# absorbs shape drift only — raising on these makes the guard fail
|
|
218
|
+
# closed instead of being usable as a best-effort env builder.
|
|
219
|
+
return {}
|
|
220
|
+
if not secret_key:
|
|
221
|
+
secret_key = "OPENAI_API_KEY" if provider_kind == "openai_compatible" else "ANTHROPIC_API_KEY"
|
|
222
|
+
if not api_key or not upstream:
|
|
223
|
+
return {}
|
|
224
|
+
return {
|
|
225
|
+
PROVIDER_KIND_ENV_KEY: provider_kind,
|
|
226
|
+
SECRET_ENV_KEY: secret_key,
|
|
227
|
+
secret_key: api_key,
|
|
228
|
+
UPSTREAM_ENV_KEY: upstream,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _write_env_contract(path: pathlib.Path, assignments: dict[str, str]) -> None:
|
|
233
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
ordered_keys = [
|
|
235
|
+
PROVIDER_KIND_ENV_KEY,
|
|
236
|
+
SECRET_ENV_KEY,
|
|
237
|
+
"OPENAI_API_KEY",
|
|
238
|
+
"OPENROUTER_API_KEY",
|
|
239
|
+
"ANTHROPIC_API_KEY",
|
|
240
|
+
UPSTREAM_ENV_KEY,
|
|
241
|
+
"ANTHROPIC_BASE_URL",
|
|
242
|
+
]
|
|
243
|
+
remaining = sorted(key for key in assignments if key not in ordered_keys)
|
|
244
|
+
lines: list[str] = []
|
|
245
|
+
for key in ordered_keys + remaining:
|
|
246
|
+
value = str(assignments.get(key) or "").strip()
|
|
247
|
+
if not value:
|
|
248
|
+
continue
|
|
249
|
+
lines.append(f"export {key}={value}")
|
|
250
|
+
path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
251
|
+
try:
|
|
252
|
+
path.chmod(0o600)
|
|
253
|
+
except OSError:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _canonical_settings(existing: dict[str, Any]) -> dict[str, Any]:
|
|
258
|
+
existing_env = existing.get("env")
|
|
259
|
+
normalized_env: dict[str, str] = {}
|
|
260
|
+
if isinstance(existing_env, dict):
|
|
261
|
+
for key in PRESERVED_ENV_KEYS:
|
|
262
|
+
value = existing_env.get(key)
|
|
263
|
+
if value is not None and str(value).strip():
|
|
264
|
+
normalized_env[key] = str(value)
|
|
265
|
+
normalized_env.update(SETTINGS_ENV_DEFAULTS)
|
|
266
|
+
return {
|
|
267
|
+
"$schema": SETTINGS_SCHEMA_URL,
|
|
268
|
+
"autoUpdatesChannel": str(existing.get("autoUpdatesChannel") or "latest"),
|
|
269
|
+
"env": normalized_env,
|
|
270
|
+
"model": str(existing.get("model") or SETTINGS_MODEL_DEFAULT),
|
|
271
|
+
"skipDangerousModePermissionPrompt": True,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def detect_profile() -> dict[str, Any]:
|
|
276
|
+
result = _base_result()
|
|
277
|
+
profile_keys, profile_error = _profile_auth_keys(PROFILE_JSON)
|
|
278
|
+
leaked_files: list[str] = []
|
|
279
|
+
if _is_file(CREDENTIALS_JSON):
|
|
280
|
+
leaked_files.append(_safe_rel(CREDENTIALS_JSON))
|
|
281
|
+
leaked_backups = _backup_files_with_auth()
|
|
282
|
+
leaked_files.extend(_safe_rel(path) for path in leaked_backups)
|
|
283
|
+
if profile_error:
|
|
284
|
+
result["warnings"].append(profile_error)
|
|
285
|
+
if profile_keys or leaked_files:
|
|
286
|
+
result["clean"] = False
|
|
287
|
+
result["leaked_auth_detected"] = True
|
|
288
|
+
result["removed_keys"] = list(profile_keys)
|
|
289
|
+
result["removed_files"] = leaked_files
|
|
290
|
+
result["state"] = "leaked_auth_detected"
|
|
291
|
+
return result
|
|
292
|
+
state = _load_guard_state()
|
|
293
|
+
if state.get("last_sanitized_at"):
|
|
294
|
+
result["state"] = "leaked_auth_removed"
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def sanitize_profile() -> dict[str, Any]:
|
|
299
|
+
detected = detect_profile()
|
|
300
|
+
if not detected.get("leaked_auth_detected"):
|
|
301
|
+
return detected
|
|
302
|
+
|
|
303
|
+
removed_files: list[str] = []
|
|
304
|
+
removed_keys: list[str] = []
|
|
305
|
+
warnings = list(detected.get("warnings") or [])
|
|
306
|
+
|
|
307
|
+
if _is_file(CREDENTIALS_JSON):
|
|
308
|
+
try:
|
|
309
|
+
CREDENTIALS_JSON.unlink()
|
|
310
|
+
removed_files.append(_safe_rel(CREDENTIALS_JSON))
|
|
311
|
+
except OSError as exc:
|
|
312
|
+
detected["error"] = f"failed to remove {_safe_rel(CREDENTIALS_JSON)}: {exc}"
|
|
313
|
+
detected["clean"] = False
|
|
314
|
+
detected["state"] = "error"
|
|
315
|
+
return detected
|
|
316
|
+
|
|
317
|
+
profile_keys, profile_error = _profile_auth_keys(PROFILE_JSON)
|
|
318
|
+
if profile_error:
|
|
319
|
+
detected["error"] = profile_error
|
|
320
|
+
detected["clean"] = False
|
|
321
|
+
detected["state"] = "error"
|
|
322
|
+
return detected
|
|
323
|
+
if profile_keys:
|
|
324
|
+
try:
|
|
325
|
+
data = json.loads(PROFILE_JSON.read_text(encoding="utf-8"))
|
|
326
|
+
if not isinstance(data, dict):
|
|
327
|
+
raise ValueError("profile root is not an object")
|
|
328
|
+
for key in profile_keys:
|
|
329
|
+
data.pop(key, None)
|
|
330
|
+
PROFILE_JSON.write_text(
|
|
331
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
332
|
+
encoding="utf-8",
|
|
333
|
+
)
|
|
334
|
+
removed_keys.extend(profile_keys)
|
|
335
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
336
|
+
detected["error"] = f"failed to sanitize {_safe_rel(PROFILE_JSON)}: {exc}"
|
|
337
|
+
detected["clean"] = False
|
|
338
|
+
detected["state"] = "error"
|
|
339
|
+
return detected
|
|
340
|
+
|
|
341
|
+
for backup in _backup_files_with_auth():
|
|
342
|
+
try:
|
|
343
|
+
backup.unlink()
|
|
344
|
+
removed_files.append(_safe_rel(backup))
|
|
345
|
+
except OSError as exc:
|
|
346
|
+
warnings.append(f"failed to remove {_safe_rel(backup)}: {exc}")
|
|
347
|
+
|
|
348
|
+
post = detect_profile()
|
|
349
|
+
if post.get("leaked_auth_detected"):
|
|
350
|
+
post["error"] = "leaked auth markers remain after sanitize"
|
|
351
|
+
post["clean"] = False
|
|
352
|
+
post["state"] = "error"
|
|
353
|
+
return post
|
|
354
|
+
|
|
355
|
+
payload = {
|
|
356
|
+
**post,
|
|
357
|
+
"sanitized": bool(removed_files or removed_keys),
|
|
358
|
+
"removed_files": removed_files,
|
|
359
|
+
"removed_keys": removed_keys,
|
|
360
|
+
"warnings": warnings,
|
|
361
|
+
"state": "leaked_auth_removed" if (removed_files or removed_keys) else post.get("state", "clean"),
|
|
362
|
+
}
|
|
363
|
+
if payload["sanitized"]:
|
|
364
|
+
_save_guard_state(
|
|
365
|
+
{
|
|
366
|
+
"last_sanitized_at": _now(),
|
|
367
|
+
"last_removed_files": removed_files,
|
|
368
|
+
"last_removed_keys": removed_keys,
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
return payload
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def ensure_profile_contract(*, target: pathlib.Path | None = None) -> dict[str, Any]:
|
|
375
|
+
result = sanitize_profile()
|
|
376
|
+
result.setdefault("warnings", [])
|
|
377
|
+
result["contract_ok"] = False
|
|
378
|
+
result["settings_rewritten"] = False
|
|
379
|
+
result["env_rewritten"] = False
|
|
380
|
+
result["env_file_present"] = _is_file(ENV_FILE)
|
|
381
|
+
result["api_key_present"] = False
|
|
382
|
+
result["upstream_present"] = False
|
|
383
|
+
result["secret_env_key"] = ""
|
|
384
|
+
result["provider_kind"] = ""
|
|
385
|
+
if result.get("error"):
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
SETTINGS_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
(CAPI_HOME / ".cache").mkdir(parents=True, exist_ok=True)
|
|
390
|
+
(CAPI_HOME / ".local" / "state").mkdir(parents=True, exist_ok=True)
|
|
391
|
+
(CAPI_HOME / ".config").mkdir(parents=True, exist_ok=True)
|
|
392
|
+
|
|
393
|
+
existing_settings = _load_json(SETTINGS_JSON)
|
|
394
|
+
canonical_settings = _canonical_settings(existing_settings)
|
|
395
|
+
if existing_settings != canonical_settings:
|
|
396
|
+
_write_json(SETTINGS_JSON, canonical_settings)
|
|
397
|
+
result["settings_rewritten"] = True
|
|
398
|
+
|
|
399
|
+
provider_env = _provider_profile_env_contract(target)
|
|
400
|
+
if provider_env:
|
|
401
|
+
existing_assignments = load_env_contract()
|
|
402
|
+
merged_assignments = dict(existing_assignments)
|
|
403
|
+
merged_assignments.update(provider_env)
|
|
404
|
+
if merged_assignments != existing_assignments or not _is_file(ENV_FILE):
|
|
405
|
+
_write_env_contract(ENV_FILE, merged_assignments)
|
|
406
|
+
result["env_rewritten"] = True
|
|
407
|
+
elif not _is_file(ENV_FILE):
|
|
408
|
+
existing_assignments = load_env_contract()
|
|
409
|
+
if existing_assignments:
|
|
410
|
+
_write_env_contract(ENV_FILE, existing_assignments)
|
|
411
|
+
result["env_rewritten"] = True
|
|
412
|
+
|
|
413
|
+
result["env_file_present"] = _is_file(ENV_FILE)
|
|
414
|
+
if not result["env_file_present"]:
|
|
415
|
+
result["clean"] = False
|
|
416
|
+
result["error"] = f"missing capi env file: {ENV_FILE}"
|
|
417
|
+
result["state"] = "error"
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
assignments = load_env_contract()
|
|
421
|
+
secret_key = str(assignments.get(SECRET_ENV_KEY) or "").strip()
|
|
422
|
+
if not secret_key:
|
|
423
|
+
secret_key = "OPENAI_API_KEY" if assignments.get("OPENAI_API_KEY") else "ANTHROPIC_API_KEY"
|
|
424
|
+
result["secret_env_key"] = secret_key
|
|
425
|
+
provider_kind = str(assignments.get(PROVIDER_KIND_ENV_KEY) or "").strip()
|
|
426
|
+
if not provider_kind:
|
|
427
|
+
provider_kind = (
|
|
428
|
+
"openai_compatible"
|
|
429
|
+
if secret_key.startswith("OPENAI") or secret_key.startswith("OPENROUTER")
|
|
430
|
+
else "anthropic_compatible"
|
|
431
|
+
)
|
|
432
|
+
result["provider_kind"] = provider_kind
|
|
433
|
+
result["api_key_present"] = bool(assignments.get(secret_key))
|
|
434
|
+
if not result["api_key_present"]:
|
|
435
|
+
result["clean"] = False
|
|
436
|
+
result["error"] = f"missing {secret_key} in {ENV_FILE}"
|
|
437
|
+
result["state"] = "error"
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
result["upstream_present"] = bool(assignments.get(UPSTREAM_ENV_KEY))
|
|
441
|
+
if not result["upstream_present"]:
|
|
442
|
+
result["clean"] = False
|
|
443
|
+
result["error"] = f"missing {UPSTREAM_ENV_KEY} in {ENV_FILE}"
|
|
444
|
+
result["state"] = "error"
|
|
445
|
+
return result
|
|
446
|
+
result["upstream_base_url"] = str(assignments.get(UPSTREAM_ENV_KEY) or "").strip()
|
|
447
|
+
|
|
448
|
+
result["contract_ok"] = True
|
|
449
|
+
if result["settings_rewritten"] and result.get("state") == "clean":
|
|
450
|
+
result["state"] = "contract_repaired"
|
|
451
|
+
return result
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
455
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
456
|
+
parser.add_argument("mode", choices=("detect", "sanitize", "ensure"))
|
|
457
|
+
parser.add_argument("--json", action="store_true", dest="json_output")
|
|
458
|
+
return parser
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def main() -> None:
|
|
462
|
+
args = _build_parser().parse_args()
|
|
463
|
+
if args.mode == "detect":
|
|
464
|
+
result = detect_profile()
|
|
465
|
+
elif args.mode == "sanitize":
|
|
466
|
+
result = sanitize_profile()
|
|
467
|
+
else:
|
|
468
|
+
result = ensure_profile_contract()
|
|
469
|
+
if args.json_output:
|
|
470
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
471
|
+
else:
|
|
472
|
+
print(result.get("state") or "unknown")
|
|
473
|
+
raise SystemExit(1 if result.get("error") else 0)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
main()
|