@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,621 @@
1
+ #!/usr/bin/env python3
2
+ """0dai Project Manager — account binding and pilot analytics for local repos."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import re
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+
18
+ import auth as auth_mod
19
+
20
+ API_BASE = os.environ.get("ODAI_API_URL", "https://api.0dai.dev")
21
+ INSIGHTS_INGEST_ENDPOINT = os.environ.get("ODAI_INSIGHTS_INGEST_ENDPOINT", "/v1/insights/ingest")
22
+ _BINDING_IDENTITY_KEYS = {
23
+ "managed",
24
+ "schema",
25
+ "target",
26
+ "current_target",
27
+ "target_aliases",
28
+ "project_id",
29
+ "project_name",
30
+ "display_name",
31
+ "stack",
32
+ "origin",
33
+ "remote_origin",
34
+ "path_hash",
35
+ "device",
36
+ }
37
+ _REMOTE_CREDENTIAL_RE = re.compile(r"^(https?://)[^@/\s]+@")
38
+ _PROJECT_ID_RE = re.compile(r"^prj_[A-Za-z0-9_-]{8,}$")
39
+
40
+
41
+ def _binding_path(target: pathlib.Path) -> pathlib.Path:
42
+ return target / ".0dai" / "project-binding.json"
43
+
44
+
45
+ def _load_json(path: pathlib.Path) -> dict | None:
46
+ if not path.is_file():
47
+ return None
48
+ try:
49
+ return json.loads(path.read_text(encoding="utf-8"))
50
+ except (json.JSONDecodeError, OSError):
51
+ return None
52
+
53
+
54
+ def _save_json(path: pathlib.Path, data: dict) -> None:
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
57
+
58
+
59
+ def _scrub_remote_url(url: str) -> str:
60
+ """Remove embedded credentials from HTTP(S) git remote URLs."""
61
+ return _REMOTE_CREDENTIAL_RE.sub(r"\1", url)
62
+
63
+
64
+ def _read_project_manifest(target: pathlib.Path) -> dict:
65
+ path = target / "ai" / "manifest" / "project.yaml"
66
+ result: dict[str, str] = {}
67
+ if not path.is_file():
68
+ return result
69
+ try:
70
+ in_project = False
71
+ for raw in path.read_text(encoding="utf-8").splitlines():
72
+ line = raw.strip()
73
+ if not line or line.startswith("#"):
74
+ continue
75
+ top_level = len(raw) == len(raw.lstrip())
76
+ if top_level:
77
+ in_project = False
78
+ if top_level and line == "project:":
79
+ in_project = True
80
+ continue
81
+ if top_level and line.startswith("name:"):
82
+ result["name"] = line.split(":", 1)[1].strip().strip('"').strip("'")
83
+ elif top_level and line.startswith("stack:"):
84
+ result["stack"] = line.split(":", 1)[1].strip().strip('"').strip("'")
85
+ elif top_level and line.startswith("repo:"):
86
+ result["repo"] = line.split(":", 1)[1].strip().strip('"').strip("'")
87
+ elif in_project and line.startswith("name:") and "name" not in result:
88
+ result["name"] = line.split(":", 1)[1].strip().strip('"').strip("'")
89
+ elif in_project and line.startswith("stack:") and "stack" not in result:
90
+ result["stack"] = line.split(":", 1)[1].strip().strip('"').strip("'")
91
+ elif in_project and line.startswith("type:") and "stack" not in result:
92
+ result["stack"] = line.split(":", 1)[1].strip().strip('"').strip("'")
93
+ elif in_project and line.startswith("repo:") and "repo" not in result:
94
+ result["repo"] = line.split(":", 1)[1].strip().strip('"').strip("'")
95
+ except OSError:
96
+ return {}
97
+ return result
98
+
99
+
100
+ def _git_remote_origin(target: pathlib.Path) -> str:
101
+ try:
102
+ result = subprocess.run(
103
+ ["git", "config", "--get", "remote.origin.url"],
104
+ cwd=target,
105
+ check=False,
106
+ capture_output=True,
107
+ text=True,
108
+ )
109
+ except OSError:
110
+ return ""
111
+ if result.returncode != 0:
112
+ return ""
113
+ return _scrub_remote_url(result.stdout.strip())
114
+
115
+
116
+ def _discover_project_name(target: pathlib.Path, manifest: dict[str, str]) -> str:
117
+ if manifest.get("name"):
118
+ return manifest["name"]
119
+ package_json = target / "package.json"
120
+ if package_json.is_file():
121
+ try:
122
+ data = json.loads(package_json.read_text(encoding="utf-8"))
123
+ if data.get("name"):
124
+ return str(data["name"])
125
+ except (json.JSONDecodeError, OSError):
126
+ pass
127
+ go_mod = target / "go.mod"
128
+ if go_mod.is_file():
129
+ try:
130
+ for line in go_mod.read_text(encoding="utf-8").splitlines():
131
+ if line.startswith("module "):
132
+ return line.split()[-1].split("/")[-1]
133
+ except OSError:
134
+ pass
135
+ pyproject = target / "pyproject.toml"
136
+ if pyproject.is_file():
137
+ try:
138
+ for line in pyproject.read_text(encoding="utf-8").splitlines():
139
+ if line.strip().startswith("name"):
140
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
141
+ except OSError:
142
+ pass
143
+ return target.resolve().name
144
+
145
+
146
+ def _discover_stack(target: pathlib.Path, manifest: dict[str, str]) -> str:
147
+ if manifest.get("stack"):
148
+ return manifest["stack"]
149
+ discovery = _load_json(target / "ai" / "manifest" / "discovery.json") or {}
150
+ if discovery.get("stack"):
151
+ return str(discovery["stack"])
152
+ return "unknown"
153
+
154
+
155
+ def _canonical_origin(target: pathlib.Path, manifest: dict[str, str]) -> tuple[str, str]:
156
+ remote = _git_remote_origin(target)
157
+ if remote:
158
+ return remote, "git"
159
+ if manifest.get("repo"):
160
+ return _scrub_remote_url(manifest["repo"]), "manifest"
161
+ return str(target.resolve()), "local"
162
+
163
+
164
+ def _project_id(project_name: str, origin: str) -> str:
165
+ seed = json.dumps({"name": project_name, "origin": origin}, sort_keys=True, ensure_ascii=False)
166
+ return "prj_" + hashlib.sha256(seed.encode()).hexdigest()[:16]
167
+
168
+
169
+ def _identity(target: pathlib.Path) -> dict:
170
+ manifest = _read_project_manifest(target)
171
+ project_name = _discover_project_name(target, manifest)
172
+ stack = _discover_stack(target, manifest)
173
+ origin_value, origin_kind = _canonical_origin(target, manifest)
174
+ return {
175
+ "project_id": _project_id(project_name, origin_value),
176
+ "project_name": project_name,
177
+ "stack": stack,
178
+ "origin": origin_kind,
179
+ "remote_origin": origin_value if origin_kind != "local" else "",
180
+ "path_hash": hashlib.sha256(str(target.resolve()).encode()).hexdigest()[:16],
181
+ "device": socket.gethostname()[:32],
182
+ }
183
+
184
+
185
+ def _valid_project_id(value: object) -> bool:
186
+ return isinstance(value, str) and bool(_PROJECT_ID_RE.match(value.strip()))
187
+
188
+
189
+ def _binding_path_candidates(binding: dict) -> list[str]:
190
+ candidates: list[str] = []
191
+ for key in ("target", "current_target"):
192
+ value = binding.get(key)
193
+ if isinstance(value, str) and value.strip():
194
+ candidates.append(value.strip())
195
+ aliases = binding.get("target_aliases")
196
+ if isinstance(aliases, list):
197
+ for alias in aliases:
198
+ if isinstance(alias, str) and alias.strip():
199
+ candidates.append(alias.strip())
200
+ return candidates
201
+
202
+
203
+ def _binding_target_matches(target: pathlib.Path, binding: dict) -> bool:
204
+ current = pathlib.Path(target).resolve()
205
+ for candidate in _binding_path_candidates(binding):
206
+ try:
207
+ if pathlib.Path(candidate).resolve() == current:
208
+ return True
209
+ except OSError:
210
+ if pathlib.Path(candidate).absolute() == current:
211
+ return True
212
+ return False
213
+
214
+
215
+ def _target_info(target: pathlib.Path, binding: dict | None = None) -> dict:
216
+ current = pathlib.Path(target).absolute()
217
+ canonical = pathlib.Path(target).resolve()
218
+ seen: set[str] = set()
219
+ aliases: list[str] = []
220
+
221
+ def add_alias(value: object) -> None:
222
+ if not isinstance(value, str) or not value.strip():
223
+ return
224
+ resolved = str(pathlib.Path(value).absolute())
225
+ if resolved == str(canonical) or resolved in seen:
226
+ return
227
+ seen.add(resolved)
228
+ aliases.append(resolved)
229
+
230
+ if str(current) != str(canonical):
231
+ add_alias(str(current))
232
+ if binding:
233
+ for candidate in _binding_path_candidates(binding):
234
+ add_alias(candidate)
235
+ return {
236
+ "target": str(canonical),
237
+ "current_target": str(current),
238
+ "target_aliases": aliases,
239
+ }
240
+
241
+
242
+ def _trusted_binding_identity(target: pathlib.Path, binding: dict) -> dict:
243
+ if binding.get("binding_status") != "bound":
244
+ return {}
245
+ if not _binding_target_matches(target, binding):
246
+ return {}
247
+ trusted: dict[str, str] = {}
248
+ if _valid_project_id(binding.get("project_id")):
249
+ trusted["project_id"] = str(binding["project_id"]).strip()
250
+ display_name = str(binding.get("display_name") or binding.get("project_name") or "").strip()
251
+ if display_name:
252
+ trusted["project_name"] = display_name
253
+ return trusted
254
+
255
+
256
+ def _binding_snapshot(target: pathlib.Path) -> dict:
257
+ binding = _load_json(_binding_path(target)) or {}
258
+ identity = _identity(target)
259
+ metadata = {
260
+ key: value
261
+ for key, value in binding.items()
262
+ if key not in _BINDING_IDENTITY_KEYS
263
+ }
264
+ target_info = _target_info(target, binding if _binding_target_matches(target, binding) else None)
265
+ trusted = _trusted_binding_identity(target, binding)
266
+ if trusted.get("project_id"):
267
+ identity["project_id"] = trusted["project_id"]
268
+ if trusted.get("project_name"):
269
+ identity["project_name"] = trusted["project_name"]
270
+ merged = {
271
+ "managed": True,
272
+ "schema": 1,
273
+ **target_info,
274
+ **identity,
275
+ "display_name": identity["project_name"],
276
+ **metadata,
277
+ }
278
+ # A binding whose stored target no longer matches this path (the project was
279
+ # moved/copied) must not report "bound" — the identity already falls back to
280
+ # the path-derived one, so the status must say "moved" and prompt a re-bind,
281
+ # not silently surface a stale project_id/name (#4363).
282
+ if (
283
+ merged.get("binding_status") == "bound"
284
+ and _binding_path_candidates(binding)
285
+ and not _binding_target_matches(target, binding)
286
+ ):
287
+ merged["binding_status"] = "moved"
288
+ merged["binding_reason"] = (
289
+ "binding target does not match this path — project moved or copied; "
290
+ "re-bind to refresh identity"
291
+ )
292
+ merged["binding_next_action"] = "run 0dai project bind"
293
+ if not merged.get("binding_status"):
294
+ merged["binding_status"] = "local-only"
295
+ merged["binding_reason"] = "no .0dai/project-binding.json"
296
+ merged["binding_next_action"] = "run 0dai project bind"
297
+ return merged
298
+
299
+
300
+ def _write_binding(target: pathlib.Path, **overrides: object) -> dict:
301
+ if "remote_origin" in overrides:
302
+ overrides["remote_origin"] = _scrub_remote_url(str(overrides["remote_origin"]))
303
+ current = _binding_snapshot(target)
304
+ current.update(overrides)
305
+ current["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
306
+ _save_json(_binding_path(target), current)
307
+ return current
308
+
309
+
310
+ def _load_access_token() -> tuple[str, dict | None]:
311
+ token = auth_mod.load_token()
312
+ if not token:
313
+ return "", None
314
+ return str(token.get("access_token", "")), token
315
+
316
+
317
+ def _insights_ingest_path(target: pathlib.Path) -> pathlib.Path:
318
+ return target / "ai" / "manifest" / "insights_ingest.json"
319
+
320
+
321
+ def _insights_outbox_path(target: pathlib.Path) -> pathlib.Path:
322
+ return target / "ai" / "manifest" / "insights_outbox.jsonl"
323
+
324
+
325
+ def _append_jsonl(path: pathlib.Path, payload: dict) -> None:
326
+ path.parent.mkdir(parents=True, exist_ok=True)
327
+ with path.open("a", encoding="utf-8") as handle:
328
+ handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
329
+
330
+
331
+ def _load_insights_ingest(target: pathlib.Path) -> dict:
332
+ return _load_json(_insights_ingest_path(target)) or {
333
+ "updated_at": "",
334
+ "total_push_attempts": 0,
335
+ "total_push_success": 0,
336
+ "total_push_failed": 0,
337
+ "last_push": {},
338
+ "recent": [],
339
+ }
340
+
341
+
342
+ def _save_insights_ingest(target: pathlib.Path, data: dict) -> None:
343
+ _save_json(_insights_ingest_path(target), data)
344
+
345
+
346
+ def _api_request(method: str, endpoint: str, token: str, payload: dict | None = None) -> dict:
347
+ url = f"{API_BASE}{endpoint}"
348
+ body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
349
+ headers = {
350
+ "Accept": "application/json",
351
+ "Authorization": f"Bearer {token}",
352
+ "User-Agent": "0dai-cli/project-manager",
353
+ }
354
+ if body is not None:
355
+ headers["Content-Type"] = "application/json"
356
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
357
+ with urllib.request.urlopen(req, timeout=20) as resp:
358
+ return json.loads(resp.read().decode("utf-8"))
359
+
360
+
361
+ def _push_insight_cloud_first(target: pathlib.Path, payload: dict) -> dict:
362
+ result = {
363
+ "attempted": True,
364
+ "ok": False,
365
+ "mode": "local-outbox",
366
+ "reason": "",
367
+ }
368
+ access_token, token = _load_access_token()
369
+ ingest_state = _load_insights_ingest(target)
370
+ ingest_state["total_push_attempts"] = int(ingest_state.get("total_push_attempts", 0) or 0) + 1
371
+
372
+ if not token or not auth_mod.is_authenticated() or not access_token:
373
+ result["reason"] = "not_authenticated"
374
+ else:
375
+ try:
376
+ _api_request("POST", INSIGHTS_INGEST_ENDPOINT, access_token, payload)
377
+ result["ok"] = True
378
+ result["mode"] = "cloud"
379
+ except urllib.error.HTTPError as exc:
380
+ result["reason"] = f"http_error:{exc.code}"
381
+ except (urllib.error.URLError, json.JSONDecodeError, OSError) as exc:
382
+ result["reason"] = str(exc)
383
+
384
+ if result["ok"]:
385
+ ingest_state["total_push_success"] = int(ingest_state.get("total_push_success", 0) or 0) + 1
386
+ else:
387
+ ingest_state["total_push_failed"] = int(ingest_state.get("total_push_failed", 0) or 0) + 1
388
+ _append_jsonl(_insights_outbox_path(target), payload)
389
+
390
+ now = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
391
+ event = {
392
+ "at": now,
393
+ "mode": result["mode"],
394
+ "ok": bool(result["ok"]),
395
+ "reason": result.get("reason", ""),
396
+ "project_id": str((payload.get("source") or {}).get("project_id") or ""),
397
+ }
398
+ recent = list(ingest_state.get("recent") or [])
399
+ recent.insert(0, event)
400
+ ingest_state["recent"] = recent[:20]
401
+ ingest_state["last_push"] = event
402
+ ingest_state["updated_at"] = now
403
+ _save_insights_ingest(target, ingest_state)
404
+ return result
405
+
406
+
407
+ def _validate_project_name(value: str | None) -> tuple[bool, str]:
408
+ name = str(value or "").strip()
409
+ if not name:
410
+ return False, "project name must not be empty"
411
+ if len(name) > 120:
412
+ return False, "project name must be 120 characters or fewer"
413
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in name):
414
+ return False, "project name must not contain control characters"
415
+ return True, name
416
+
417
+
418
+ def _bind_remote(target: pathlib.Path, source: str, project_name: str | None = None) -> tuple[bool, str, dict | None]:
419
+ access_token, token = _load_access_token()
420
+ if not token or not auth_mod.is_authenticated() or not access_token:
421
+ _write_binding(
422
+ target,
423
+ binding_status="local-only",
424
+ binding_source=source,
425
+ last_error="CLI is not authenticated with a cloud access token",
426
+ account_email=token.get("user", "") if token else "",
427
+ )
428
+ return False, "local-only", None
429
+
430
+ payload = _identity(target)
431
+ existing = _load_json(_binding_path(target)) or {}
432
+ trusted = _trusted_binding_identity(target, existing)
433
+ if trusted.get("project_id"):
434
+ payload["project_id"] = trusted["project_id"]
435
+ if project_name:
436
+ ok, normalized_name = _validate_project_name(project_name)
437
+ if not ok:
438
+ return False, normalized_name, None
439
+ payload["project_name"] = normalized_name
440
+ if not trusted.get("project_id"):
441
+ payload["project_id"] = _project_id(normalized_name, payload.get("remote_origin") or str(target.resolve()))
442
+ payload["binding_source"] = source
443
+ try:
444
+ data = _api_request("POST", "/v1/projects/bind", access_token, payload)
445
+ except urllib.error.HTTPError as exc:
446
+ try:
447
+ details = json.loads(exc.read().decode("utf-8"))
448
+ message = details.get("error", str(exc))
449
+ except json.JSONDecodeError:
450
+ message = str(exc)
451
+ _write_binding(
452
+ target,
453
+ binding_status="binding-failed",
454
+ binding_source=source,
455
+ last_error=message,
456
+ account_email=token.get("user", ""),
457
+ )
458
+ return False, message, None
459
+ except (urllib.error.URLError, json.JSONDecodeError, OSError) as exc:
460
+ _write_binding(
461
+ target,
462
+ binding_status="binding-failed",
463
+ binding_source=source,
464
+ last_error=str(exc),
465
+ account_email=token.get("user", ""),
466
+ )
467
+ return False, str(exc), None
468
+
469
+ project = data.get("project", {}) if isinstance(data, dict) else {}
470
+ project_id = project.get("project_id") or payload["project_id"]
471
+ resolved_project_name = project_name or project.get("name") or payload["project_name"]
472
+ saved = _write_binding(
473
+ target,
474
+ project_id=project_id,
475
+ project_name=resolved_project_name,
476
+ display_name=resolved_project_name,
477
+ binding_status="bound",
478
+ binding_source=source,
479
+ bound_at=project.get("bound_at") or time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime()),
480
+ last_sync=project.get("last_sync"),
481
+ activation_id=project.get("activation_id", ""),
482
+ activation_status=project.get("activation_status", ""),
483
+ account_email=data.get("email", token.get("user", "")),
484
+ server_project=project,
485
+ last_error="",
486
+ )
487
+ return True, "bound", saved
488
+
489
+
490
+ def _analytics_snapshot(target: pathlib.Path) -> dict:
491
+ binding = _binding_snapshot(target)
492
+ analytics = _load_json(target / "ai" / "manifest" / "agent-analytics.json") or {}
493
+ runtime = _load_json(target / "ai" / "sessions" / "runtime.json") or {}
494
+ summary = {
495
+ "project": {
496
+ "project_id": binding.get("project_id", ""),
497
+ "name": binding.get("project_name", target.resolve().name),
498
+ "stack": binding.get("stack", "unknown"),
499
+ "binding_status": binding.get("binding_status", "unknown"),
500
+ "account_email": binding.get("account_email", ""),
501
+ },
502
+ "swarm": {
503
+ "active": sum(1 for _ in (target / "ai" / "swarm" / "active").glob("*.json")) if (target / "ai" / "swarm" / "active").is_dir() else 0,
504
+ "done": sum(1 for _ in (target / "ai" / "swarm" / "done").glob("*.json")) if (target / "ai" / "swarm" / "done").is_dir() else 0,
505
+ "queue": sum(1 for _ in (target / "ai" / "swarm" / "queue").glob("*.json")) if (target / "ai" / "swarm" / "queue").is_dir() else 0,
506
+ },
507
+ "runtime": {
508
+ "total_sessions": len((runtime.get("sessions") or {})),
509
+ "updated_at": runtime.get("updated_at", ""),
510
+ },
511
+ "agent_analytics": analytics,
512
+ }
513
+ return summary
514
+
515
+
516
+ def cmd_status(args: argparse.Namespace) -> int:
517
+ target = pathlib.Path(args.target).resolve()
518
+ data = _binding_snapshot(target)
519
+ if args.json:
520
+ print(json.dumps(data, ensure_ascii=False, indent=2))
521
+ else:
522
+ print(f"[0dai] project: {data.get('project_name', target.name)} ({data.get('project_id', '?')})")
523
+ print(f"[0dai] binding: {data.get('binding_status', 'unknown')}")
524
+ if data.get("activation_status"):
525
+ print(f"[0dai] activation: {data.get('activation_status')} ({data.get('activation_id', '')})")
526
+ if data.get("account_email"):
527
+ print(f"[0dai] account: {data['account_email']}")
528
+ print(f"[0dai] stack: {data.get('stack', 'unknown')}")
529
+ if data.get("remote_origin"):
530
+ print(f"[0dai] origin: {data['remote_origin']}")
531
+ if data.get("last_error"):
532
+ print(f"[0dai] error: {data['last_error']}")
533
+ return 0
534
+
535
+
536
+ def cmd_bind(args: argparse.Namespace) -> int:
537
+ target = pathlib.Path(args.target).resolve()
538
+ ok, message, data = _bind_remote(target, args.source, getattr(args, "name", None))
539
+ if args.json:
540
+ print(json.dumps(data or _binding_snapshot(target), ensure_ascii=False, indent=2))
541
+ return 0 if ok else 1
542
+ if ok:
543
+ print(f"[0dai] bound project '{(data or {}).get('project_name', target.name)}' to {(data or {}).get('account_email', '?')}")
544
+ print(f"[0dai] project id: {(data or {}).get('project_id', '?')}")
545
+ return 0
546
+ print(f"[0dai] project binding skipped/failed: {message}", file=sys.stderr)
547
+ return 1
548
+
549
+
550
+ def cmd_auto_bind(args: argparse.Namespace) -> int:
551
+ target = pathlib.Path(args.target).resolve()
552
+ ok, message, _ = _bind_remote(target, args.source)
553
+ if ok:
554
+ print(f"[0dai] project bound ({args.source})")
555
+ else:
556
+ print(f"[0dai] project binding state: {message}")
557
+ return 0
558
+
559
+
560
+ def cmd_analytics(args: argparse.Namespace) -> int:
561
+ target = pathlib.Path(args.target).resolve()
562
+ snapshot = _analytics_snapshot(target)
563
+ if args.push:
564
+ payload = {
565
+ "type": "project_analytics",
566
+ "emitted_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime()),
567
+ "source": {
568
+ "project_id": str((snapshot.get("project") or {}).get("project_id") or ""),
569
+ "project_name": str((snapshot.get("project") or {}).get("name") or target.name),
570
+ "stack": str((snapshot.get("project") or {}).get("stack") or "unknown"),
571
+ "binding_status": str((snapshot.get("project") or {}).get("binding_status") or "unknown"),
572
+ },
573
+ "snapshot": snapshot,
574
+ }
575
+ snapshot["push_result"] = _push_insight_cloud_first(target, payload)
576
+ print(json.dumps(snapshot, ensure_ascii=False, indent=2))
577
+ return 0
578
+
579
+
580
+ def _build_parser() -> argparse.ArgumentParser:
581
+ parser = argparse.ArgumentParser(prog="0dai project")
582
+ sub = parser.add_subparsers(dest="command", required=True)
583
+
584
+ shared = argparse.ArgumentParser(add_help=False)
585
+ shared.add_argument("--target", default=".")
586
+ shared.add_argument("--json", action="store_true")
587
+
588
+ p_status = sub.add_parser("status", parents=[shared], help="Show local project/account binding status")
589
+ p_status.set_defaults(func=cmd_status)
590
+
591
+ p_bind = sub.add_parser("bind", parents=[shared], help="Bind the current project to the authenticated account")
592
+ p_bind.add_argument("--source", default="manual")
593
+ p_bind.add_argument("--name", default=None, help="Update the bound project's human-readable display name")
594
+ p_bind.set_defaults(func=cmd_bind)
595
+
596
+ p_sync = sub.add_parser("sync", parents=[shared], help="Rebind or refresh the current project against the account registry")
597
+ p_sync.add_argument("--source", default="sync")
598
+ p_sync.add_argument("--name", default=None, help="Update the bound project's human-readable display name")
599
+ p_sync.set_defaults(func=cmd_bind)
600
+
601
+ p_auto = sub.add_parser("auto-bind", help=argparse.SUPPRESS)
602
+ p_auto.add_argument("--target", default=".")
603
+ p_auto.add_argument("--source", default="auto")
604
+ p_auto.set_defaults(func=cmd_auto_bind)
605
+
606
+ p_analytics = sub.add_parser("analytics", help="Emit a local pilot analytics snapshot for this project")
607
+ p_analytics.add_argument("--target", default=".")
608
+ p_analytics.add_argument("--push", action="store_true", help="Cloud-first push with local outbox fallback")
609
+ p_analytics.set_defaults(func=cmd_analytics)
610
+
611
+ return parser
612
+
613
+
614
+ def main(argv: list[str] | None = None) -> int:
615
+ parser = _build_parser()
616
+ args = parser.parse_args(argv)
617
+ return int(args.func(args))
618
+
619
+
620
+ if __name__ == "__main__":
621
+ raise SystemExit(main())