@0dai-dev/cli 4.3.6 → 4.3.8

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 (77) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +133 -33
  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 +707 -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 +298 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/play.js +20 -4
  18. package/lib/commands/provider.js +30 -59
  19. package/lib/commands/quota.js +1 -1
  20. package/lib/commands/receipt.js +1 -1
  21. package/lib/commands/run.js +14 -6
  22. package/lib/commands/runner.js +31 -1
  23. package/lib/commands/status.js +176 -11
  24. package/lib/commands/swarm.js +130 -12
  25. package/lib/commands/trust.js +1 -1
  26. package/lib/commands/update.js +184 -38
  27. package/lib/commands/usage.js +1 -1
  28. package/lib/commands/validate.js +32 -3
  29. package/lib/commands/vault.js +43 -8
  30. package/lib/python/__init__.py +0 -0
  31. package/lib/python/agent_quotas.py +525 -0
  32. package/lib/python/anomaly_alert.py +397 -0
  33. package/lib/python/anti_pattern_detector.py +799 -0
  34. package/lib/python/auth.py +443 -0
  35. package/lib/python/capi_profile_guard.py +477 -0
  36. package/lib/python/compliance_report.py +581 -0
  37. package/lib/python/drift_detector.py +388 -0
  38. package/lib/python/experience_pipeline.py +1130 -0
  39. package/lib/python/graph.py +19 -0
  40. package/lib/python/graph_core.py +293 -0
  41. package/lib/python/graph_io.py +179 -0
  42. package/lib/python/graph_legacy.py +2052 -0
  43. package/lib/python/graph_legacy_helpers.py +221 -0
  44. package/lib/python/graph_outcomes_core.py +85 -0
  45. package/lib/python/graph_queries.py +171 -0
  46. package/lib/python/graph_slice.py +198 -0
  47. package/lib/python/graph_slicer.py +576 -0
  48. package/lib/python/graph_slicer_cli.py +60 -0
  49. package/lib/python/graph_validation.py +64 -0
  50. package/lib/python/heatmap.py +943 -0
  51. package/lib/python/json_utils.py +193 -0
  52. package/lib/python/mcp_exposure_check.py +247 -0
  53. package/lib/python/model_router.py +1434 -0
  54. package/lib/python/project_manager.py +621 -0
  55. package/lib/python/provider_profiles.py +1618 -0
  56. package/lib/python/provider_registry.py +1211 -0
  57. package/lib/python/provider_registry_cli.py +125 -0
  58. package/lib/python/receipt_png.py +727 -0
  59. package/lib/python/structural_memory.py +325 -0
  60. package/lib/python/swarm_cost.py +177 -0
  61. package/lib/python/usage_ledger.py +569 -0
  62. package/lib/scripts/mcp_tier_config.py +240 -0
  63. package/lib/shared.js +96 -12
  64. package/lib/tui/index.mjs +35174 -0
  65. package/lib/utils/activation_telemetry.js +1 -4
  66. package/lib/utils/constants.js +7 -1
  67. package/lib/utils/identity.js +184 -0
  68. package/lib/utils/mcp-auth.js +81 -15
  69. package/lib/utils/plan.js +1 -1
  70. package/lib/vault/index.js +19 -3
  71. package/lib/vault/storage.js +21 -2
  72. package/lib/wizard.js +5 -2
  73. package/package.json +9 -3
  74. package/scripts/build-python-bundle.js +106 -0
  75. package/scripts/build-tui.js +14 -1
  76. package/scripts/harvest_experience.py +523 -0
  77. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,193 @@
1
+ """Shared JSON/JSONL file utilities for 0dai scripts.
2
+
3
+ Consolidates duplicated _load_json/_save_json/_load_jsonl patterns.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import fcntl
8
+ import gzip
9
+ import json
10
+ import logging
11
+ import os
12
+ import pathlib
13
+ import shutil
14
+ import threading
15
+ import time
16
+ from collections.abc import Iterator
17
+ from typing import Any, Callable
18
+
19
+ log = logging.getLogger("0dai.json_utils")
20
+
21
+ _locks: dict[pathlib.Path, threading.Lock] = {}
22
+ _locks_mutex = threading.Lock()
23
+
24
+
25
+ def _get_lock(path: pathlib.Path) -> threading.Lock:
26
+ """Get or create a threading.Lock for a specific file path."""
27
+ abs_path = path.resolve()
28
+ with _locks_mutex:
29
+ if abs_path not in _locks:
30
+ _locks[abs_path] = threading.Lock()
31
+ return _locks[abs_path]
32
+
33
+
34
+ def load_json(path: pathlib.Path) -> dict:
35
+ """Load JSON file, return empty dict on missing.
36
+ Backs up corrupted files before returning {} on invalid JSON.
37
+ """
38
+ if not path.is_file():
39
+ return {}
40
+
41
+ try:
42
+ return json.loads(path.read_text("utf-8"))
43
+ except json.JSONDecodeError as e:
44
+ ts = int(time.time())
45
+ backup = path.with_suffix(f".corrupt-{ts}.json")
46
+ try:
47
+ shutil.copy2(path, backup)
48
+ log.error("load_json: corrupted %s backed up to %s: %s", path, backup, e)
49
+ except OSError as backup_err:
50
+ log.critical("load_json: %s corrupted AND backup failed: %s + %s", path, e, backup_err)
51
+ raise
52
+ return {}
53
+ except OSError as e:
54
+ log.warning("load_json: cannot read %s: %s", path, e)
55
+ return {}
56
+
57
+
58
+ def save_json(path: pathlib.Path, data: dict) -> None:
59
+ """Atomic JSON write via temp file rename."""
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ tmp = path.with_suffix(".tmp")
62
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", "utf-8")
63
+ tmp.rename(path)
64
+
65
+
66
+ def update_json(path: pathlib.Path, mutator_fn: Callable[[dict[str, Any]], None]) -> dict[str, Any]:
67
+ """Atomically load, mutate, and save a JSON file under a path-based lock.
68
+
69
+ The mutator_fn receives the loaded dict and should modify it in-place.
70
+ Returns the updated dictionary.
71
+ """
72
+ lock = _get_lock(path)
73
+ with lock:
74
+ data = load_json(path)
75
+ mutator_fn(data)
76
+ save_json(path, data)
77
+ return data
78
+
79
+
80
+ def load_jsonl(path: pathlib.Path) -> list[dict]:
81
+ """Load JSONL file, skip invalid lines."""
82
+ if not path.is_file():
83
+ return []
84
+ entries = []
85
+ for line in path.read_text(encoding="utf-8").splitlines():
86
+ line = line.strip()
87
+ if line:
88
+ try:
89
+ entries.append(json.loads(line))
90
+ except json.JSONDecodeError:
91
+ pass
92
+ return entries
93
+
94
+
95
+ def _loads_jsonl_line(raw: bytes) -> dict | None:
96
+ line = raw.strip()
97
+ if not line:
98
+ return None
99
+ try:
100
+ value = json.loads(line.decode("utf-8", errors="replace"))
101
+ except (UnicodeDecodeError, json.JSONDecodeError):
102
+ return None
103
+ return value if isinstance(value, dict) else None
104
+
105
+
106
+ def iter_jsonl_reverse(path: pathlib.Path, *, chunk_size: int = 64 * 1024) -> Iterator[dict]:
107
+ """Yield valid JSONL dicts from newest to oldest without loading the full file.
108
+
109
+ Large operational logs such as swarm ledgers grow continuously. Status and
110
+ health probes usually need recent rows only; this keeps those hot paths
111
+ bounded by tail size instead of total file size.
112
+ """
113
+ if chunk_size <= 0:
114
+ raise ValueError("chunk_size must be positive")
115
+ if not path.is_file():
116
+ return
117
+ try:
118
+ with path.open("rb") as fh:
119
+ fh.seek(0, os.SEEK_END)
120
+ pos = fh.tell()
121
+ pending = b""
122
+ while pos > 0:
123
+ read_size = min(chunk_size, pos)
124
+ pos -= read_size
125
+ fh.seek(pos)
126
+ chunk = fh.read(read_size)
127
+ parts = (chunk + pending).split(b"\n")
128
+ pending = parts[0]
129
+ for raw in reversed(parts[1:]):
130
+ parsed = _loads_jsonl_line(raw)
131
+ if parsed is not None:
132
+ yield parsed
133
+ parsed = _loads_jsonl_line(pending)
134
+ if parsed is not None:
135
+ yield parsed
136
+ except (OSError, PermissionError):
137
+ return
138
+
139
+
140
+ def tail_jsonl(path: pathlib.Path, max_lines: int) -> list[dict]:
141
+ """Return up to max_lines valid JSONL dicts in chronological order."""
142
+ if max_lines <= 0:
143
+ return []
144
+ rows: list[dict] = []
145
+ for row in iter_jsonl_reverse(path):
146
+ rows.append(row)
147
+ if len(rows) >= max_lines:
148
+ break
149
+ return list(reversed(rows))
150
+
151
+
152
+ def append_jsonl(path: pathlib.Path, entry: dict) -> None:
153
+ """Append one JSON line to a JSONL file (flock-safe for concurrent agents)."""
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ line = json.dumps(entry, ensure_ascii=False) + "\n"
156
+ with open(path, "a", encoding="utf-8") as f:
157
+ fcntl.flock(f, fcntl.LOCK_EX)
158
+ f.write(line)
159
+ fcntl.flock(f, fcntl.LOCK_UN)
160
+
161
+
162
+ def rotate_jsonl(path: pathlib.Path, max_lines: int = 10_000) -> pathlib.Path | None:
163
+ """Rotate JSONL file when it exceeds max_lines.
164
+
165
+ Archives old content to {stem}-YYYY-MM.jsonl.gz and keeps only
166
+ the most recent max_lines // 2 entries in the original file.
167
+ Returns the archive path if rotated, None otherwise.
168
+ """
169
+ if not path.is_file():
170
+ return None
171
+ lines = path.read_text(encoding="utf-8").splitlines()
172
+ if len(lines) <= max_lines:
173
+ return None
174
+
175
+ # Archive all but the most recent half
176
+ keep = max_lines // 2
177
+ archive_lines = lines[:-keep]
178
+ keep_lines = lines[-keep:]
179
+
180
+ month = time.strftime("%Y-%m", time.gmtime())
181
+ archive_name = f"{path.stem}-{month}.jsonl.gz"
182
+ archive_path = path.parent / archive_name
183
+
184
+ # Append to existing archive (monthly) or create new
185
+ with gzip.open(archive_path, "at", encoding="utf-8") as gz:
186
+ gz.write("\n".join(archive_lines) + "\n")
187
+
188
+ # Rewrite current file with recent entries only
189
+ tmp = path.with_suffix(".tmp")
190
+ tmp.write_text("\n".join(keep_lines) + "\n", "utf-8")
191
+ tmp.rename(path)
192
+
193
+ return archive_path
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """Check whether an agent runtime exposes the expected 0dai MCP tools."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import re
11
+ import sys
12
+ from typing import Any
13
+
14
+ REPO_ROOT = pathlib.Path(
15
+ os.environ.get("ODAI_REPO_ROOT", str(pathlib.Path(__file__).resolve().parent.parent))
16
+ )
17
+ CONTRACT_PATH = pathlib.Path(
18
+ os.environ.get(
19
+ "ODAI_MCP_EXPOSURE_CONTRACT",
20
+ str(REPO_ROOT / "ai" / "manifest" / "mcp-exposure-contract.json"),
21
+ )
22
+ )
23
+
24
+
25
+ def _read_json(path: pathlib.Path, default: Any) -> Any:
26
+ try:
27
+ return json.loads(path.read_text(encoding="utf-8"))
28
+ except (json.JSONDecodeError, OSError):
29
+ return default
30
+
31
+
32
+ def load_contract(path: pathlib.Path | None = None) -> dict[str, Any]:
33
+ data = _read_json(path or CONTRACT_PATH, {})
34
+ return data if isinstance(data, dict) else {}
35
+
36
+
37
+ def canonical_tool_name(name: str) -> str:
38
+ """Normalize tool names across MCP namespace formats."""
39
+ name = name.strip()
40
+ if not name:
41
+ return ""
42
+ if "." in name:
43
+ name = name.rsplit(".", 1)[-1]
44
+ if name.startswith("mcp__"):
45
+ parts = [part for part in name.split("__") if part]
46
+ if parts:
47
+ name = parts[-1]
48
+ return name.lstrip("_")
49
+
50
+
51
+ def _as_list(value: Any) -> list[str]:
52
+ if isinstance(value, str):
53
+ return [value]
54
+ if isinstance(value, list):
55
+ return [str(item) for item in value if str(item)]
56
+ return []
57
+
58
+
59
+ def _merge_agent_contract(contract: dict[str, Any], agent: str) -> dict[str, list[str]]:
60
+ agents = contract.get("agents", {})
61
+ if not isinstance(agents, dict):
62
+ return {"required": [], "recommended": []}
63
+ current = agents.get(agent) if isinstance(agents.get(agent), dict) else None
64
+ if current is None:
65
+ current = agents.get("default") if isinstance(agents.get("default"), dict) else {}
66
+ parent: dict[str, Any] = {}
67
+ inherits = current.get("inherits") if isinstance(current, dict) else ""
68
+ if isinstance(inherits, str) and isinstance(agents.get(inherits), dict):
69
+ parent = agents[inherits]
70
+ required = set(_as_list(parent.get("required"))) | set(_as_list(current.get("required")))
71
+ recommended = set(_as_list(parent.get("recommended"))) | set(_as_list(current.get("recommended")))
72
+ return {
73
+ "required": sorted(canonical_tool_name(item) for item in required if item),
74
+ "recommended": sorted(canonical_tool_name(item) for item in recommended if item),
75
+ }
76
+
77
+
78
+ def _load_tier_tool_count() -> int | None:
79
+ scripts_dir = REPO_ROOT / "scripts"
80
+ config_path = scripts_dir / "mcp_tier_config.py"
81
+ if not config_path.is_file():
82
+ return None
83
+ module_name = f"_odai_mcp_exposure_tier_config_{abs(hash(config_path))}"
84
+ spec = importlib.util.spec_from_file_location(module_name, config_path)
85
+ if spec is None or spec.loader is None:
86
+ return None
87
+ old_path = list(sys.path)
88
+ sys.path = [str(scripts_dir), *[item for item in old_path if item != str(scripts_dir)]]
89
+ try:
90
+ module = importlib.util.module_from_spec(spec)
91
+ spec.loader.exec_module(module)
92
+ except Exception: # noqa: BLE001 — tier config load failed
93
+ return None
94
+ finally:
95
+ sys.path = old_path
96
+ return len(set(module.FREE_TOOLS) | set(module.PRO_TOOLS))
97
+
98
+
99
+ def parse_observed_tools(values: list[str] | None = None, observed_file: pathlib.Path | None = None) -> set[str]:
100
+ raw: list[str] = []
101
+ if values:
102
+ for value in values:
103
+ raw.extend([item for item in re.split(r"[\s,]+", value) if item])
104
+ env_value = os.environ.get("ODAI_EXPOSED_MCP_TOOLS", "")
105
+ if env_value:
106
+ raw.extend([item for item in re.split(r"[\s,]+", env_value) if item])
107
+ if observed_file:
108
+ data = _read_json(observed_file, [])
109
+ if isinstance(data, list):
110
+ raw.extend(str(item) for item in data)
111
+ elif isinstance(data, dict):
112
+ for key in ("tools", "tool_names", "observed_tools"):
113
+ raw.extend(_as_list(data.get(key)))
114
+ return {name for name in (canonical_tool_name(item) for item in raw) if name}
115
+
116
+
117
+ def evaluate_exposure(
118
+ *,
119
+ agent: str = "default",
120
+ observed_tools: set[str] | None = None,
121
+ contract: dict[str, Any] | None = None,
122
+ ) -> dict[str, Any]:
123
+ if contract is None:
124
+ contract = load_contract()
125
+ expected = _merge_agent_contract(contract, agent)
126
+ observed = observed_tools or set()
127
+ required = set(expected["required"])
128
+ recommended = set(expected["recommended"])
129
+ agents = contract.get("agents")
130
+ contract_available = isinstance(agents, dict) and bool(agents)
131
+ contract_tool_count = int((contract.get("project_server") or {}).get("expected_tool_count") or 0)
132
+ expected_count = contract_tool_count
133
+ tier_count = _load_tier_tool_count()
134
+ if tier_count:
135
+ expected_count = tier_count
136
+
137
+ if not observed:
138
+ status = "unknown"
139
+ anomaly_type = ""
140
+ blocking = False
141
+ elif not contract_available:
142
+ status = "red"
143
+ anomaly_type = "tooling.mcp_contract_missing"
144
+ blocking = True
145
+ else:
146
+ missing_required = sorted(required - observed)
147
+ missing_recommended = sorted(recommended - observed)
148
+ if missing_required:
149
+ status = "red"
150
+ anomaly_type = "tooling.mcp_required_missing"
151
+ blocking = True
152
+ elif missing_recommended:
153
+ status = "yellow"
154
+ anomaly_type = "tooling.mcp_partial_exposure"
155
+ blocking = False
156
+ elif expected_count <= 0:
157
+ status = "yellow"
158
+ anomaly_type = "tooling.mcp_contract_count_missing"
159
+ blocking = False
160
+ else:
161
+ status = "green"
162
+ anomaly_type = ""
163
+ blocking = False
164
+
165
+ contract_tier_count_match = True
166
+ if tier_count is not None:
167
+ contract_tier_count_match = contract_tool_count == tier_count
168
+ if not contract_tier_count_match and status != "red":
169
+ status = "yellow"
170
+ anomaly_type = "tooling.mcp_contract_tier_drift"
171
+ blocking = False
172
+
173
+ result = {
174
+ "status": status,
175
+ "agent": agent,
176
+ "blocking": blocking,
177
+ "anomaly_type": anomaly_type,
178
+ "required": sorted(required),
179
+ "recommended": sorted(recommended),
180
+ "observed_count": len(observed),
181
+ "expected_tool_count": expected_count,
182
+ "missing_required": sorted(required - observed) if observed else [],
183
+ "missing_recommended": sorted(recommended - observed) if observed else [],
184
+ "observed_required": sorted(required & observed),
185
+ "observed_recommended": sorted(recommended & observed),
186
+ "contract_tool_count": contract_tool_count,
187
+ "contract_available": contract_available,
188
+ }
189
+ if tier_count is not None:
190
+ result["tier_config_tool_count"] = tier_count
191
+ result["contract_tier_count_match"] = contract_tier_count_match
192
+ return result
193
+
194
+
195
+ def evaluate_from_env(agent: str = "") -> dict[str, Any]:
196
+ return evaluate_exposure(
197
+ agent=agent or os.environ.get("ODAI_AGENT_KIND") or os.environ.get("ODAI_AGENT") or "default",
198
+ observed_tools=parse_observed_tools(),
199
+ )
200
+
201
+
202
+ def _emit_anomaly(result: dict[str, Any], *, dry_run: bool) -> dict[str, Any] | None:
203
+ anomaly_type = result.get("anomaly_type")
204
+ if not anomaly_type:
205
+ return None
206
+ try:
207
+ import anomaly_alert # noqa: PLC0415
208
+ except ImportError:
209
+ return {"status": "failed", "reason": "anomaly_alert_import_failed"}
210
+ return anomaly_alert.emit_anomaly(
211
+ str(anomaly_type),
212
+ f"MCP exposure check {result['status']} for {result['agent']}",
213
+ source="mcp_exposure_check",
214
+ context={
215
+ "agent": result["agent"],
216
+ "missing_required": result.get("missing_required", []),
217
+ "missing_recommended": result.get("missing_recommended", []),
218
+ "observed_count": result.get("observed_count", 0),
219
+ "expected_tool_count": result.get("expected_tool_count", 0),
220
+ },
221
+ dry_run=dry_run,
222
+ )
223
+
224
+
225
+ def _build_parser() -> argparse.ArgumentParser:
226
+ parser = argparse.ArgumentParser(description=__doc__)
227
+ parser.add_argument("--agent", default="default")
228
+ parser.add_argument("--observed-tool", action="append", default=[])
229
+ parser.add_argument("--observed-file", type=pathlib.Path)
230
+ parser.add_argument("--emit-anomaly", action="store_true")
231
+ parser.add_argument("--dry-run", action="store_true")
232
+ parser.add_argument("--fail-red", action="store_true")
233
+ return parser
234
+
235
+
236
+ def main(argv: list[str] | None = None) -> int:
237
+ args = _build_parser().parse_args(argv)
238
+ observed = parse_observed_tools(args.observed_tool, args.observed_file)
239
+ result = evaluate_exposure(agent=args.agent, observed_tools=observed)
240
+ if args.emit_anomaly:
241
+ result["emitted_anomaly"] = _emit_anomaly(result, dry_run=args.dry_run)
242
+ sys.stdout.write(json.dumps(result, ensure_ascii=True, sort_keys=True) + "\n")
243
+ return 2 if args.fail_red and result["status"] == "red" else 0
244
+
245
+
246
+ if __name__ == "__main__":
247
+ raise SystemExit(main())