@0dai-dev/cli 4.1.0 → 4.3.4

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 (53) hide show
  1. package/README.md +30 -5
  2. package/bin/0dai.js +308 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +404 -122
  5. package/lib/commands/boneyard.js +44 -0
  6. package/lib/commands/ci.js +329 -0
  7. package/lib/commands/compliance.js +20 -0
  8. package/lib/commands/doctor.js +79 -14
  9. package/lib/commands/experience.js +5 -1
  10. package/lib/commands/feedback.js +92 -5
  11. package/lib/commands/gh.js +506 -0
  12. package/lib/commands/graph.js +78 -10
  13. package/lib/commands/heatmap.js +17 -0
  14. package/lib/commands/import_claude_code_agents.js +367 -0
  15. package/lib/commands/init.js +553 -53
  16. package/lib/commands/loop.js +108 -0
  17. package/lib/commands/mcp.js +410 -0
  18. package/lib/commands/models.js +42 -12
  19. package/lib/commands/paste.js +114 -0
  20. package/lib/commands/persona-simulate.js +19 -0
  21. package/lib/commands/play.js +173 -0
  22. package/lib/commands/provider.js +87 -0
  23. package/lib/commands/quota.js +76 -0
  24. package/lib/commands/receipt.js +53 -0
  25. package/lib/commands/report.js +29 -2
  26. package/lib/commands/run.js +44 -4
  27. package/lib/commands/runner.js +527 -0
  28. package/lib/commands/session.js +1 -7
  29. package/lib/commands/ssh.js +416 -0
  30. package/lib/commands/standup.js +40 -0
  31. package/lib/commands/status.js +131 -36
  32. package/lib/commands/swarm.js +97 -4
  33. package/lib/commands/tui.js +117 -0
  34. package/lib/commands/usage.js +87 -0
  35. package/lib/commands/vault.js +246 -0
  36. package/lib/commands/workspace.js +1 -0
  37. package/lib/onboarding.js +30 -10
  38. package/lib/shared.js +153 -96
  39. package/lib/tui/index.mjs +34994 -0
  40. package/lib/utils/auth.js +1 -0
  41. package/lib/utils/canonical-counts.js +54 -0
  42. package/lib/utils/diff-preview.js +192 -0
  43. package/lib/utils/identity.js +76 -18
  44. package/lib/utils/mcp-auth.js +607 -0
  45. package/lib/utils/model_ratings.js +77 -0
  46. package/lib/utils/plan.js +37 -2
  47. package/lib/vault/cipher.js +125 -0
  48. package/lib/vault/identity.js +122 -0
  49. package/lib/vault/index.js +184 -0
  50. package/lib/vault/storage.js +84 -0
  51. package/lib/wizard.js +19 -12
  52. package/package.json +13 -5
  53. package/scripts/build-tui.js +77 -0
@@ -1,16 +1,147 @@
1
1
  "use strict";
2
+ const crypto = require("crypto");
2
3
  const shared = require("../shared");
3
4
  const {
4
- T, R, D, log,
5
+ T, R, D, W, log,
5
6
  fs, path,
6
7
  VERSION, SUPPORTED_CLIS,
7
- apiCall, makeEnsureAuthenticated, ensureLicenseActivation,
8
+ CONFIG_DIR, PROJECTS_FILE,
9
+ apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
8
10
  collectMetadata, buildProjectIdentity, registerProject,
9
- writeFiles, writeManagedFiles, sendProjectHeartbeat, recordExperienceEvent,
11
+ writeFiles, sendProjectHeartbeat, recordExperienceEvent,
12
+ logFirstRunSuccess,
10
13
  } = shared;
11
- const { cmdAuthLogin } = require("./auth");
14
+ const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = require("./auth");
15
+ const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
16
+ const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
17
+ const { bootstrapMcp } = require("../utils/mcp-auth");
12
18
 
13
19
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
20
+ const SYNC_FULL_CONTENT_LIMIT = 10000;
21
+ const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
22
+
23
+ function hashFile(filePath) {
24
+ const hash = crypto.createHash("sha256");
25
+ const fd = fs.openSync(filePath, "r");
26
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
27
+ try {
28
+ let bytesRead = 0;
29
+ do {
30
+ bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
31
+ if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
32
+ } while (bytesRead > 0);
33
+ } finally {
34
+ fs.closeSync(fd);
35
+ }
36
+ return hash.digest("hex");
37
+ }
38
+
39
+ function describeCurrentFile(filePath, stat) {
40
+ if (stat.size < SYNC_FULL_CONTENT_LIMIT) return fs.readFileSync(filePath, "utf8");
41
+ return {
42
+ size: stat.size,
43
+ sha256: hashFile(filePath),
44
+ compare: "hash-only",
45
+ };
46
+ }
47
+
48
+ // detectRegistryDrift — Phase 1.5 of #2237 registry self-heal.
49
+ // Returns { drifted: bool, existingPath?: string, currentPath: string } when an
50
+ // entry with the same `name` is already in ~/.0dai/projects.json but points at
51
+ // a different directory than the one we're about to register. Caller decides
52
+ // what to do (warn, prompt, or auto-overwrite based on flags).
53
+ function detectRegistryDrift(target, name) {
54
+ const currentPath = path.resolve(target);
55
+ try {
56
+ if (!fs.existsSync(PROJECTS_FILE)) return { drifted: false, currentPath };
57
+ const raw = fs.readFileSync(PROJECTS_FILE, "utf8");
58
+ const data = JSON.parse(raw);
59
+ const projects = Array.isArray(data && data.projects) ? data.projects : [];
60
+ for (const entry of projects) {
61
+ if (!entry || typeof entry !== "object") continue;
62
+ if (entry.name !== name) continue;
63
+ if (!entry.path || typeof entry.path !== "string") continue;
64
+ if (entry.path === currentPath) continue;
65
+ return { drifted: true, existingPath: entry.path, currentPath, archived: entry.archived === true };
66
+ }
67
+ } catch {}
68
+ return { drifted: false, currentPath };
69
+ }
70
+
71
+ // guardRegistryDrift — returns true when caller may proceed with registration,
72
+ // false if the operator declined the overwrite. Async to support interactive
73
+ // prompt; honours --non-interactive (and --yes / common CI flags) by
74
+ // auto-overwriting silently. ODAI_REGISTRY_DRIFT_OVERWRITE=1 also bypasses
75
+ // the prompt for scripted runs.
76
+ async function guardRegistryDrift(target, name, args) {
77
+ const drift = detectRegistryDrift(target, name);
78
+ if (!drift.drifted) return true;
79
+ // Archived entries are not a real conflict — registry_audit.py archives
80
+ // missing paths; let init silently overwrite them.
81
+ if (drift.archived) return true;
82
+
83
+ const nonInteractive =
84
+ args.includes("--non-interactive") ||
85
+ args.includes("--yes") ||
86
+ args.includes("-y") ||
87
+ !process.stdout.isTTY ||
88
+ process.env.ODAI_REGISTRY_DRIFT_OVERWRITE === "1";
89
+
90
+ log(`${W}registry drift: project '${name}' is registered at ${drift.existingPath}${R}`);
91
+ console.log(` current cwd: ${drift.currentPath}`);
92
+ if (nonInteractive) {
93
+ console.log(` ${D}auto-overwriting registry entry (non-interactive mode)${R}`);
94
+ return true;
95
+ }
96
+
97
+ try {
98
+ const readline = require("readline");
99
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
100
+ const answer = await new Promise((resolve) => {
101
+ rl.question(` Overwrite registry entry to point at ${drift.currentPath}? [y/N] `, (a) => {
102
+ rl.close();
103
+ resolve(String(a || "").trim().toLowerCase());
104
+ });
105
+ });
106
+ if (answer === "y" || answer === "yes") return true;
107
+ } catch {
108
+ // readline failed (e.g. no TTY) — fall through to skip
109
+ }
110
+ console.log(
111
+ ` ${D}skipping registry update. Set ODAI_REGISTRY_DRIFT_OVERWRITE=1 or pass --non-interactive to overwrite.${R}`,
112
+ );
113
+ return false;
114
+ }
115
+
116
+ function registerProjectOrExit(target, name, stack) {
117
+ try {
118
+ return registerProject(target, name, stack, CONFIG_DIR, PROJECTS_FILE);
119
+ } catch (err) {
120
+ log(`error: failed to update project registry: ${err.message}`);
121
+ process.exit(1);
122
+ }
123
+ }
124
+
125
+ function collectCurrentAiFiles(target) {
126
+ const currentFiles = {};
127
+ const aiDir = path.join(target, "ai");
128
+ if (fs.existsSync(aiDir)) {
129
+ const walk = (dir) => {
130
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
131
+ const p = path.join(dir, f.name);
132
+ if (f.isDirectory()) walk(p);
133
+ else {
134
+ try {
135
+ const stat = fs.statSync(p);
136
+ if (stat.isFile()) currentFiles[path.relative(target, p)] = describeCurrentFile(p, stat);
137
+ } catch {}
138
+ }
139
+ }
140
+ };
141
+ walk(aiDir);
142
+ }
143
+ return currentFiles;
144
+ }
14
145
 
15
146
  // bindProjectForCloud — binds project to cloud via /v1/projects/bind
16
147
  async function bindProjectForCloud(target, metadata, identity) {
@@ -31,24 +162,163 @@ async function bindProjectForCloud(target, metadata, identity) {
31
162
  };
32
163
  }
33
164
 
165
+ function cloudInitCheckpointPath(target) {
166
+ return path.join(target, CLOUD_INIT_CHECKPOINT_REL);
167
+ }
168
+
169
+ function _hasAuthToken() {
170
+ const auth = loadAuthState();
171
+ return !!(auth && (auth.api_key || auth.access_token || auth.token));
172
+ }
173
+
174
+ function _quoteCommandArg(value) {
175
+ const text = String(value || "");
176
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
177
+ return `'${text.replace(/'/g, "'\"'\"'")}'`;
178
+ }
179
+
180
+ function _initCommandName(args = []) {
181
+ const first = String((Array.isArray(args) ? args : [])[0] || "");
182
+ return first === "init-existing" ? "init-existing" : "init";
183
+ }
184
+
185
+ function _resumeArgs(args = []) {
186
+ const raw = Array.isArray(args) ? args : [];
187
+ const withoutCommand = raw[0] === "init" || raw[0] === "init-existing" ? raw.slice(1) : raw.slice();
188
+ const skipWithValue = new Set(["--auth-code", "--oauth-code", "--exchange-code", "--code", "--activation-code", "--redeem-code", "--plan-code"]);
189
+ const filtered = [];
190
+ for (let i = 0; i < withoutCommand.length; i++) {
191
+ const arg = String(withoutCommand[i] || "");
192
+ if (!arg || arg === "--resume") continue;
193
+ if (skipWithValue.has(arg)) { i++; continue; }
194
+ if ([...skipWithValue].some((name) => arg.startsWith(`${name}=`))) continue;
195
+ filtered.push(arg);
196
+ }
197
+ return filtered;
198
+ }
199
+
200
+ function buildCloudInitResumeCommand(target, args = []) {
201
+ const parts = ["0dai", _initCommandName(args), "--target", _quoteCommandArg(path.resolve(target)), "--resume"];
202
+ for (const arg of _resumeArgs(args)) parts.push(_quoteCommandArg(arg));
203
+ return parts.join(" ");
204
+ }
205
+
206
+ function writeCloudInitCheckpoint(target, details = {}) {
207
+ const checkpointPath = cloudInitCheckpointPath(target);
208
+ const checkpoint = {
209
+ version: 1,
210
+ command: _initCommandName(details.args || []),
211
+ stage: details.stage || "auth_required",
212
+ reason: details.reason || "missing_auth",
213
+ target: path.resolve(target),
214
+ created_at: new Date().toISOString(),
215
+ next_command: "0dai auth login --device --no-browser",
216
+ resume_command: buildCloudInitResumeCommand(target, details.args || []),
217
+ };
218
+ fs.mkdirSync(path.dirname(checkpointPath), { recursive: true, mode: 0o700 });
219
+ fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", { mode: 0o600 });
220
+ return checkpoint;
221
+ }
222
+
223
+ function clearCloudInitCheckpoint(target) {
224
+ try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
225
+ }
226
+
227
+ function maybePauseCloudInitForAuth(target, args = [], options = {}) {
228
+ if (options.dryRun || options.localMode) return false;
229
+ if (_hasAuthToken()) return false;
230
+ const parsed = parseActivationArgs(args, { genericCode: "activation" });
231
+ if (parsed.authCode) return false;
232
+ if (process.stdout.isTTY && process.stdin.isTTY) return false;
233
+
234
+ const checkpoint = writeCloudInitCheckpoint(target, {
235
+ args,
236
+ stage: "auth_required",
237
+ reason: "missing_auth",
238
+ });
239
+ log("authentication required for init");
240
+ console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
241
+ console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
242
+ console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
243
+ process.exit(1);
244
+ }
245
+
246
+ async function cmdProjectBind(target, args = []) {
247
+ const json = args.includes("--json");
248
+ const authStatus = await ensureAuthenticated("project bind");
249
+ const license = await ensureLicenseActivation();
250
+ const metadata = collectMetadata(target);
251
+ const identity = buildProjectIdentity(target, metadata);
252
+ const boundProject = await bindProjectForCloud(target, metadata, identity);
253
+ const stack = boundProject.stack || identity.stack || "unknown";
254
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
255
+ registerProjectOrExit(target, path.basename(target), stack);
256
+ }
257
+ const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
258
+ project_id: boundProject.project_id || identity.project_id,
259
+ }).catch((err) => ({ error: err.message || String(err) }));
260
+ const payload = {
261
+ ok: true,
262
+ account: {
263
+ email: authStatus.email || null,
264
+ plan: authStatus.plan || license.plan || "free",
265
+ activation: license.status || "active",
266
+ },
267
+ project: {
268
+ project_id: boundProject.project_id || identity.project_id,
269
+ name: boundProject.name || identity.project_name,
270
+ stack,
271
+ binding_status: boundProject.binding_status || "bound",
272
+ },
273
+ heartbeat: !(heartbeat && heartbeat.error),
274
+ };
275
+ if (json) {
276
+ console.log(JSON.stringify(payload, null, 2));
277
+ } else {
278
+ log("project bound");
279
+ console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
280
+ console.log(` project: ${payload.project.project_id}`);
281
+ if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
282
+ }
283
+ return payload;
284
+ }
285
+
34
286
  async function cmdInit(target, args = []) {
35
287
  const dryRun = args.includes("--dry-run");
36
288
  const minimal = args.includes("--minimal");
37
289
  const noWizard = args.includes("--no-wizard");
290
+ const localMode = args.includes("--local");
38
291
 
39
292
  if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
40
293
  const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
41
294
  log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
295
+ if (args.includes("--resume")) clearCloudInitCheckpoint(target);
296
+ if (!dryRun) await runMcpBootstrap(target, args);
42
297
  return;
43
298
  }
44
299
 
300
+ // Pre-check: verify init quota before starting wizard (avoid 10 min wizard → "limit reached")
301
+ if (!dryRun) {
302
+ try {
303
+ const precheck = await apiCall("/v1/projects/precheck", {
304
+ device_id: shared.deviceFingerprint(),
305
+ });
306
+ if (precheck.error && precheck.error.includes("limit")) {
307
+ log(`${precheck.error}`);
308
+ if (precheck.hint) console.log(` ${D}${precheck.hint}${R}`);
309
+ return;
310
+ }
311
+ } catch {}
312
+ }
313
+
45
314
  // First-run wizard (unless --no-wizard or non-interactive)
46
- if (!noWizard && !dryRun && !minimal) {
315
+ if (!noWizard && !dryRun && !minimal && !localMode) {
47
316
  try {
48
317
  const { runWizard, isInteractive } = require("../wizard");
49
318
  if (isInteractive()) {
50
319
  const result = await runWizard(target);
51
320
  if (result.completed) {
321
+ await runMcpBootstrap(target, args);
52
322
  try {
53
323
  const ob = require("../onboarding");
54
324
  ob.trackFirstInit(target);
@@ -56,9 +326,25 @@ async function cmdInit(target, args = []) {
56
326
  } catch {}
57
327
  return;
58
328
  }
329
+ if (result.cancelled) return;
59
330
  }
60
331
  } catch {}
61
332
  }
333
+ if (localMode) {
334
+ const { runWizard } = require("../wizard");
335
+ const result = await runWizard(target, { forceLocal: true });
336
+ if (result.completed) {
337
+ await runMcpBootstrap(target, args);
338
+ try {
339
+ const ob = require("../onboarding");
340
+ ob.trackFirstInit(target);
341
+ ob.showWhatsNext("local", false);
342
+ } catch {}
343
+ return;
344
+ }
345
+ }
346
+
347
+ maybePauseCloudInitForAuth(target, args, { dryRun, localMode });
62
348
 
63
349
  const isTTY = process.stdout.isTTY;
64
350
  let spinner = null;
@@ -68,7 +354,7 @@ async function cmdInit(target, args = []) {
68
354
 
69
355
  const metadata = collectMetadata(target);
70
356
  const { projectFiles, manifestContents, clis } = metadata;
71
- const authStatus = await ensureAuthenticated("init");
357
+ const authStatus = await ensureAccountForActivation("init", args);
72
358
  const license = await ensureLicenseActivation();
73
359
  const identity = buildProjectIdentity(target, metadata);
74
360
  const boundProject = await bindProjectForCloud(target, metadata, identity);
@@ -108,6 +394,11 @@ async function cmdInit(target, args = []) {
108
394
  return;
109
395
  }
110
396
  writeFiles(target, result.files || {});
397
+ const envManifest = normalizeEnvironmentManifest(target);
398
+ if (envManifest.changed) {
399
+ log("environment manifest target normalized: ai/manifest/environment.yaml");
400
+ }
401
+ await runMcpBootstrap(target, args);
111
402
 
112
403
  // Ensure ai/VERSION matches CLI version
113
404
  const versionFile = path.join(target, "ai", "VERSION");
@@ -121,8 +412,15 @@ async function cmdInit(target, args = []) {
121
412
  if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
122
413
  } catch {}
123
414
 
124
- // Register in global portfolio
125
- registerProject(target, path.basename(target), result.stack);
415
+ const gitPolicy = ensureGithubFlowPolicy(target);
416
+ if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
417
+ console.log(` ${D}branch protection: ${gitPolicy.protection.reason}${R}`);
418
+ }
419
+
420
+ // Register in global portfolio (Phase 1.5 #2237: drift guard)
421
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
422
+ registerProjectOrExit(target, path.basename(target), result.stack);
423
+ }
126
424
 
127
425
  log(`initialized (${result.file_count || "?"} files)`);
128
426
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
@@ -150,9 +448,9 @@ async function cmdInit(target, args = []) {
150
448
  }
151
449
  console.log(` ${D}3.${R} Open dashboard: ${D}https://0dai.dev/dashboard${R}`);
152
450
 
153
- await sendProjectHeartbeat(identity, result, {
451
+ const heartbeat = await sendProjectHeartbeat(target, identity, result, {
154
452
  project_id: boundProject.project_id || identity.project_id,
155
- }).catch(() => {});
453
+ }).catch(() => null);
156
454
  recordExperienceEvent(target, {
157
455
  event_type: "config_generated",
158
456
  agent: "cli",
@@ -162,18 +460,107 @@ async function cmdInit(target, args = []) {
162
460
  context: { stack: result.stack || identity.stack || "unknown", files_touched: Number(result.file_count || 0), tests_passed: true },
163
461
  });
164
462
 
463
+ // First-run proof gate (issue #342). All 4 gates pass once we reach here:
464
+ // license active (line above), project bound, ai/ layer written, heartbeat sent.
465
+ // Idempotent — only fires once per project. See docs/first-run.md.
466
+ const firstRun = logFirstRunSuccess(target, {
467
+ license: true,
468
+ project_bound: true,
469
+ layer_written: true,
470
+ heartbeat: !!heartbeat && !heartbeat.error,
471
+ });
472
+ if (firstRun.fired) {
473
+ const suffix = typeof firstRun.elapsedS === "number" ? ` (${firstRun.elapsedS}s)` : "";
474
+ console.log(` ${D}first-run gate: success${suffix}${R}`);
475
+ }
476
+
165
477
  // Send anonymous usage ping
166
478
  apiCall("/v1/feedback", { report: {
167
479
  stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
168
480
  _cli_version: VERSION, _files_generated: result.file_count || 0,
169
481
  }}).catch(() => {});
482
+ clearCloudInitCheckpoint(target);
483
+ }
484
+
485
+ async function runMcpBootstrap(target, args = []) {
486
+ const result = await bootstrapMcp(target, args, (message) => log(message));
487
+ if (!result.ok) {
488
+ log(`warn: MCP bootstrap skipped: ${result.warnings.join("; ")}`);
489
+ return result;
490
+ }
491
+ if (result.config) {
492
+ const verbs = [];
493
+ if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
494
+ if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
495
+ if (result.config.changed) {
496
+ log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
497
+ }
498
+ }
499
+ if (result.settings && result.settings.changed) {
500
+ log("Claude project MCP auto-load enabled: .claude/settings.json");
501
+ }
502
+ if (result.auth) {
503
+ if (result.auth.status === "preserved") {
504
+ log("MCP auth token preserved");
505
+ } else if (result.auth.status === "written") {
506
+ log(`MCP auth token stored: ${result.auth.tokenPath}`);
507
+ } else if (result.auth.status === "disabled") {
508
+ log("MCP cloud auth skipped (--no-mcp-auth)");
509
+ } else if (result.auth.status === "skipped") {
510
+ console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude.ai 0dai${R}`);
511
+ }
512
+ }
513
+ for (const warning of result.warnings || []) log(`warn: ${warning}`);
514
+ return result;
515
+ }
516
+
517
+ function normalizeEnvironmentManifest(target) {
518
+ const filePath = path.join(target, "ai", "manifest", "environment.yaml");
519
+ if (!fs.existsSync(filePath)) return { path: filePath, changed: false };
520
+ const original = fs.readFileSync(filePath, "utf8");
521
+ const lines = original.split(/\n/);
522
+ let inWorkspace = false;
523
+ let sawTarget = false;
524
+ let sawCwd = false;
525
+ const next = [];
526
+ for (const line of lines) {
527
+ if (/^\S/.test(line) && !line.startsWith("workspace:")) inWorkspace = false;
528
+ if (line.trim() === "workspace:") {
529
+ inWorkspace = true;
530
+ next.push(line);
531
+ continue;
532
+ }
533
+ if (inWorkspace && /^ target:\s*/.test(line)) {
534
+ next.push(" target: .");
535
+ sawTarget = true;
536
+ continue;
537
+ }
538
+ if (inWorkspace && /^ cwd:\s*/.test(line)) {
539
+ next.push(" cwd: .");
540
+ sawCwd = true;
541
+ continue;
542
+ }
543
+ if (inWorkspace && /^available_clis:\s*$/.test(line)) {
544
+ if (!sawTarget) next.push(" target: .");
545
+ if (!sawCwd) next.push(" cwd: .");
546
+ inWorkspace = false;
547
+ }
548
+ next.push(line);
549
+ }
550
+ if (inWorkspace) {
551
+ if (!sawTarget) next.push(" target: .");
552
+ if (!sawCwd) next.push(" cwd: .");
553
+ }
554
+ const rendered = next.join("\n");
555
+ if (rendered === original) return { path: filePath, changed: false };
556
+ fs.writeFileSync(filePath, rendered, "utf8");
557
+ return { path: filePath, changed: true };
170
558
  }
171
559
 
172
560
  async function cmdSync(target, args = []) {
173
561
  const dryRun = args.includes("--dry-run");
174
562
  const quiet = args.includes("--quiet") || args.includes("-q");
175
563
  const force = args.includes("--force");
176
- const updateTemplates = args.includes("--update-templates");
177
564
 
178
565
  // Quick local check: skip API if already at current version (unless dry-run or force)
179
566
  let version = "unknown";
@@ -181,8 +568,6 @@ async function cmdSync(target, args = []) {
181
568
 
182
569
  const metadata = collectMetadata(target);
183
570
  const { manifestContents, clis } = metadata;
184
- const authStatus = await ensureAuthenticated("sync");
185
- const license = await ensureLicenseActivation();
186
571
  let stack = "generic", agents = [];
187
572
  try {
188
573
  const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
@@ -190,35 +575,41 @@ async function cmdSync(target, args = []) {
190
575
  agents = d.selected_agents || [];
191
576
  } catch {}
192
577
  const identity = buildProjectIdentity(target, metadata, stack);
193
- const boundProject = await bindProjectForCloud(target, metadata, identity);
194
578
 
195
- // Collect current ai/ files
196
- const currentFiles = {};
197
- const aiDir = path.join(target, "ai");
198
- if (fs.existsSync(aiDir)) {
199
- const walk = (dir) => {
200
- for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
201
- const p = path.join(dir, f.name);
202
- if (f.isDirectory()) walk(p);
203
- else {
204
- try {
205
- const stat = fs.statSync(p);
206
- if (stat.size < 10000) currentFiles[path.relative(target, p)] = fs.readFileSync(p, "utf8");
207
- } catch {}
208
- }
579
+ if (dryRun) {
580
+ const auth = loadAuthState();
581
+ const hasAuth = !!(auth && (auth.api_key || auth.access_token || auth.token));
582
+ if (!hasAuth) {
583
+ const preview = buildLocalSyncPreview(target, { version, stack, cliVersion: VERSION });
584
+ log(`${D}dry-run: local preview without auth (exact cloud plan unavailable)${R}`);
585
+ console.log(` stack: ${preview.stack}`);
586
+ console.log(` ai version: ${preview.current_version} ${preview.version_matches ? `${D}(matches CLI ${preview.cli_version})${R}` : `${D}(CLI ${preview.cli_version})${R}`}`);
587
+ if (preview.changes.length) {
588
+ console.log(" likely changes:");
589
+ for (const change of preview.changes) console.log(` ~ ${change}`);
590
+ } else {
591
+ console.log(` ${D}no obvious local drift found${R}`);
209
592
  }
210
- };
211
- walk(aiDir);
593
+ console.log(` ${D}Run: 0dai auth login for exact managed diff and write-mode sync${R}`);
594
+ return;
595
+ }
212
596
  }
213
597
 
598
+ const authStatus = await ensureAuthenticated("sync");
599
+ const license = await ensureLicenseActivation();
600
+ const boundProject = await bindProjectForCloud(target, metadata, identity);
601
+
602
+ // Collect current ai/ files. Small files send content; larger files send
603
+ // hash descriptors so the server can compare without a full upload.
604
+ const currentFiles = collectCurrentAiFiles(target);
605
+
214
606
  if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
215
607
  if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
216
- if (updateTemplates && !dryRun) log(`${T}template update mode: will refresh managed native configs from latest templates${R}`);
217
608
 
218
609
  const result = await apiCall("/v1/sync", {
219
610
  ai_version: version, stack, agents: agents.length ? agents : clis,
220
611
  current_files: currentFiles, manifest_contents: manifestContents,
221
- dry_run: dryRun, quiet, force, update_templates: updateTemplates,
612
+ dry_run: dryRun, quiet, force,
222
613
  project_name: identity.project_name,
223
614
  project_id: boundProject.project_id || identity.project_id,
224
615
  remote_origin: identity.remote_origin,
@@ -237,17 +628,38 @@ async function cmdSync(target, args = []) {
237
628
  const files = Object.keys(updated);
238
629
  if (files.length) {
239
630
  log(`${D}dry-run: would update ${files.length} file(s):${R}`);
240
- for (const f of files) console.log(` ${D}~ ${f}${R}`);
631
+ const { diff, summary } = renderFileMapDiff(updated, currentFiles);
632
+ for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
633
+ for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
634
+ for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
635
+ if (diff && !args.includes("--no-diff")) {
636
+ console.log("");
637
+ console.log(diff);
638
+ }
241
639
  } else {
242
640
  log(`${D}dry-run: nothing to update${R}`);
243
641
  }
244
- if (result.template_update_available) {
245
- console.log(` ${D}template update available: run 0dai sync --update-templates${R}`);
246
- }
247
642
  return;
248
643
  }
249
644
  const changedCount = Object.keys(updated).length;
250
645
  if (changedCount) {
646
+ if (!quiet && !shouldAutoYes(args)) {
647
+ const { diff, summary } = renderFileMapDiff(updated, currentFiles);
648
+ log(`sync would change ${changedCount} file(s):`);
649
+ for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
650
+ for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
651
+ for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
652
+ if (diff && !args.includes("--no-diff")) {
653
+ console.log("");
654
+ console.log(diff);
655
+ console.log("");
656
+ }
657
+ const ok = await confirmOrExit({ args, quiet, message: "Apply sync?", defaultYes: false });
658
+ if (!ok) {
659
+ log("aborted by user (no files changed). Re-run with --yes to skip this prompt.");
660
+ return;
661
+ }
662
+ }
251
663
  writeFiles(target, updated);
252
664
  if (!quiet) {
253
665
  for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
@@ -257,19 +669,39 @@ async function cmdSync(target, args = []) {
257
669
  } else {
258
670
  log("already up to date");
259
671
  }
260
-
261
- if (result.template_update_available && !updateTemplates && !quiet) {
262
- console.log(` ${D}Template update available: run 0dai sync --update-templates to refresh managed native configs${R}`);
672
+ const envManifest = normalizeEnvironmentManifest(target);
673
+ if (!quiet && envManifest.changed) {
674
+ log("environment manifest target normalized: ai/manifest/environment.yaml");
675
+ }
676
+ await runMcpBootstrap(target, [...args, "--no-mcp-auth"]);
677
+
678
+ // Round-trip imported personas back to native agent dirs (Phase 2 of #2197).
679
+ // Currently supports claude-code only. Persona files marked
680
+ // `imported_from: "claude-code"` are mirrored into `.claude/agents/<name>.md`
681
+ // so users can edit either side without losing parity.
682
+ // Phase 2.5 (deferred): full export — generate agents for personas that
683
+ // were authored in 0dai but not imported. Tracked in #2197.
684
+ try {
685
+ const { syncImportedClaudeCodeAgents } = require("./import_claude_code_agents");
686
+ const rt = syncImportedClaudeCodeAgents(target, { dryRun });
687
+ if (!quiet && rt.written.length) {
688
+ log(`round-trip: wrote ${rt.written.length} claude-code agent file(s) from imported personas`);
689
+ for (const w of rt.written) {
690
+ const rel = path.relative(target, w.outPath).replace(/\\/g, "/");
691
+ console.log(` -> ${rel}`);
692
+ }
693
+ }
694
+ } catch (err) {
695
+ if (!quiet) console.log(` ${D}round-trip skipped: ${err.message}${R}`);
263
696
  }
264
697
 
265
698
  // --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
266
699
  if (force && result.native_configs) {
700
+ const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
267
701
  let overwritten = 0;
268
- for (const [name, content] of Object.entries(result.native_configs)) {
269
- const targetPath = path.join(target, name);
270
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
271
- if (content) {
272
- fs.writeFileSync(targetPath, content, "utf8");
702
+ for (const name of NATIVE_CONFIGS) {
703
+ if (result.native_configs[name]) {
704
+ fs.writeFileSync(path.join(target, name), result.native_configs[name], "utf8");
273
705
  overwritten++;
274
706
  if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
275
707
  }
@@ -277,11 +709,6 @@ async function cmdSync(target, args = []) {
277
709
  if (overwritten && !quiet) {
278
710
  log(`force: ${overwritten} native config file(s) overwritten`);
279
711
  }
280
- } else if (updateTemplates && result.native_configs) {
281
- writeManagedFiles(target, result.native_configs);
282
- if (!quiet) {
283
- log("template update: managed native configs refreshed");
284
- }
285
712
  }
286
713
 
287
714
  // --force: update drift baseline hashes so drift clears after regeneration
@@ -298,6 +725,7 @@ async function cmdSync(target, args = []) {
298
725
  if (!quiet) {
299
726
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
300
727
  console.log(` project: ${boundProject.project_id || identity.project_id}`);
728
+ warnHooksPathDrift(target);
301
729
  }
302
730
 
303
731
  // Ensure ai/VERSION matches CLI version after successful sync
@@ -309,9 +737,11 @@ async function cmdSync(target, args = []) {
309
737
  }
310
738
  } catch {}
311
739
 
312
- // Update portfolio registry
313
- registerProject(target, path.basename(target), stack);
314
- await sendProjectHeartbeat(identity, result, {
740
+ // Update portfolio registry (Phase 1.5 #2237: drift guard)
741
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
742
+ registerProjectOrExit(target, path.basename(target), stack);
743
+ }
744
+ await sendProjectHeartbeat(target, identity, result, {
315
745
  project_id: boundProject.project_id || identity.project_id,
316
746
  }).catch(() => {});
317
747
  recordExperienceEvent(target, {
@@ -324,4 +754,74 @@ async function cmdSync(target, args = []) {
324
754
  });
325
755
  }
326
756
 
327
- module.exports = { cmdInit, cmdSync };
757
+ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
758
+ const changes = [];
759
+ const expectedAiFiles = [
760
+ "ai/VERSION",
761
+ "ai/manifest/project.yaml",
762
+ "ai/manifest/commands.yaml",
763
+ "ai/manifest/discovery.json",
764
+ ];
765
+ for (const rel of expectedAiFiles) {
766
+ if (!fs.existsSync(path.join(target, rel))) changes.push(`${rel} (missing)`);
767
+ }
768
+
769
+ if (version !== "unknown" && version !== cliVersion) {
770
+ changes.push(`ai/VERSION (${version} -> ${cliVersion})`);
771
+ }
772
+
773
+ const driftTracked = [
774
+ "CLAUDE.md",
775
+ "AGENTS.md",
776
+ "GEMINI.md",
777
+ "opencode.json",
778
+ ".cursorrules",
779
+ ".windsurfrules",
780
+ ".aider.conf.yml",
781
+ ];
782
+ const hashesPath = path.join(target, "ai", "manifest", "config_hashes.json");
783
+ try {
784
+ const hashes = JSON.parse(fs.readFileSync(hashesPath, "utf8"));
785
+ const crypto = require("crypto");
786
+ for (const rel of driftTracked) {
787
+ const filePath = path.join(target, rel);
788
+ const recorded = hashes[rel];
789
+ const exists = fs.existsSync(filePath) && fs.statSync(filePath).isFile();
790
+ if (recorded && !exists) changes.push(`${rel} (missing from workspace)`);
791
+ if (recorded && exists) {
792
+ const currentHash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
793
+ if (currentHash !== String(recorded.hash || "")) changes.push(`${rel} (local edits)`);
794
+ }
795
+ }
796
+ } catch {}
797
+
798
+ if (!fs.existsSync(path.join(target, "ai"))) {
799
+ changes.push("ai/ layer missing — run 0dai init after auth");
800
+ }
801
+
802
+ return {
803
+ stack,
804
+ current_version: version,
805
+ cli_version: cliVersion,
806
+ version_matches: version === cliVersion,
807
+ changes,
808
+ };
809
+ }
810
+
811
+ module.exports = {
812
+ cmdInit,
813
+ cmdSync,
814
+ cmdProjectBind,
815
+ buildLocalSyncPreview,
816
+ runMcpBootstrap,
817
+ bindProjectForCloud,
818
+ cloudInitCheckpointPath,
819
+ writeCloudInitCheckpoint,
820
+ clearCloudInitCheckpoint,
821
+ buildCloudInitResumeCommand,
822
+ collectCurrentAiFiles,
823
+ hashFile,
824
+ detectRegistryDrift,
825
+ guardRegistryDrift,
826
+ SYNC_FULL_CONTENT_LIMIT,
827
+ };