@0dai-dev/cli 4.3.4 → 4.3.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 0dai
2
2
 
3
- One config layer for <!-- canonical-count:agent_clis_total -->6<!-- /canonical-count --> AI agent CLIs — Claude Code, Codex, OpenCode, Gemini, Aider, Qoder.
3
+ One config layer for <!-- canonical-count:agent_clis_total -->7<!-- /canonical-count --> AI agent CLIs — Claude Code, Codex, OpenCode, Gemini, Aider, Qoder, Cursor.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,22 +8,85 @@ One config layer for <!-- canonical-count:agent_clis_total -->6<!-- /canonical-c
8
8
  npm install -g @0dai-dev/cli
9
9
  ```
10
10
 
11
- ## Usage
11
+ ## Five commands that matter
12
12
 
13
13
  ```bash
14
14
  cd your-project
15
- 0dai init # guides OAuth/device auth + activation, then generates ai/ layer
16
- 0dai init --auth-code <browser-code> --code <TEAM-CODE>
17
- 0dai auth login # sign in separately when needed
18
- 0dai activate code <TEAM-CODE>
19
- 0dai sync # update after changes
20
- 0dai detect # show detected stack
21
- 0dai doctor # check health
22
- 0dai status # maturity, swarm tasks, session
23
- 0dai feedback retry # retry queued feedback after an API failure
24
- 0dai persona-simulate "topic" # run synthetic focus-group simulation
15
+ 0dai init # create ai/ layer, auth, and MCP bootstrap
16
+ 0dai status # maturity, swarm tasks, and session state
17
+ 0dai sync # refresh ai/ after repo or server changes
18
+ 0dai run "…" # split a backlog item into agent tasks
19
+ 0dai doctor # health check for credentials, drift, and env
25
20
  ```
26
21
 
22
+ ## All commands
23
+
24
+ Start (first 5 minutes):
25
+
26
+ ```bash
27
+ 0dai init # create ai/ layer + MCP [--local] [--dry-run] [--minimal]
28
+ 0dai init --auth-code <code> --code <TEAM-CODE> # non-interactive auth + activation
29
+ 0dai doctor # check health, credentials, and drift [--drift]
30
+ 0dai status # maturity, swarm, and session state [--json]
31
+ 0dai quickstart # auth, init, doctor, and status in one pass
32
+ 0dai detect # show detected stack
33
+ 0dai auth login # OAuth/device flow [--device] [--no-browser] [--code CODE] [--mcp]
34
+ 0dai auth mcp # store MCP token from current auth or device code
35
+ 0dai auth status # account and usage
36
+ 0dai activate free # claim free activation license
37
+ ```
38
+
39
+ Daily (regular work):
40
+
41
+ ```bash
42
+ 0dai sync # update ai/ after repo or server changes [--dry-run] [--yes] [--quiet] [--force]
43
+ 0dai run <goal> # split a backlog item into agent tasks [--dry-run] [--dry-cost] [--agent claude|codex|gemini]
44
+ 0dai swarm status # show queued, active, and done tasks
45
+ 0dai swarm add # queue one task [--task '...' --to agent]
46
+ 0dai swarm-run # add, dispatch, and wait for one swarm task as JSON
47
+ 0dai harvest # convert experience events into candidate lessons
48
+ 0dai watch # live task monitor [--interval N]
49
+ 0dai reflect # session reflection: delivered, delegation, blockers
50
+ 0dai standup # morning voice briefing about overnight agent work
51
+ 0dai feedback push # send feedback to 0dai
52
+ 0dai feedback retry # retry queued feedback after a failed push
53
+ 0dai persona-simulate "topic" # focus-group report and optional issue drafts
54
+ ```
55
+
56
+ Pro / advanced:
57
+
58
+ ```bash
59
+ 0dai init-existing # existing-repo setup alias for init
60
+ 0dai project bind # bind repository to your 0dai account [--json]
61
+ 0dai project status # local project binding and health [--json]
62
+ 0dai graph push # upload local graph (Pro: edges, Free: nodes)
63
+ 0dai graph pull # download server graph and merge locally
64
+ 0dai graph status # local graph stats and sync state
65
+ 0dai ci # portable CI pipelines and AI-MQ [list|plan|mq-status]
66
+ 0dai heatmap # repo treemap: LOC × agent-edit intensity
67
+ 0dai session save # save session for roaming
68
+ 0dai provider # local provider profiles and direct invoke
69
+ 0dai models # model ratings (--fast/--balanced/--deep/--available)
70
+ 0dai quota # agent subscription usage [--refresh] [--json]
71
+ 0dai usage # local token, task, and USD ledger [status|daily|monthly]
72
+ 0dai workspace # tmux workspace sessions (init|up|status)
73
+ 0dai runner # runner/host architecture and queues [--json]
74
+ 0dai report # privacy-safe project reports (preview|push|status)
75
+ 0dai compliance # SOC2/ISO evidence and ADR audit-trail export
76
+ 0dai experience # structured experience events (list|stats|sync|warnings|dismiss)
77
+ 0dai receipt # session receipt PNG [--last|--active|--session ID]
78
+ 0dai boneyard # weekly digest of worst agent moves [--week YYYY-WW|current]
79
+ 0dai gh branch-protection # GitHub branch protection [print|apply|install]
80
+ 0dai import claude-code-agents # import .claude/agents/*.md as personas [--dry-run]
81
+ 0dai auth logout # remove credentials
82
+ 0dai activate code <TEAM-CODE> # redeem Pro/Team activation code
83
+ 0dai activate status # activation and bound-project status
84
+ 0dai redeem <CODE> # redeem a plan upgrade code
85
+ 0dai upgrade # open pricing page (browser or printed URL)
86
+ ```
87
+
88
+ Global flags: `--target PATH`, `--version`, `--help`, `--json`, `--quiet`. See `0dai --help` for the full surface.
89
+
27
90
  ## What it does
28
91
 
29
92
  `0dai init` and `0dai sync` are activation-first. `init` can complete auth and plan activation in one run: browser/OAuth exchange code via `--auth-code`, Team/Pro activation code via `--code` or `--activation-code`, or the interactive OAuth/device-code prompt. After that it binds the project and sends only allowlisted project metadata (file names + package/build manifests) to the API and generates:
package/bin/0dai.js CHANGED
@@ -31,18 +31,58 @@ const { T, R, D, log, VERSION, fs, path, spawnSync, findRepoScript, checkVersion
31
31
  /**
32
32
  * Hot-path Go binary fallback (issue #2424).
33
33
  *
34
- * For read-only hot-path commands (status/doctor/version) we attempt to delegate
35
- * to a Go binary when:
34
+ * For read-only hot-path commands we attempt to delegate to a Go binary when:
36
35
  * 1. ODAI_GO_BIN is set and points at an executable file, OR a binary called
37
36
  * `0dai-go` is found on PATH.
38
- * 2. The binary reports `binary_version` matching the dispatcher VERSION
39
- * (we accept exact match only drift means fall back to Python/Node).
37
+ * 2. The binary reports `dispatcher_compat_version` matching the dispatcher
38
+ * VERSION (legacy binaries may still use `binary_version` for this).
40
39
  * 3. ODAI_GO_DISABLE is NOT set to a truthy value.
40
+ * 4. The command's batch flag is not disabled. SPEC-035 rollback Level 1
41
+ * is `ODAI_GO_BATCH_<N>=0`, which transparently routes the whole batch
42
+ * back to the Node/Python implementation without reinstalling.
43
+ *
44
+ * Only commands listed in GO_HOT_PATH_COMMANDS are eligible for automatic
45
+ * delegation. `status` moved into batch 2 only after the #4098 Go↔Node
46
+ * payload parity proof landed. Base `doctor` moved into the same batch only
47
+ * after #4111 made the local `doctor_checks` shadow contract explicit;
48
+ * `doctor --drift` stays on the full Node implementation.
41
49
  *
42
50
  * If any of these checks fail we silently fall through to the existing
43
51
  * Python/Node implementations. The goal is zero behaviour change when the Go
44
52
  * binary is missing, broken, or version-skewed.
45
53
  */
54
+ const GO_HOT_PATH_COMMANDS = new Set(["version", "status", "doctor"]);
55
+ const GO_HOT_PATH_BATCHES = Object.freeze({
56
+ version: 1,
57
+ status: 2,
58
+ doctor: 2,
59
+ });
60
+
61
+ function goFlagDisabled(raw) {
62
+ if (raw === undefined || raw === null || raw === "") return false;
63
+ const value = String(raw).trim().toLowerCase();
64
+ return ["0", "false", "no", "off"].includes(value);
65
+ }
66
+
67
+ function goCommandEnvName(cmdName) {
68
+ return `ODAI_GO_COMMAND_${String(cmdName).toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
69
+ }
70
+
71
+ function goBatchEnvName(batch) {
72
+ return `ODAI_GO_BATCH_${batch}`;
73
+ }
74
+
75
+ function goBatchEnabled(cmdName) {
76
+ const batch = GO_HOT_PATH_BATCHES[cmdName];
77
+ if (!batch) return false;
78
+ if (goFlagDisabled(process.env[goBatchEnvName(batch)])) return false;
79
+
80
+ // Drill-only per-command rollback override. Batch flags remain the release
81
+ // contract; this override is intentionally narrower for staging drills.
82
+ if (goFlagDisabled(process.env[goCommandEnvName(cmdName)])) return false;
83
+ return true;
84
+ }
85
+
46
86
  function locateGoBinary() {
47
87
  if (process.env.ODAI_GO_DISABLE && process.env.ODAI_GO_DISABLE !== "0") return null;
48
88
  const explicit = process.env.ODAI_GO_BIN;
@@ -68,7 +108,11 @@ function goBinaryCompatible(binPath) {
68
108
  if (res.status !== 0 || !res.stdout) return false;
69
109
  const info = JSON.parse(res.stdout.toString());
70
110
  if (!info || typeof info.binary_version !== "string") return false;
71
- return info.binary_version === VERSION;
111
+ const compatVersion =
112
+ typeof info.dispatcher_compat_version === "string"
113
+ ? info.dispatcher_compat_version
114
+ : info.binary_version;
115
+ return compatVersion === VERSION;
72
116
  } catch {
73
117
  return false;
74
118
  }
@@ -80,6 +124,8 @@ function goBinaryCompatible(binPath) {
80
124
  * caller must fall back to the existing Python/Node path.
81
125
  */
82
126
  function tryGoHotPath(cmdName, target, argv) {
127
+ if (!GO_HOT_PATH_COMMANDS.has(cmdName)) return false;
128
+ if (!goBatchEnabled(cmdName)) return false;
83
129
  const bin = locateGoBinary();
84
130
  if (!bin) return false;
85
131
  if (!goBinaryCompatible(bin)) return false;
@@ -91,7 +137,16 @@ function tryGoHotPath(cmdName, target, argv) {
91
137
  }
92
138
 
93
139
  // Export for tests; harmless at runtime.
94
- module.exports = { locateGoBinary, goBinaryCompatible, tryGoHotPath };
140
+ module.exports = {
141
+ locateGoBinary,
142
+ goBinaryCompatible,
143
+ goBatchEnabled,
144
+ goBatchEnvName,
145
+ goCommandEnvName,
146
+ tryGoHotPath,
147
+ GO_HOT_PATH_BATCHES,
148
+ GO_HOT_PATH_COMMANDS,
149
+ };
95
150
  const { loadCanonicalCounts, mcpToolsLabel } = require("../lib/utils/canonical-counts");
96
151
 
97
152
  // --- Command imports ---
@@ -99,9 +154,13 @@ const { cmdAuthLogin, cmdAuthLogout, cmdRedeem, cmdAuthStatus, cmdAuthMcp, cmdAc
99
154
  const { cmdInit, cmdSync, cmdProjectBind } = require("../lib/commands/init");
100
155
  const { cmdDetect } = require("../lib/commands/detect");
101
156
  const { cmdAudit } = require("../lib/commands/audit");
157
+ const { cmdExport } = require("../lib/commands/export");
158
+ const { cmdMcp } = require("../lib/commands/mcp");
159
+ const { cmdVault } = require("../lib/commands/vault");
102
160
  const { cmdDoctor } = require("../lib/commands/doctor");
103
161
  const { cmdValidate } = require("../lib/commands/validate");
104
162
  const { cmdUpdate } = require("../lib/commands/update");
163
+ const { cmdUpgrade } = require("../lib/commands/upgrade");
105
164
  const { cmdReflect } = require("../lib/commands/reflect");
106
165
  const { cmdMetrics } = require("../lib/commands/metrics");
107
166
  const { cmdHeatmap } = require("../lib/commands/heatmap");
@@ -109,7 +168,7 @@ const { cmdStatus } = require("../lib/commands/status");
109
168
  const { cmdPortfolio } = require("../lib/commands/portfolio");
110
169
  const { cmdRun } = require("../lib/commands/run");
111
170
  const { cmdWatch } = require("../lib/commands/watch");
112
- const { cmdModels } = require("../lib/commands/models");
171
+ const { cmdModels, cmdModelsRecommend } = require("../lib/commands/models");
113
172
  const { cmdSession } = require("../lib/commands/session");
114
173
  const { cmdSwarm, cmdSwarmRun } = require("../lib/commands/swarm");
115
174
  const { cmdStandup } = require("../lib/commands/standup");
@@ -128,15 +187,24 @@ const { cmdPaste } = require("../lib/commands/paste");
128
187
  const { cmdReceipt } = require("../lib/commands/receipt");
129
188
  const { cmdBoneyard } = require("../lib/commands/boneyard");
130
189
  const { cmdQuota } = require("../lib/commands/quota");
190
+ const { cmdUsage } = require("../lib/commands/usage");
131
191
  const { cmdGh } = require("../lib/commands/gh");
132
192
  const { cmdLoop } = require("../lib/commands/loop");
133
193
  const { cmdImportClaudeCodeAgents } = require("../lib/commands/import_claude_code_agents");
134
194
  const { cmdRunner } = require("../lib/commands/runner");
135
195
  const { cmdCi } = require("../lib/commands/ci");
196
+ const { cmdTrust } = require("../lib/commands/trust");
136
197
 
137
198
  function printHelp() {
138
199
  const counts = loadCanonicalCounts();
139
200
  console.log(`\n ${T}0dai${R} v${VERSION} — One config for ${counts.agent_clis_total} AI agent CLIs · ${mcpToolsLabel(counts)}\n`);
201
+ console.log("First-run sequence (canonical):");
202
+ console.log(" npm install -g @0dai-dev/cli # install once, globally");
203
+ console.log(" 0dai auth login # sign in (OAuth / device code)");
204
+ console.log(" 0dai activate free # claim free-tier license");
205
+ console.log(" 0dai init # generate ai/ layer in cwd");
206
+ console.log(" 0dai doctor # verify health and drift");
207
+ console.log("");
140
208
  console.log("Start (first 5 minutes):");
141
209
  console.log(" init Create ai/ layer + MCP [--local] [--dry-run] [--minimal]");
142
210
  console.log(" doctor Check health, credentials, and drift [--drift]");
@@ -150,7 +218,7 @@ function printHelp() {
150
218
  console.log("");
151
219
  console.log("Daily (regular work):");
152
220
  console.log(" sync Update ai/ layer after repo or server changes [--dry-run] [--yes] [--quiet] [--force]");
153
- console.log(" run <goal> Split a backlog item into agent tasks [--dry-run] [--agent claude|codex|gemini] [--provider X]");
221
+ console.log(" run <goal> Split a backlog item into agent tasks [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider X]");
154
222
  console.log(" swarm status Show queued, active, and done tasks");
155
223
  console.log(" swarm add Queue one task for an agent [--task '...' --to agent]");
156
224
  console.log(" swarm-run Add, dispatch, and wait for one swarm task as JSON");
@@ -162,21 +230,26 @@ function printHelp() {
162
230
  console.log(" feedback retry Retry queued feedback after a failed push");
163
231
  console.log("");
164
232
  console.log("Pro / advanced:");
165
- console.log(" init-existing Existing-repo setup alias for init [--minimal] [--dry-run]");
233
+ console.log(" init-existing Legacy alias for init (older docs / scripted bootstraps); use 'init' [--minimal] [--dry-run]");
166
234
  console.log(" project bind Bind current repository to your 0dai account [--json]");
167
235
  console.log(" project status Show local project binding and health state [--json]");
168
236
  console.log(" graph push Upload local graph to server (Pro: edges, Free: nodes)");
169
237
  console.log(" graph pull Download server graph and merge locally");
170
238
  console.log(" graph status Show local graph stats and sync state");
171
239
  console.log(" ci Plan portable 0dai CI pipelines and inspect AI-MQ [list|plan|mq-status] [--json]");
240
+ console.log(" mcp MCP server, tools, and health [list|catalog|doctor|call] [--json]");
241
+ console.log(" vault Local age-encrypted secrets vault [init|add|get] [--json]");
172
242
  console.log(" heatmap Repo treemap: LOC x agent-edit intensity");
173
243
  console.log(" session save Save session for roaming");
174
244
  console.log(" provider Local provider profiles, bindings, and direct invoke");
175
245
  console.log(" models Show model ratings (--fast/--balanced/--deep/--available)");
246
+ console.log(" models recommend Ledger-ranked model pick for a task type [--task TYPE] [--goal '...'] [--json]");
176
247
  console.log(" quota Agent subscription usage table [--refresh] [--json]");
248
+ console.log(" usage Local token, task, and USD usage ledger [status|daily|monthly]");
177
249
  console.log(" workspace Manage tmux workspace sessions (init|up|status)");
178
250
  console.log(" runner Show runner/project-host architecture, queues, labels, and burst routing [status|plan|queue-status|label-audit|route-dry-run] [--json]");
179
251
  console.log(" report Privacy-safe project reports (preview|push|status)");
252
+ console.log(" trust Pre-run blast-radius: protected paths, authority matrix, egress [--json]");
180
253
  console.log(" compliance SOC2/ISO evidence and ADR audit-trail export");
181
254
  console.log(" experience Structured experience events (list|stats|sync|warnings|dismiss)");
182
255
  console.log(" persona-simulate Produce a focus-group report and optional issue drafts");
@@ -206,6 +279,8 @@ const SYNC_ALLOWED_FLAGS = new Set([
206
279
  "--force",
207
280
  "--no-diff",
208
281
  "--no-mcp-auth",
282
+ "--skip-link-check",
283
+ "--strict-links",
209
284
  ]);
210
285
 
211
286
  function printSyncHelp() {
@@ -220,6 +295,8 @@ function printSyncHelp() {
220
295
  console.log(" --force Also overwrite native config files from managed ai/ sources");
221
296
  console.log(" --no-diff Hide unified diff output in previews/prompts");
222
297
  console.log(" --no-mcp-auth Skip MCP cloud-token bootstrap during sync");
298
+ console.log(" --skip-link-check Skip the SPEC-028 doc cross-link scan after sync");
299
+ console.log(" --strict-links Fail sync if doc_link_check reports broken or closed refs");
223
300
  console.log(" --target PATH Run sync against another project path");
224
301
  console.log("");
225
302
  }
@@ -281,6 +358,7 @@ async function main() {
281
358
  case "run": await cmdRun(args[1] || "", target, args.slice(2)); break;
282
359
  case "watch": cmdWatch(target, args.slice(1)); break;
283
360
  case "audit": cmdAudit(target); break;
361
+ case "export": await cmdExport(target, args); break;
284
362
  case "security": {
285
363
  const subSec = args[1] || "";
286
364
  if (subSec === "install-hook") {
@@ -324,7 +402,7 @@ async function main() {
324
402
  if (!driftMode) {
325
403
  tryGoHotPath("doctor", target, args.slice(1));
326
404
  }
327
- cmdDoctor(target, { drift: driftMode });
405
+ cmdDoctor(target, { drift: driftMode, json: args.includes("--json") });
328
406
  if (args.includes("--drift")) {
329
407
  const ds = findRepoScript(target, "drift_detector.py");
330
408
  console.log("\n drift report:");
@@ -352,6 +430,7 @@ async function main() {
352
430
  case "validate": cmdValidate(target); break;
353
431
  case "reflect": cmdReflect(target, args); break;
354
432
  case "update": cmdUpdate(args); break;
433
+ case "upgrade": cmdUpgrade(); break;
355
434
  case "metrics": cmdMetrics(target); break;
356
435
  case "heatmap": cmdHeatmap(target, args.slice(1)); break;
357
436
  case "portfolio": cmdPortfolio(); break;
@@ -377,6 +456,8 @@ async function main() {
377
456
  else if (sub === "code" || sub === "redeem") await cmdRedeem(args[2]);
378
457
  else console.log("Usage: 0dai activate [free|status|code <CODE>]");
379
458
  break;
459
+ case "mcp": cmdMcp(target, sub, args); break;
460
+ case "vault": cmdVault(target, args[1], args.slice(2)); break;
380
461
  case "session": cmdSession(target, sub, args); break;
381
462
  case "swarm": cmdSwarm(target, sub, args); break;
382
463
  case "workspace": cmdWorkspace(target, sub, args.slice(2)); break;
@@ -408,6 +489,7 @@ async function main() {
408
489
  case "receipt": cmdReceipt(target, args.slice(1)); break;
409
490
  case "boneyard": cmdBoneyard(target, args.slice(1)); break;
410
491
  case "quota": case "quotas": cmdQuota(target, args.slice(1)); break;
492
+ case "usage": cmdUsage(target, args.slice(1)); break;
411
493
  case "gh": await cmdGh(target, sub, args); break;
412
494
  case "loop": cmdLoop(target, sub, args); break;
413
495
  case "import": {
@@ -429,11 +511,15 @@ async function main() {
429
511
  break;
430
512
  }
431
513
  case "report": cmdReport(target, sub, args); break;
514
+ case "trust": cmdTrust(target, args.slice(1)); break;
432
515
  case "compliance": cmdCompliance(target, args.slice(1)); break;
433
516
  case "experience": cmdExperience(target, sub, args); break;
434
517
  case "persona-simulate": cmdPersonaSimulate(target, args.slice(1)); break;
435
518
  case "graph": await cmdGraph(target, sub, args); break;
436
- case "models": cmdModels(sub || args[1]); break;
519
+ case "models":
520
+ if (sub === "recommend") await cmdModelsRecommend(target, args.slice(2));
521
+ else cmdModels(sub || args[1]);
522
+ break;
437
523
  case "delegate": case "delegation": {
438
524
  const deScript = findRepoScript(target, "delegation_engine.py");
439
525
  if (!deScript) { log("delegation engine unavailable"); break; }
@@ -133,16 +133,19 @@ async function loginWithExchangeCode(code) {
133
133
  if (!exchanged || exchanged.error || !exchanged.access_token) {
134
134
  throw new Error(exchanged && exchanged.error ? exchanged.error : "invalid or expired auth code");
135
135
  }
136
+ const status = await fetchAuthStatus(exchanged.access_token);
137
+ if (!status || status.error || !status.email) {
138
+ throw new Error(status && status.error ? status.error : "cloud validation failed after auth exchange");
139
+ }
136
140
  saveAuthState({
137
141
  access_token: exchanged.access_token,
138
- email: exchanged.email || "",
139
- name: exchanged.name || "",
142
+ email: status.email,
143
+ plan: status.plan || "free",
144
+ name: status.name || exchanged.name || "",
145
+ license: status.license || {},
146
+ plan_expires_at: status.plan_expires_at || "",
140
147
  authenticated_at: new Date().toISOString(),
141
148
  });
142
- const status = await fetchAuthStatus();
143
- if (!status || status.error || !status.email) {
144
- throw new Error(status && status.error ? status.error : "cloud validation failed after auth exchange");
145
- }
146
149
  return status;
147
150
  }
148
151
 
@@ -478,6 +481,54 @@ async function cmdAuthStatus() {
478
481
  }
479
482
  }
480
483
 
484
+ // Disclosure text for the remote activation event.
485
+ // Exported so tests can assert the notice references the real payload fields.
486
+ const ACTIVATION_TELEMETRY_NOTICE =
487
+ "Telemetry: on activation, 0dai sends an event (free_tier_activated, timestamp, "
488
+ + "path=cli://activate-free, source=cli) to api.0dai.dev/v1/events via your "
489
+ + "authenticated session with a device identifier (X-Device-ID header). "
490
+ + "To opt out: set DO_NOT_TRACK=1 or ODAI_NO_TELEMETRY=1.";
491
+
492
+ /**
493
+ * Post a best-effort activation event to the remote analytics endpoint.
494
+ *
495
+ * Opt-out: set DO_NOT_TRACK=1 or ODAI_NO_TELEMETRY=1 in the environment.
496
+ * When opted out, the local activation log (ai/meta/telemetry/activation.jsonl)
497
+ * is NOT affected — only the remote HTTP POST is skipped.
498
+ *
499
+ * @param {object} [deps]
500
+ * @param {Function} [deps.apiCallFn] - injectable stand-in for apiCall (tests)
501
+ * @param {object} [deps.env] - injectable env (defaults to process.env)
502
+ * @param {Function} [deps.print] - injectable print fn (defaults to console.log)
503
+ */
504
+ async function trackFreeTierActivated(deps = {}) {
505
+ const env = deps.env || process.env;
506
+ const print = deps.print || console.log;
507
+ const callFn = deps.apiCallFn || apiCall;
508
+
509
+ // Honour standard cross-ecosystem opt-out AND 0dai-specific form.
510
+ if (env.DO_NOT_TRACK === "1" || env.ODAI_NO_TELEMETRY === "1") {
511
+ return; // remote POST skipped; local log is unaffected
512
+ }
513
+
514
+ // One-time honest disclosure: what is sent, where, and how to opt out.
515
+ print(ACTIVATION_TELEMETRY_NOTICE);
516
+
517
+ try {
518
+ await callFn("/v1/events", {
519
+ events: [{
520
+ event: "free_tier_activated",
521
+ timestamp: new Date().toISOString(),
522
+ path: "cli://activate-free",
523
+ page: "0dai activate free",
524
+ props: { source: "cli" },
525
+ }],
526
+ });
527
+ } catch {
528
+ // best-effort telemetry — network failure is non-fatal
529
+ }
530
+ }
531
+
481
532
  async function cmdActivateFree() {
482
533
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
483
534
  await ensureAuthenticated("activation");
@@ -485,6 +536,9 @@ async function cmdActivateFree() {
485
536
  log(`license ${license.status}`);
486
537
  console.log(` activation id: ${license.activation_id}`);
487
538
  console.log(` plan: ${license.plan || "free"}`);
539
+ if (license.status === "active") {
540
+ await trackFreeTierActivated();
541
+ }
488
542
  }
489
543
 
490
544
  async function cmdActivateStatus() {
@@ -520,4 +574,6 @@ module.exports = {
520
574
  parseActivationArgs,
521
575
  parseAuthLoginFlags,
522
576
  printDeviceLoginInstructions,
577
+ trackFreeTierActivated,
578
+ ACTIVATION_TELEMETRY_NOTICE,
523
579
  };
@@ -1,15 +1,21 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
- const { D, R, log, apiCall, collectMetadata } = shared;
3
+ const { D, R, log, apiCall, collectMetadata, buildProjectIdentity, hashManifestFiles } = shared;
4
4
 
5
5
  async function cmdDetect(target) {
6
6
  const OPTIONAL_CLIS = ["gemini", "aider", "opencode"];
7
- const { projectFiles, manifestContents, clis: localClis } = collectMetadata(target);
8
- // Send file contents AND local CLI inventory so server can do content-based detection
7
+ const metadata = collectMetadata(target);
8
+ const { projectFiles, manifestContents, clis: localClis } = metadata;
9
+ const identity = buildProjectIdentity(target, metadata);
10
+ // Privacy: send only filenames + SHA-256 hashes, never raw content (closes #4016).
11
+ // Local stack detection is pre-computed and passed as `stack`; server works from
12
+ // project_files + client-derived stack, not file content.
9
13
  const result = await apiCall("/v1/detect", {
10
14
  project_files: projectFiles,
11
- manifest_contents: manifestContents,
15
+ manifest_files: hashManifestFiles(manifestContents),
12
16
  available_clis: localClis,
17
+ project_name: identity.project_name,
18
+ stack: identity.stack,
13
19
  });
14
20
  if (result.error) { log(`error: ${result.error}`); return; }
15
21
  console.log(`stack: ${result.stack || "?"}`);
@@ -2,6 +2,34 @@
2
2
  const shared = require("../shared");
3
3
  const { log, T, R, D, fs, path, spawnSync, findRepoScript, SUPPORTED_CLIS, recordExperienceEvent } = shared;
4
4
 
5
+ const LOCAL_LAYER_CHECKS = Object.freeze([
6
+ ["ai/VERSION", "ai/VERSION", "error"],
7
+ ["ai/manifest/project.yaml", "ai/manifest/project.yaml", "error"],
8
+ ["ai/manifest/discovery.json", "ai/manifest/discovery.json", "warn"],
9
+ ["ai/manifest/commands.yaml", "ai/manifest/commands.yaml", "warn"],
10
+ [".claude/settings.json", ".claude/settings.json", "warn"],
11
+ ["AGENTS.md", "AGENTS.md", "warn"],
12
+ ]);
13
+
14
+ function nodePtyProbe() {
15
+ try {
16
+ require.resolve("node-pty");
17
+ return {
18
+ name: "node-pty",
19
+ present: true,
20
+ sev: "ok",
21
+ hint: "node-pty available for terminal session spawn",
22
+ };
23
+ } catch {
24
+ return {
25
+ name: "node-pty",
26
+ present: false,
27
+ sev: "warn",
28
+ hint: "install node-pty for terminal sessions: npm i -g node-pty",
29
+ };
30
+ }
31
+ }
32
+
5
33
  function ghostAuthStatus(home = process.env.HOME || process.env.USERPROFILE || "") {
6
34
  const customApiEnv = home ? path.join(home, ".custom-api", "custom-api.env") : "";
7
35
  const legacyAnthropicEnv = home ? path.join(home, ".claude-api-isolated", "anthropic.env") : "";
@@ -20,9 +48,35 @@ function ghostAuthStatus(home = process.env.HOME || process.env.USERPROFILE || "
20
48
  };
21
49
  }
22
50
 
51
+ function collectLayerChecks(target) {
52
+ return LOCAL_LAYER_CHECKS.map(([name, relPath, missingSev]) => {
53
+ const fullPath = path.join(target, relPath);
54
+ const present = fs.existsSync(fullPath);
55
+ return {
56
+ name,
57
+ ok: present,
58
+ sev: present ? "ok" : missingSev,
59
+ hint: present ? "present" : `missing: ${fullPath}`,
60
+ };
61
+ });
62
+ }
63
+
64
+ function collectDoctorPayload(target) {
65
+ return {
66
+ binary: "node",
67
+ binary_version: shared.VERSION,
68
+ checks: collectLayerChecks(target),
69
+ };
70
+ }
71
+
23
72
  function cmdDoctor(target, options = {}) {
24
73
  const ai = path.join(target, "ai");
25
74
  if (!fs.existsSync(ai)) { log("No 0dai config found. Run: 0dai init"); return; }
75
+ if (options.json) {
76
+ const payload = collectDoctorPayload(target);
77
+ console.log(JSON.stringify(payload, null, 2));
78
+ return payload;
79
+ }
26
80
  let v = "?", stack = "generic";
27
81
  try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
28
82
  try { stack = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")).stack || "generic"; } catch {}
@@ -33,15 +87,6 @@ function cmdDoctor(target, options = {}) {
33
87
  const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
34
88
 
35
89
  // --- ai/ layer checks ---
36
- const layerChecks = {
37
- "ai/VERSION": { path: path.join(ai, "VERSION"), sev: "error" },
38
- "ai/manifest/project.yaml": { path: path.join(ai, "manifest", "project.yaml"), sev: "error" },
39
- "ai/manifest/commands.yaml": { path: path.join(ai, "manifest", "commands.yaml"), sev: "warn" },
40
- "ai/manifest/discovery.json": { path: path.join(ai, "manifest", "discovery.json"),sev: "warn" },
41
- ".claude/settings.json": { path: path.join(target, ".claude", "settings.json"), sev: "warn" },
42
- "AGENTS.md": { path: path.join(target, "AGENTS.md"), sev: "warn" },
43
- };
44
-
45
90
  // --- credentials checklist ---
46
91
  // Detect subscription-based auth (not just env API keys)
47
92
  const { execFileSync: _execFile } = require("child_process");
@@ -99,14 +144,13 @@ function cmdDoctor(target, options = {}) {
99
144
 
100
145
  const missingConfigs = [];
101
146
  console.log(" ai/ layer:");
102
- for (const [name, { path: p, sev }] of Object.entries(layerChecks)) {
103
- const exists = fs.existsSync(p);
104
- if (!exists) {
105
- sev === "error" ? errors++ : warnings++;
106
- if (sev === "warn") missingConfigs.push(name);
147
+ for (const check of collectLayerChecks(target)) {
148
+ if (!check.ok) {
149
+ check.sev === "error" ? errors++ : warnings++;
150
+ if (check.sev === "warn") missingConfigs.push(check.name);
107
151
  }
108
- const mark = exists ? `${G}ok${R2}` : sev === "error" ? `${E}MISSING${R2}` : `${W}missing${R2}`;
109
- console.log(` ${mark.padEnd(22)} ${name}`);
152
+ const mark = check.ok ? `${G}ok${R2}` : check.sev === "error" ? `${E}MISSING${R2}` : `${W}missing${R2}`;
153
+ console.log(` ${mark.padEnd(22)} ${check.name}`);
110
154
  }
111
155
  // Explain WHY native configs are missing and what to do
112
156
  if (missingConfigs.length > 0) {
@@ -256,4 +300,4 @@ function cmdDoctor(target, options = {}) {
256
300
  }
257
301
  }
258
302
 
259
- module.exports = { cmdDoctor, ghostAuthStatus };
303
+ module.exports = { cmdDoctor, ghostAuthStatus, nodePtyProbe, collectDoctorPayload, collectLayerChecks };