@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.
- package/README.md +12 -11
- package/bin/0dai.js +214 -40
- 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 +55 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +545 -26
- package/lib/commands/experience.js +40 -5
- package/lib/commands/export.js +73 -0
- 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 +222 -30
- package/lib/commands/mcp.js +129 -21
- 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 +18 -7
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +44 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +286 -0
- 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 +46 -9
- 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 +97 -14
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +230 -11
- package/lib/utils/constants.js +7 -1
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +198 -1
- 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,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())
|