@0dai-dev/cli 4.3.6 → 4.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +127 -30
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +506 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +209 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/provider.js +30 -59
  18. package/lib/commands/quota.js +1 -1
  19. package/lib/commands/receipt.js +1 -1
  20. package/lib/commands/run.js +14 -6
  21. package/lib/commands/runner.js +31 -1
  22. package/lib/commands/status.js +38 -10
  23. package/lib/commands/swarm.js +130 -12
  24. package/lib/commands/update.js +184 -38
  25. package/lib/commands/usage.js +1 -1
  26. package/lib/commands/validate.js +32 -3
  27. package/lib/commands/vault.js +43 -8
  28. package/lib/python/__init__.py +0 -0
  29. package/lib/python/agent_quotas.py +525 -0
  30. package/lib/python/anomaly_alert.py +397 -0
  31. package/lib/python/anti_pattern_detector.py +799 -0
  32. package/lib/python/auth.py +443 -0
  33. package/lib/python/capi_profile_guard.py +477 -0
  34. package/lib/python/compliance_report.py +581 -0
  35. package/lib/python/drift_detector.py +388 -0
  36. package/lib/python/experience_pipeline.py +1130 -0
  37. package/lib/python/graph.py +19 -0
  38. package/lib/python/graph_core.py +293 -0
  39. package/lib/python/graph_io.py +179 -0
  40. package/lib/python/graph_legacy.py +2052 -0
  41. package/lib/python/graph_legacy_helpers.py +221 -0
  42. package/lib/python/graph_outcomes_core.py +85 -0
  43. package/lib/python/graph_queries.py +171 -0
  44. package/lib/python/graph_slice.py +198 -0
  45. package/lib/python/graph_slicer.py +576 -0
  46. package/lib/python/graph_slicer_cli.py +60 -0
  47. package/lib/python/graph_validation.py +64 -0
  48. package/lib/python/heatmap.py +934 -0
  49. package/lib/python/json_utils.py +193 -0
  50. package/lib/python/mcp_exposure_check.py +247 -0
  51. package/lib/python/model_router.py +1434 -0
  52. package/lib/python/project_manager.py +621 -0
  53. package/lib/python/provider_profiles.py +1618 -0
  54. package/lib/python/provider_registry.py +1211 -0
  55. package/lib/python/provider_registry_cli.py +125 -0
  56. package/lib/python/receipt_png.py +727 -0
  57. package/lib/python/structural_memory.py +325 -0
  58. package/lib/python/swarm_cost.py +177 -0
  59. package/lib/python/usage_ledger.py +569 -0
  60. package/lib/scripts/mcp_tier_config.py +240 -0
  61. package/lib/shared.js +95 -12
  62. package/lib/tui/index.mjs +35174 -0
  63. package/lib/utils/activation_telemetry.js +1 -4
  64. package/lib/utils/constants.js +7 -1
  65. package/lib/utils/identity.js +184 -0
  66. package/lib/utils/mcp-auth.js +81 -15
  67. package/lib/utils/plan.js +1 -1
  68. package/lib/vault/index.js +19 -3
  69. package/lib/vault/storage.js +21 -2
  70. package/lib/wizard.js +5 -2
  71. package/package.json +9 -3
  72. package/scripts/build-python-bundle.js +106 -0
  73. package/scripts/build-tui.js +14 -1
  74. package/scripts/harvest_experience.py +523 -0
  75. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,443 @@
1
+ #!/usr/bin/env python3
2
+ """0dai Auth — cloud authentication for team and enterprise features.
3
+
4
+ Core CLI works without auth. Team features require `0dai auth login`.
5
+ Token stored locally at ~/.0dai/auth.json, refreshes weekly.
6
+
7
+ Usage:
8
+ 0dai auth login # authenticate via browser
9
+ 0dai auth login --code XXXX-YYYY # paste code directly
10
+ 0dai auth signup --email <e> # create account
11
+ 0dai auth status # show current auth state
12
+ 0dai auth refresh # refresh token manually
13
+ 0dai auth logout # remove token
14
+ 0dai auth --json # JSON output
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import pathlib
22
+ import sys
23
+ import time
24
+ import urllib.request
25
+ import urllib.error
26
+
27
+ AUTH_DIR = pathlib.Path.home() / ".0dai"
28
+ TOKEN_PATH = AUTH_DIR / "auth.json"
29
+ TOKEN_TTL_SECONDS = 7 * 24 * 3600 # 7 days offline
30
+ API_BASE = os.environ.get("ODAI_API_URL", "https://api.0dai.dev")
31
+ GOTRUE_URL = os.environ.get("ODAI_GOTRUE_URL", "http://localhost:9999")
32
+
33
+
34
+ def _now_iso() -> str:
35
+ return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
36
+
37
+
38
+ def _now_ts() -> float:
39
+ return time.time()
40
+
41
+
42
+ def _api_post_status(endpoint: str, payload: dict, timeout: int = 15) -> tuple[dict | None, bool]:
43
+ """POST to cloud API. Returns ``(response|None, reached)``.
44
+
45
+ ``reached`` is True when the server answered at all — including an HTTP
46
+ error status (a rejection) or an unparseable body — and False only when
47
+ the server was unreachable (DNS/connection/timeout). Callers must not
48
+ treat a rejection as "offline" (#4363).
49
+ """
50
+ url = f"{API_BASE}{endpoint}"
51
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
52
+ req = urllib.request.Request(
53
+ url, data=body,
54
+ headers={"Content-Type": "application/json", "User-Agent": "0dai-cli/0.9"},
55
+ method="POST",
56
+ )
57
+ try:
58
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
59
+ return json.loads(resp.read().decode("utf-8")), True
60
+ except urllib.error.HTTPError:
61
+ return None, True # server reached and rejected the request
62
+ except json.JSONDecodeError:
63
+ return None, True # server answered, just not parseable
64
+ except (urllib.error.URLError, OSError):
65
+ return None, False # unreachable
66
+
67
+
68
+ def _api_post(endpoint: str, payload: dict, timeout: int = 15) -> dict | None:
69
+ """POST to cloud API. Returns response dict or None on failure."""
70
+ result, _reached = _api_post_status(endpoint, payload, timeout=timeout)
71
+ return result
72
+
73
+
74
+ def load_token() -> dict | None:
75
+ if not TOKEN_PATH.is_file():
76
+ return None
77
+ try:
78
+ return json.loads(TOKEN_PATH.read_text(encoding="utf-8"))
79
+ except (json.JSONDecodeError, OSError):
80
+ return None
81
+
82
+
83
+ def save_token(token: dict) -> None:
84
+ AUTH_DIR.mkdir(parents=True, exist_ok=True)
85
+ TOKEN_PATH.write_text(json.dumps(token, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
86
+ try:
87
+ TOKEN_PATH.chmod(0o600)
88
+ except OSError:
89
+ pass
90
+
91
+
92
+ def remove_token() -> None:
93
+ if TOKEN_PATH.is_file():
94
+ TOKEN_PATH.unlink()
95
+
96
+
97
+ def is_authenticated() -> bool:
98
+ token = load_token()
99
+ if not token:
100
+ return False
101
+ expires = token.get("expires_at", 0)
102
+ if isinstance(expires, str):
103
+ try:
104
+ import datetime
105
+ dt = datetime.datetime.fromisoformat(expires.replace("+00:00", "+00:00"))
106
+ expires = dt.timestamp()
107
+ except (ValueError, AttributeError):
108
+ return False
109
+ return _now_ts() < expires
110
+
111
+
112
+ def get_plan() -> str:
113
+ token = load_token()
114
+ if not token or not is_authenticated():
115
+ return "free"
116
+ return token.get("plan", "free")
117
+
118
+
119
+ def check_cloud_tier(required: str) -> bool:
120
+ if required == "free":
121
+ return True
122
+ if not is_authenticated():
123
+ return False
124
+ token = load_token()
125
+ if not token:
126
+ return False
127
+ current = token.get("plan", "free")
128
+ levels = {"free": 0, "team": 1, "enterprise": 2}
129
+ return levels.get(current, 0) >= levels.get(required, 0)
130
+
131
+
132
+ def _validate_code(code: str) -> dict | None:
133
+ """Validate auth code against cloud API with local fallback."""
134
+ code = code.strip().upper()
135
+ parts = code.split("-")
136
+ if len(parts) != 2 or len(parts[0]) != 4 or len(parts[1]) != 4:
137
+ return None
138
+
139
+ # Ask the server. A reachable server that does not validate the code is an
140
+ # explicit rejection — never fall through to an offline grant on a rejected
141
+ # code (that let any well-formed XXXX-YYYY mint team/enterprise) (#4363).
142
+ api_result, reached = _api_post_status("/auth/validate", {"code": code})
143
+ if api_result and api_result.get("token_id"):
144
+ api_result["expires_at"] = api_result.get("expires_at", time.strftime(
145
+ "%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(_now_ts() + TOKEN_TTL_SECONDS),
146
+ ))
147
+ return api_result
148
+ if reached:
149
+ return None # server answered and did not validate the code
150
+
151
+ # Server unreachable: an offline grant is dev-only and opt-in (it confers a
152
+ # plan from the code prefix with no server trust, so it must never be the
153
+ # default for a normal user). Gate it behind ODAI_AUTH_OFFLINE_DEV=1.
154
+ if os.environ.get("ODAI_AUTH_OFFLINE_DEV", "").strip().lower() not in {"1", "true", "yes", "on"}:
155
+ return None
156
+ code_hash = hashlib.sha256(code.encode()).hexdigest()[:12]
157
+ plan = "enterprise" if code.startswith("ENT") else "team"
158
+ return {
159
+ "token_id": f"tok_{code_hash}",
160
+ "user": os.environ.get("USER", "dev") + "@local",
161
+ "team": "Local" if plan == "team" else "Enterprise Local",
162
+ "plan": plan,
163
+ "seats": 999 if plan == "enterprise" else 10,
164
+ "authenticated_at": _now_iso(),
165
+ "expires_at": time.strftime(
166
+ "%Y-%m-%dT%H:%M:%S+00:00",
167
+ time.gmtime(_now_ts() + TOKEN_TTL_SECONDS),
168
+ ),
169
+ "refresh_url": f"{API_BASE}/auth/refresh",
170
+ "mode": "offline",
171
+ }
172
+
173
+
174
+ def _gotrue_post(endpoint: str, payload: dict, timeout: int = 15) -> dict | None:
175
+ """POST to GoTrue auth server."""
176
+ url = f"{GOTRUE_URL}{endpoint}"
177
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
178
+ req = urllib.request.Request(
179
+ url, data=body,
180
+ headers={"Content-Type": "application/json", "User-Agent": "0dai-cli/0.9"},
181
+ method="POST",
182
+ )
183
+ try:
184
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
185
+ return json.loads(resp.read().decode("utf-8"))
186
+ except (urllib.error.URLError, json.JSONDecodeError, OSError):
187
+ return None
188
+
189
+
190
+ def _signup(email: str, password: str = "") -> dict | None:
191
+ """Register via GoTrue, with offline fallback."""
192
+ if not password:
193
+ password = hashlib.sha256(email.encode()).hexdigest()[:16]
194
+
195
+ # Try real GoTrue signup
196
+ result = _gotrue_post("/signup", {"email": email, "password": password})
197
+ if result and result.get("access_token"):
198
+ user = result.get("user", {})
199
+ code_hash = hashlib.sha256(email.encode()).hexdigest()[:8]
200
+ auth_code = f"{code_hash[:4].upper()}-{code_hash[4:8].upper()}"
201
+ return {
202
+ "success": True,
203
+ "user": email,
204
+ "user_id": user.get("id", ""),
205
+ "team": email.split("@", maxsplit=1)[0] + "-team",
206
+ "auth_code": auth_code,
207
+ "access_token": result["access_token"],
208
+ "refresh_token": result.get("refresh_token", ""),
209
+ "message": "Account created via GoTrue.",
210
+ "mode": "gotrue",
211
+ }
212
+
213
+ # Offline fallback
214
+ code_hash = hashlib.sha256(email.encode()).hexdigest()[:8]
215
+ return {
216
+ "success": True,
217
+ "user": email,
218
+ "team": email.split("@", maxsplit=1)[0] + "-team",
219
+ "auth_code": f"{code_hash[:4].upper()}-{code_hash[4:8].upper()}",
220
+ "message": "Account created (offline mode). Use auth code to login.",
221
+ "mode": "offline",
222
+ }
223
+
224
+
225
+ def _refresh_token(token: dict) -> dict | None:
226
+ """Refresh token via GoTrue or extend TTL offline."""
227
+ refresh_tok = token.get("refresh_token", "")
228
+ if refresh_tok:
229
+ result = _gotrue_post("/token?grant_type=refresh_token", {"refresh_token": refresh_tok})
230
+ if result and result.get("access_token"):
231
+ token["access_token"] = result["access_token"]
232
+ token["refresh_token"] = result.get("refresh_token", refresh_tok)
233
+ token["expires_at"] = time.strftime(
234
+ "%Y-%m-%dT%H:%M:%S+00:00",
235
+ time.gmtime(_now_ts() + TOKEN_TTL_SECONDS),
236
+ )
237
+ token["refreshed_at"] = _now_iso()
238
+ token["mode"] = "gotrue"
239
+ return token
240
+
241
+ # Offline: extend TTL locally
242
+ token["expires_at"] = time.strftime(
243
+ "%Y-%m-%dT%H:%M:%S+00:00",
244
+ time.gmtime(_now_ts() + TOKEN_TTL_SECONDS),
245
+ )
246
+ token["refreshed_at"] = _now_iso()
247
+ token["mode"] = "offline"
248
+ return token
249
+
250
+
251
+ def cmd_login(code: str = "") -> None:
252
+ if is_authenticated():
253
+ token = load_token()
254
+ print(f"[0dai] already authenticated as {token.get('user', '?')}")
255
+ print(f"[0dai] plan: {token.get('plan', 'free')} ({token.get('team', '')})")
256
+ print("[0dai] run '0dai auth logout' first to re-authenticate")
257
+ return
258
+
259
+ if not code:
260
+ print("[0dai] Authenticate with 0dai Cloud")
261
+ print("")
262
+ print(f" 1. Go to: {API_BASE}/auth")
263
+ print(" 2. Sign in or create an account")
264
+ print(" 3. Copy the auth code (format: XXXX-YYYY)")
265
+ print("")
266
+ print(" No account yet? Run: 0dai auth signup --email you@example.com")
267
+ print("")
268
+ try:
269
+ code = input("[0dai] Paste auth code: ").strip()
270
+ except (EOFError, KeyboardInterrupt):
271
+ print("\n[0dai] cancelled")
272
+ return
273
+
274
+ if not code:
275
+ print("[0dai] no code provided")
276
+ return
277
+
278
+ result = _validate_code(code)
279
+ if not result:
280
+ print("[0dai] invalid auth code. Format: XXXX-YYYY")
281
+ sys.exit(1)
282
+
283
+ save_token(result)
284
+ mode = f" ({result['mode']})" if result.get("mode") == "offline" else ""
285
+ print(f"[0dai] authenticated as {result['user']}{mode}")
286
+ print(f"[0dai] team: {result['team']}")
287
+ print(f"[0dai] plan: {result['plan']} ({result['seats']} seats)")
288
+ print(f"[0dai] token saved to {TOKEN_PATH}")
289
+ print(f"[0dai] expires: {result['expires_at'][:10]} (auto-refresh when online)")
290
+
291
+
292
+ def cmd_signup(email: str) -> None:
293
+ if not email or "@" not in email:
294
+ print("[0dai] invalid email")
295
+ sys.exit(1)
296
+
297
+ print(f"[0dai] creating account for {email}...")
298
+ result = _signup(email)
299
+ if not result:
300
+ print(f"[0dai] signup failed. Try again or visit {API_BASE}/auth")
301
+ sys.exit(1)
302
+
303
+ mode = f" ({result['mode']})" if result.get("mode") == "offline" else ""
304
+ print(f"[0dai] account created{mode}")
305
+ print(f"[0dai] team: {result.get('team', '?')}")
306
+
307
+ # If GoTrue returned a token, auto-login
308
+ if result.get("access_token"):
309
+ token = {
310
+ "token_id": f"tok_{result.get('user_id', '')[:12]}",
311
+ "user": email,
312
+ "team": result.get("team", ""),
313
+ "plan": "free",
314
+ "seats": 1,
315
+ "authenticated_at": _now_iso(),
316
+ "expires_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(_now_ts() + TOKEN_TTL_SECONDS)),
317
+ "access_token": result["access_token"],
318
+ "refresh_token": result.get("refresh_token", ""),
319
+ "mode": "gotrue",
320
+ }
321
+ save_token(token)
322
+ print(f"[0dai] auto-logged in as {email}")
323
+ print(f"[0dai] token saved to {TOKEN_PATH}")
324
+ else:
325
+ print(f"[0dai] auth code: {result.get('auth_code', '?')}")
326
+ print("")
327
+ print(f" Login now: 0dai auth login --code {result.get('auth_code', '?')}")
328
+
329
+
330
+ def cmd_refresh() -> None:
331
+ token = load_token()
332
+ if not token:
333
+ print("[0dai] not authenticated. Run: 0dai auth login")
334
+ return
335
+
336
+ print("[0dai] refreshing token...")
337
+ new_token = _refresh_token(token)
338
+ if new_token:
339
+ save_token(new_token)
340
+ mode = f" ({new_token['mode']})" if new_token.get("mode") == "offline" else ""
341
+ print(f"[0dai] token refreshed{mode}")
342
+ print(f"[0dai] expires: {new_token.get('expires_at', '?')[:10]}")
343
+ else:
344
+ print("[0dai] refresh failed. Run: 0dai auth login")
345
+
346
+
347
+ def cmd_status() -> None:
348
+ token = load_token()
349
+ if not token:
350
+ print("[0dai] not authenticated")
351
+ print(" Run: 0dai auth login")
352
+ print(" Core CLI works without auth. Team features require authentication.")
353
+ return
354
+
355
+ valid = is_authenticated()
356
+ mode = f" ({token.get('mode', 'cloud')})" if token.get("mode") else ""
357
+ print("Auth Status:")
358
+ print(f" User: {token.get('user', '?')}")
359
+ print(f" Team: {token.get('team', '?')}")
360
+ print(f" Plan: {token.get('plan', 'free')}")
361
+ print(f" Seats: {token.get('seats', '?')}")
362
+ print(f" Status: {'active' if valid else 'EXPIRED'}{mode}")
363
+ print(f" Expires: {token.get('expires_at', '?')[:10]}")
364
+ print(f" Token: {TOKEN_PATH}")
365
+ if not valid:
366
+ print("\n Token expired. Run '0dai auth login' to re-authenticate.")
367
+
368
+
369
+ def cmd_logout() -> None:
370
+ if not TOKEN_PATH.is_file():
371
+ print("[0dai] not authenticated")
372
+ return
373
+ remove_token()
374
+ print("[0dai] logged out. Token removed.")
375
+ print("[0dai] core CLI continues to work. Team features require re-authentication.")
376
+
377
+
378
+ def cmd_json() -> None:
379
+ token = load_token()
380
+ print(json.dumps({
381
+ "authenticated": is_authenticated(),
382
+ "plan": get_plan(),
383
+ "token": token,
384
+ }, indent=2, ensure_ascii=False))
385
+
386
+
387
+ def require_auth(feature_name: str = "This feature") -> None:
388
+ if is_authenticated() and check_cloud_tier("team"):
389
+ return
390
+ print(f"[0dai] {feature_name} requires a Team plan.")
391
+ print("")
392
+ if not is_authenticated():
393
+ print(" Authenticate: 0dai auth login")
394
+ print(" Create account: 0dai auth signup --email you@example.com")
395
+ print(f" Pricing: {API_BASE}/pricing")
396
+ else:
397
+ print(f" Current plan: {get_plan()}")
398
+ print(f" Upgrade: {API_BASE}/pricing")
399
+ print("")
400
+ print(" Free tier: init-existing, sync, doctor, detect, serve, harvest, promote, search, mcp.")
401
+ sys.exit(0)
402
+
403
+
404
+ def main() -> None:
405
+ subcmd = "status"
406
+ code = ""
407
+ email = ""
408
+ json_mode = False
409
+ args = sys.argv[1:]
410
+
411
+ i = 0
412
+ while i < len(args):
413
+ if args[i] == "--code" and i + 1 < len(args):
414
+ code = args[i + 1]
415
+ i += 2
416
+ elif args[i] == "--email" and i + 1 < len(args):
417
+ email = args[i + 1]
418
+ i += 2
419
+ elif args[i] == "--json":
420
+ json_mode = True
421
+ i += 1
422
+ elif args[i] in ("login", "status", "logout", "signup", "refresh"):
423
+ subcmd = args[i]
424
+ i += 1
425
+ else:
426
+ i += 1
427
+
428
+ if json_mode:
429
+ cmd_json()
430
+ elif subcmd == "login":
431
+ cmd_login(code)
432
+ elif subcmd == "signup":
433
+ cmd_signup(email)
434
+ elif subcmd == "refresh":
435
+ cmd_refresh()
436
+ elif subcmd == "logout":
437
+ cmd_logout()
438
+ else:
439
+ cmd_status()
440
+
441
+
442
+ if __name__ == "__main__":
443
+ main()