@0dai-dev/cli 4.3.5 → 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 (79) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +214 -40
  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 +55 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/detect.js +10 -4
  9. package/lib/commands/doctor.js +545 -26
  10. package/lib/commands/experience.js +40 -5
  11. package/lib/commands/export.js +73 -0
  12. package/lib/commands/feedback.js +157 -15
  13. package/lib/commands/gh.js +26 -0
  14. package/lib/commands/graph.js +9 -4
  15. package/lib/commands/heatmap.js +1 -1
  16. package/lib/commands/init.js +222 -30
  17. package/lib/commands/mcp.js +129 -21
  18. package/lib/commands/models.js +138 -41
  19. package/lib/commands/provider.js +30 -59
  20. package/lib/commands/quota.js +1 -1
  21. package/lib/commands/receipt.js +1 -1
  22. package/lib/commands/run.js +18 -7
  23. package/lib/commands/runner.js +31 -1
  24. package/lib/commands/status.js +44 -11
  25. package/lib/commands/swarm.js +130 -12
  26. package/lib/commands/trust.js +286 -0
  27. package/lib/commands/update.js +184 -38
  28. package/lib/commands/usage.js +1 -1
  29. package/lib/commands/validate.js +32 -3
  30. package/lib/commands/vault.js +46 -9
  31. package/lib/python/__init__.py +0 -0
  32. package/lib/python/agent_quotas.py +525 -0
  33. package/lib/python/anomaly_alert.py +397 -0
  34. package/lib/python/anti_pattern_detector.py +799 -0
  35. package/lib/python/auth.py +443 -0
  36. package/lib/python/capi_profile_guard.py +477 -0
  37. package/lib/python/compliance_report.py +581 -0
  38. package/lib/python/drift_detector.py +388 -0
  39. package/lib/python/experience_pipeline.py +1130 -0
  40. package/lib/python/graph.py +19 -0
  41. package/lib/python/graph_core.py +293 -0
  42. package/lib/python/graph_io.py +179 -0
  43. package/lib/python/graph_legacy.py +2052 -0
  44. package/lib/python/graph_legacy_helpers.py +221 -0
  45. package/lib/python/graph_outcomes_core.py +85 -0
  46. package/lib/python/graph_queries.py +171 -0
  47. package/lib/python/graph_slice.py +198 -0
  48. package/lib/python/graph_slicer.py +576 -0
  49. package/lib/python/graph_slicer_cli.py +60 -0
  50. package/lib/python/graph_validation.py +64 -0
  51. package/lib/python/heatmap.py +934 -0
  52. package/lib/python/json_utils.py +193 -0
  53. package/lib/python/mcp_exposure_check.py +247 -0
  54. package/lib/python/model_router.py +1434 -0
  55. package/lib/python/project_manager.py +621 -0
  56. package/lib/python/provider_profiles.py +1618 -0
  57. package/lib/python/provider_registry.py +1211 -0
  58. package/lib/python/provider_registry_cli.py +125 -0
  59. package/lib/python/receipt_png.py +727 -0
  60. package/lib/python/structural_memory.py +325 -0
  61. package/lib/python/swarm_cost.py +177 -0
  62. package/lib/python/usage_ledger.py +569 -0
  63. package/lib/scripts/mcp_tier_config.py +240 -0
  64. package/lib/shared.js +97 -14
  65. package/lib/tui/index.mjs +35174 -0
  66. package/lib/utils/activation_telemetry.js +230 -11
  67. package/lib/utils/constants.js +7 -1
  68. package/lib/utils/export-bundler.js +285 -0
  69. package/lib/utils/identity.js +198 -1
  70. package/lib/utils/mcp-auth.js +81 -15
  71. package/lib/utils/plan.js +1 -1
  72. package/lib/vault/index.js +19 -3
  73. package/lib/vault/storage.js +21 -2
  74. package/lib/wizard.js +5 -2
  75. package/package.json +9 -3
  76. package/scripts/build-python-bundle.js +106 -0
  77. package/scripts/build-tui.js +14 -1
  78. package/scripts/harvest_experience.py +523 -0
  79. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env python3
2
+ """Config drift detection between agent configs.
3
+
4
+ Detects when native configs (CLAUDE.md, AGENTS.md, etc.) diverge from
5
+ what 0dai generated. Tracks hashes, finds contradictions.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import pathlib
11
+ import re
12
+ import sys
13
+ import time
14
+
15
+ from json_utils import load_json, save_json
16
+
17
+ # Configs that 0dai generates
18
+ TRACKED_CONFIGS = [
19
+ "CLAUDE.md", "AGENTS.md", "GEMINI.md",
20
+ "opencode.json", ".cursorrules", ".windsurfrules",
21
+ ".aider.conf.yml",
22
+ ]
23
+
24
+ HASHES_FILE = "ai/manifest/config_hashes.json"
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Hash tracking
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def _hashes_path(target: pathlib.Path) -> pathlib.Path:
32
+ return target / HASHES_FILE
33
+
34
+
35
+ def compute_file_hash(file_path: pathlib.Path) -> str:
36
+ if not file_path.is_file():
37
+ return ""
38
+ return hashlib.sha256(file_path.read_bytes()).hexdigest()[:16]
39
+
40
+
41
+ def load_hashes(target: pathlib.Path) -> dict:
42
+ return load_json(_hashes_path(target))
43
+
44
+
45
+ def save_hashes(target: pathlib.Path, hashes: dict) -> None:
46
+ save_json(_hashes_path(target), hashes)
47
+
48
+
49
+ def record_hash(target: pathlib.Path, config_name: str) -> None:
50
+ """Record hash for a generated config file."""
51
+ file_path = target / config_name
52
+ hashes = load_hashes(target)
53
+ hashes[config_name] = {
54
+ "hash": compute_file_hash(file_path),
55
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
56
+ }
57
+ save_hashes(target, hashes)
58
+
59
+
60
+ def record_all_hashes(target: pathlib.Path) -> int:
61
+ """Record hashes for all existing tracked configs. Returns count."""
62
+ count = 0
63
+ hashes = load_hashes(target)
64
+ for name in TRACKED_CONFIGS:
65
+ fp = target / name
66
+ if fp.is_file():
67
+ hashes[name] = {
68
+ "hash": compute_file_hash(fp),
69
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
70
+ }
71
+ count += 1
72
+ save_hashes(target, hashes)
73
+ return count
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Drift detection
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def detect_drift(target: pathlib.Path) -> list[dict]:
81
+ """Detect drift across all tracked configs."""
82
+ hashes = load_hashes(target)
83
+ findings: list[dict] = []
84
+
85
+ for name in TRACKED_CONFIGS:
86
+ fp = target / name
87
+ recorded = hashes.get(name)
88
+
89
+ if recorded and not fp.is_file():
90
+ # Config was generated but now missing
91
+ findings.append({
92
+ "config_file": name,
93
+ "drift_type": "missing",
94
+ "severity": "warning",
95
+ "description": f"{name} was generated but has been deleted",
96
+ "source_hash": recorded.get("hash", ""),
97
+ "current_hash": "",
98
+ "last_synced_at": recorded.get("generated_at", ""),
99
+ })
100
+ continue
101
+
102
+ if not recorded and fp.is_file():
103
+ # Config exists but wasn't generated by 0dai
104
+ findings.append({
105
+ "config_file": name,
106
+ "drift_type": "extra",
107
+ "severity": "info",
108
+ "description": f"{name} exists but wasn't generated by 0dai",
109
+ "source_hash": "",
110
+ "current_hash": compute_file_hash(fp),
111
+ "last_synced_at": "",
112
+ })
113
+ continue
114
+
115
+ if recorded and fp.is_file():
116
+ current = compute_file_hash(fp)
117
+ if current != recorded.get("hash", ""):
118
+ findings.append({
119
+ "config_file": name,
120
+ "drift_type": "modified",
121
+ "severity": "warning",
122
+ "description": f"{name} was manually edited after last sync",
123
+ "source_hash": recorded.get("hash", ""),
124
+ "current_hash": current,
125
+ "last_synced_at": recorded.get("generated_at", ""),
126
+ })
127
+
128
+ # Check for contradictions between configs
129
+ findings.extend(detect_contradictions(target))
130
+
131
+ return findings
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Contradiction detection
136
+ # ---------------------------------------------------------------------------
137
+
138
+ # Keywords to look for in config content — grouped by topic
139
+ _TOPIC_PATTERNS: dict[str, list[tuple[str, re.Pattern]]] = {
140
+ "testing": [
141
+ ("jest", re.compile(r"\bjest\b", re.IGNORECASE)),
142
+ ("vitest", re.compile(r"\bvitest\b", re.IGNORECASE)),
143
+ ("mocha", re.compile(r"\bmocha\b", re.IGNORECASE)),
144
+ ("pytest", re.compile(r"\bpytest\b", re.IGNORECASE)),
145
+ ("unittest", re.compile(r"\bunittest\b", re.IGNORECASE)),
146
+ ],
147
+ "package_manager": [
148
+ ("npm", re.compile(r"\bnpm\s+(install|run|ci|test)\b", re.IGNORECASE)),
149
+ ("yarn", re.compile(r"\byarn\s+(add|run|install|test)\b", re.IGNORECASE)),
150
+ ("pnpm", re.compile(r"\bpnpm\s+(add|run|install|test)\b", re.IGNORECASE)),
151
+ ("bun", re.compile(r"\bbun\s+(add|run|install|test)\b", re.IGNORECASE)),
152
+ ],
153
+ "node_version": [
154
+ ("node18", re.compile(r"node\s*(?:version\s*)?:?\s*['\"]?18\b")),
155
+ ("node20", re.compile(r"node\s*(?:version\s*)?:?\s*['\"]?20\b")),
156
+ ("node22", re.compile(r"node\s*(?:version\s*)?:?\s*['\"]?22\b")),
157
+ ],
158
+ }
159
+
160
+
161
+ def detect_contradictions(target: pathlib.Path) -> list[dict]:
162
+ """Find contradicting values across config files."""
163
+ # Read all config contents
164
+ configs: dict[str, str] = {}
165
+ for name in TRACKED_CONFIGS:
166
+ fp = target / name
167
+ if fp.is_file():
168
+ try:
169
+ configs[name] = fp.read_text("utf-8", errors="ignore")
170
+ except OSError:
171
+ continue
172
+
173
+ if len(configs) < 2:
174
+ return []
175
+
176
+ findings: list[dict] = []
177
+
178
+ for topic, patterns in _TOPIC_PATTERNS.items():
179
+ # For each config, find which values are mentioned
180
+ config_values: dict[str, set[str]] = {}
181
+ for name, content in configs.items():
182
+ vals = set()
183
+ for val_name, regex in patterns:
184
+ if regex.search(content):
185
+ vals.add(val_name)
186
+ if vals:
187
+ config_values[name] = vals
188
+
189
+ if len(config_values) < 2:
190
+ continue
191
+
192
+ # Check for contradictions: different configs mention different values
193
+ all_values = set()
194
+ for vals in config_values.values():
195
+ all_values.update(vals)
196
+
197
+ if len(all_values) <= 1:
198
+ continue # all agree
199
+
200
+ # Find specific contradictions
201
+ files_by_value: dict[str, list[str]] = {}
202
+ for name, vals in config_values.items():
203
+ for v in vals:
204
+ files_by_value.setdefault(v, []).append(name)
205
+
206
+ # Only flag if different files advocate different values
207
+ contradicting_pairs: list[tuple[str, str, str, str]] = []
208
+ seen = set()
209
+ for v1, files1 in files_by_value.items():
210
+ for v2, files2 in files_by_value.items():
211
+ if v1 >= v2:
212
+ continue
213
+ # Check if different files mention different values
214
+ f1_set = set(files1)
215
+ f2_set = set(files2)
216
+ if f1_set != f2_set and not f1_set.issubset(f2_set) and not f2_set.issubset(f1_set):
217
+ key = (v1, v2)
218
+ if key not in seen:
219
+ seen.add(key)
220
+ contradicting_pairs.append((v1, ", ".join(sorted(files1)), v2, ", ".join(sorted(files2))))
221
+
222
+ for v1, f1, v2, f2 in contradicting_pairs:
223
+ findings.append({
224
+ "config_file": f"{f1} vs {f2}",
225
+ "drift_type": "contradicts",
226
+ "severity": "critical",
227
+ "description": f"Contradiction in {topic}: {v1} ({f1}) vs {v2} ({f2})",
228
+ "source_hash": "",
229
+ "current_hash": "",
230
+ "last_synced_at": "",
231
+ "details": {
232
+ "topic": topic,
233
+ "value_a": v1,
234
+ "files_a": f1,
235
+ "value_b": v2,
236
+ "files_b": f2,
237
+ },
238
+ })
239
+
240
+ return findings
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Drift summary
245
+ # ---------------------------------------------------------------------------
246
+
247
+ def get_drift_summary(target: pathlib.Path) -> dict:
248
+ findings = detect_drift(target)
249
+ by_type = {}
250
+ for f in findings:
251
+ by_type[f["drift_type"]] = by_type.get(f["drift_type"], 0) + 1
252
+ total_tracked = sum(1 for n in TRACKED_CONFIGS if (target / n).is_file())
253
+ drifted = sum(1 for f in findings if f["drift_type"] in ("modified", "missing"))
254
+ return {
255
+ "total_configs": total_tracked,
256
+ "drifted": drifted,
257
+ "contradictions": by_type.get("contradicts", 0),
258
+ "findings": findings,
259
+ "clean": len(findings) == 0,
260
+ }
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Accept drift (update hash to current)
265
+ # ---------------------------------------------------------------------------
266
+
267
+ def accept_drift(target: pathlib.Path, config_name: str) -> dict:
268
+ fp = target / config_name
269
+ if not fp.is_file():
270
+ return {"ok": False, "error": f"{config_name} does not exist"}
271
+ hashes = load_hashes(target)
272
+ hashes[config_name] = {
273
+ "hash": compute_file_hash(fp),
274
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
275
+ "accepted": True,
276
+ }
277
+ save_hashes(target, hashes)
278
+ return {"ok": True, "config": config_name}
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # CLI
283
+ # ---------------------------------------------------------------------------
284
+
285
+ def cmd_drift_report(target: pathlib.Path) -> int:
286
+ summary = get_drift_summary(target)
287
+ if summary["clean"]:
288
+ print("Config drift: all configs match generated versions.")
289
+ return 0
290
+
291
+ print("Config Drift Report:\n")
292
+ for f in summary["findings"]:
293
+ if f["drift_type"] == "modified":
294
+ icon = "\U0001f7e1" # yellow circle
295
+ print(f" {icon} MODIFIED: {f['config_file']}")
296
+ print(f" Changed after last sync ({f.get('last_synced_at', '?')})")
297
+ print(" -> Run: 0dai sync --force to regenerate")
298
+ print(f" -> Or: 0dai drift accept {f['config_file']}")
299
+ elif f["drift_type"] == "missing":
300
+ print(f" \u274c MISSING: {f['config_file']}")
301
+ print(" -> Run: 0dai sync --force to regenerate")
302
+ elif f["drift_type"] == "extra":
303
+ print(f" \u2139 EXTRA: {f['config_file']}")
304
+ print(f" Not generated by 0dai. Run: 0dai drift accept {f['config_file']}")
305
+ elif f["drift_type"] == "contradicts":
306
+ print(f" \U0001f534 CONTRADICTS: {f['description']}")
307
+ print(" Agents will receive conflicting instructions")
308
+ print(" -> Run: 0dai sync --force to resolve")
309
+ print()
310
+
311
+ print(f"Drift score: {summary['drifted']}/{summary['total_configs']} configs drifted")
312
+ if summary["contradictions"]:
313
+ print(f"Contradictions: {summary['contradictions']}")
314
+ return 1 if summary["contradictions"] else 0
315
+
316
+
317
+ def cmd_accept(target: pathlib.Path, config_name: str) -> int:
318
+ result = accept_drift(target, config_name)
319
+ if result["ok"]:
320
+ print(f"Accepted {config_name} as new baseline. Drift cleared.")
321
+ return 0
322
+ print(result["error"])
323
+ return 1
324
+
325
+
326
+ def cmd_show(target: pathlib.Path, config_name: str) -> int:
327
+ hashes = load_hashes(target)
328
+ recorded = hashes.get(config_name)
329
+ fp = target / config_name
330
+ if not fp.is_file():
331
+ print(f"{config_name} does not exist.")
332
+ return 1
333
+ if not recorded:
334
+ print(f"{config_name} has no recorded baseline (not generated by 0dai).")
335
+ return 0
336
+ current = compute_file_hash(fp)
337
+ if current == recorded.get("hash", ""):
338
+ print(f"{config_name}: no drift (matches generated version).")
339
+ return 0
340
+ print(f"{config_name}: DRIFTED")
341
+ print(f" Generated: {recorded.get('generated_at', '?')}")
342
+ print(f" Hash: {recorded.get('hash', '?')} -> {current}")
343
+ print("\n Run: 0dai sync --force to regenerate")
344
+ print(f" Or: 0dai drift accept {config_name}")
345
+ return 1
346
+
347
+
348
+ def main() -> None:
349
+ target = pathlib.Path(".")
350
+ args = sys.argv[1:]
351
+ command = "report"
352
+ config_name = ""
353
+
354
+ i = 0
355
+ while i < len(args):
356
+ if args[i] == "--target" and i + 1 < len(args):
357
+ target = pathlib.Path(args[i + 1]).resolve()
358
+ i += 2
359
+ elif args[i] == "accept" and i + 1 < len(args):
360
+ command = "accept"
361
+ config_name = args[i + 1]
362
+ i += 2
363
+ elif args[i] == "show" and i + 1 < len(args):
364
+ command = "show"
365
+ config_name = args[i + 1]
366
+ i += 2
367
+ elif args[i] == "record":
368
+ command = "record"
369
+ i += 1
370
+ elif args[i] == "report":
371
+ command = "report"
372
+ i += 1
373
+ else:
374
+ i += 1
375
+
376
+ if command == "report":
377
+ sys.exit(cmd_drift_report(target))
378
+ elif command == "accept":
379
+ sys.exit(cmd_accept(target, config_name))
380
+ elif command == "show":
381
+ sys.exit(cmd_show(target, config_name))
382
+ elif command == "record":
383
+ count = record_all_hashes(target)
384
+ print(f"Recorded hashes for {count} configs.")
385
+
386
+
387
+ if __name__ == "__main__":
388
+ main()