@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.
Files changed (75) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +127 -30
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +506 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +209 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/provider.js +30 -59
  18. package/lib/commands/quota.js +1 -1
  19. package/lib/commands/receipt.js +1 -1
  20. package/lib/commands/run.js +14 -6
  21. package/lib/commands/runner.js +31 -1
  22. package/lib/commands/status.js +38 -10
  23. package/lib/commands/swarm.js +130 -12
  24. package/lib/commands/update.js +184 -38
  25. package/lib/commands/usage.js +1 -1
  26. package/lib/commands/validate.js +32 -3
  27. package/lib/commands/vault.js +43 -8
  28. package/lib/python/__init__.py +0 -0
  29. package/lib/python/agent_quotas.py +525 -0
  30. package/lib/python/anomaly_alert.py +397 -0
  31. package/lib/python/anti_pattern_detector.py +799 -0
  32. package/lib/python/auth.py +443 -0
  33. package/lib/python/capi_profile_guard.py +477 -0
  34. package/lib/python/compliance_report.py +581 -0
  35. package/lib/python/drift_detector.py +388 -0
  36. package/lib/python/experience_pipeline.py +1130 -0
  37. package/lib/python/graph.py +19 -0
  38. package/lib/python/graph_core.py +293 -0
  39. package/lib/python/graph_io.py +179 -0
  40. package/lib/python/graph_legacy.py +2052 -0
  41. package/lib/python/graph_legacy_helpers.py +221 -0
  42. package/lib/python/graph_outcomes_core.py +85 -0
  43. package/lib/python/graph_queries.py +171 -0
  44. package/lib/python/graph_slice.py +198 -0
  45. package/lib/python/graph_slicer.py +576 -0
  46. package/lib/python/graph_slicer_cli.py +60 -0
  47. package/lib/python/graph_validation.py +64 -0
  48. package/lib/python/heatmap.py +934 -0
  49. package/lib/python/json_utils.py +193 -0
  50. package/lib/python/mcp_exposure_check.py +247 -0
  51. package/lib/python/model_router.py +1434 -0
  52. package/lib/python/project_manager.py +621 -0
  53. package/lib/python/provider_profiles.py +1618 -0
  54. package/lib/python/provider_registry.py +1211 -0
  55. package/lib/python/provider_registry_cli.py +125 -0
  56. package/lib/python/receipt_png.py +727 -0
  57. package/lib/python/structural_memory.py +325 -0
  58. package/lib/python/swarm_cost.py +177 -0
  59. package/lib/python/usage_ledger.py +569 -0
  60. package/lib/scripts/mcp_tier_config.py +240 -0
  61. package/lib/shared.js +95 -12
  62. package/lib/tui/index.mjs +35174 -0
  63. package/lib/utils/activation_telemetry.js +1 -4
  64. package/lib/utils/constants.js +7 -1
  65. package/lib/utils/identity.js +184 -0
  66. package/lib/utils/mcp-auth.js +81 -15
  67. package/lib/utils/plan.js +1 -1
  68. package/lib/vault/index.js +19 -3
  69. package/lib/vault/storage.js +21 -2
  70. package/lib/wizard.js +5 -2
  71. package/package.json +9 -3
  72. package/scripts/build-python-bundle.js +106 -0
  73. package/scripts/build-tui.js +14 -1
  74. package/scripts/harvest_experience.py +523 -0
  75. 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()