@0dai-dev/cli 4.3.6 → 4.3.8

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 (77) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +133 -33
  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 +707 -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 +298 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/play.js +20 -4
  18. package/lib/commands/provider.js +30 -59
  19. package/lib/commands/quota.js +1 -1
  20. package/lib/commands/receipt.js +1 -1
  21. package/lib/commands/run.js +14 -6
  22. package/lib/commands/runner.js +31 -1
  23. package/lib/commands/status.js +176 -11
  24. package/lib/commands/swarm.js +130 -12
  25. package/lib/commands/trust.js +1 -1
  26. package/lib/commands/update.js +184 -38
  27. package/lib/commands/usage.js +1 -1
  28. package/lib/commands/validate.js +32 -3
  29. package/lib/commands/vault.js +43 -8
  30. package/lib/python/__init__.py +0 -0
  31. package/lib/python/agent_quotas.py +525 -0
  32. package/lib/python/anomaly_alert.py +397 -0
  33. package/lib/python/anti_pattern_detector.py +799 -0
  34. package/lib/python/auth.py +443 -0
  35. package/lib/python/capi_profile_guard.py +477 -0
  36. package/lib/python/compliance_report.py +581 -0
  37. package/lib/python/drift_detector.py +388 -0
  38. package/lib/python/experience_pipeline.py +1130 -0
  39. package/lib/python/graph.py +19 -0
  40. package/lib/python/graph_core.py +293 -0
  41. package/lib/python/graph_io.py +179 -0
  42. package/lib/python/graph_legacy.py +2052 -0
  43. package/lib/python/graph_legacy_helpers.py +221 -0
  44. package/lib/python/graph_outcomes_core.py +85 -0
  45. package/lib/python/graph_queries.py +171 -0
  46. package/lib/python/graph_slice.py +198 -0
  47. package/lib/python/graph_slicer.py +576 -0
  48. package/lib/python/graph_slicer_cli.py +60 -0
  49. package/lib/python/graph_validation.py +64 -0
  50. package/lib/python/heatmap.py +943 -0
  51. package/lib/python/json_utils.py +193 -0
  52. package/lib/python/mcp_exposure_check.py +247 -0
  53. package/lib/python/model_router.py +1434 -0
  54. package/lib/python/project_manager.py +621 -0
  55. package/lib/python/provider_profiles.py +1618 -0
  56. package/lib/python/provider_registry.py +1211 -0
  57. package/lib/python/provider_registry_cli.py +125 -0
  58. package/lib/python/receipt_png.py +727 -0
  59. package/lib/python/structural_memory.py +325 -0
  60. package/lib/python/swarm_cost.py +177 -0
  61. package/lib/python/usage_ledger.py +569 -0
  62. package/lib/scripts/mcp_tier_config.py +240 -0
  63. package/lib/shared.js +96 -12
  64. package/lib/tui/index.mjs +35174 -0
  65. package/lib/utils/activation_telemetry.js +1 -4
  66. package/lib/utils/constants.js +7 -1
  67. package/lib/utils/identity.js +184 -0
  68. package/lib/utils/mcp-auth.js +81 -15
  69. package/lib/utils/plan.js +1 -1
  70. package/lib/vault/index.js +19 -3
  71. package/lib/vault/storage.js +21 -2
  72. package/lib/wizard.js +5 -2
  73. package/package.json +9 -3
  74. package/scripts/build-python-bundle.js +106 -0
  75. package/scripts/build-tui.js +14 -1
  76. package/scripts/harvest_experience.py +523 -0
  77. package/scripts/postinstall.js +15 -9
@@ -3,23 +3,38 @@ const crypto = require("crypto");
3
3
  const shared = require("../shared");
4
4
  const {
5
5
  T, R, D, W, log,
6
- fs, path,
6
+ fs, path, os,
7
7
  VERSION, SUPPORTED_CLIS,
8
8
  CONFIG_DIR, PROJECTS_FILE,
9
9
  apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
10
- collectMetadata, buildProjectIdentity, registerProject, hashManifestFiles,
11
- writeFiles, sendProjectHeartbeat, recordExperienceEvent,
12
- logFirstRunSuccess,
10
+ collectMetadata, inferProjectName, detectStackHint,
11
+ buildProjectIdentity, registerProject, projectIdFor, hashManifestFiles,
12
+ loadProjectBinding, boundProjectIdentityFromBinding, validateProjectDisplayName,
13
+ writeProjectBinding,
14
+ writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults, LIVE_MANIFEST_DEFAULTS,
15
+ sendProjectHeartbeat, recordExperienceEvent,
16
+ logFirstRunSuccess, checkVersion,
13
17
  } = shared;
14
18
  const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = require("./auth");
15
19
  const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
16
20
  const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
17
21
  const { bootstrapMcp } = require("../utils/mcp-auth");
18
22
  const { recordActivationInit } = require("../utils/activation_telemetry");
23
+ const { projectIdForExplicitRebindFromBinding, readProjectManifest } = require("../utils/identity");
19
24
 
20
25
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
21
26
  const SYNC_FULL_CONTENT_LIMIT = 10000;
22
27
  const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
28
+ const INIT_RUNTIME_GITIGNORE_LINES = [
29
+ ".0dai/",
30
+ "ai/.backups/",
31
+ "ai/manifest/audit.jsonl",
32
+ "ai/manifest/wal.jsonl",
33
+ "ai/manifest/project_graph_usage.json",
34
+ "ai/experience/archive/",
35
+ "ai/experience/events/",
36
+ "ai/experience/warnings.json",
37
+ ];
23
38
 
24
39
  function hashFile(filePath) {
25
40
  const hash = crypto.createHash("sha256");
@@ -123,6 +138,16 @@ function registerProjectOrExit(target, name, stack) {
123
138
  }
124
139
  }
125
140
 
141
+ function projectBindHostRegistryInfo(target) {
142
+ return {
143
+ scope: "host-local",
144
+ host: os.hostname(),
145
+ registry: PROJECTS_FILE,
146
+ project_path: path.resolve(target),
147
+ note: "0dai project bind writes this host's registry. A remote operator MCP server reads its own host registry and will not see this bind automatically.",
148
+ };
149
+ }
150
+
126
151
  function collectCurrentAiFiles(target) {
127
152
  const currentFiles = {};
128
153
  const aiDir = path.join(target, "ai");
@@ -225,6 +250,26 @@ function clearCloudInitCheckpoint(target) {
225
250
  try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
226
251
  }
227
252
 
253
+ function ensureRuntimeGitignore(target) {
254
+ const gi = path.join(target, ".gitignore");
255
+ try {
256
+ const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
257
+ const existing = new Set(text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
258
+ const missing = INIT_RUNTIME_GITIGNORE_LINES.filter((line) => !existing.has(line));
259
+ if (!missing.length) return [];
260
+
261
+ let block = "";
262
+ if (text.length && !text.endsWith("\n")) block += "\n";
263
+ if (text.length) block += "\n";
264
+ block += "# 0dai runtime state\n";
265
+ block += `${missing.join("\n")}\n`;
266
+ fs.appendFileSync(gi, block, "utf8");
267
+ return missing;
268
+ } catch {
269
+ return [];
270
+ }
271
+ }
272
+
228
273
  function maybePauseCloudInitForAuth(target, args = [], options = {}) {
229
274
  if (options.dryRun || options.localMode) return false;
230
275
  if (_hasAuthToken()) return false;
@@ -232,6 +277,7 @@ function maybePauseCloudInitForAuth(target, args = [], options = {}) {
232
277
  if (parsed.authCode) return false;
233
278
  if (process.stdout.isTTY && process.stdin.isTTY) return false;
234
279
 
280
+ ensureRuntimeGitignore(target);
235
281
  const checkpoint = writeCloudInitCheckpoint(target, {
236
282
  args,
237
283
  stage: "auth_required",
@@ -241,23 +287,89 @@ function maybePauseCloudInitForAuth(target, args = [], options = {}) {
241
287
  console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
242
288
  console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
243
289
  console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
290
+ console.log(` ${D}Or run offline without signing in: 0dai init --local${R}`);
244
291
  process.exit(1);
245
292
  }
246
293
 
294
+ function parseProjectBindOptions(args = []) {
295
+ const options = { json: args.includes("--json"), name: "" };
296
+ for (let i = 0; i < args.length; i++) {
297
+ const arg = String(args[i] || "");
298
+ if (arg === "--name") {
299
+ const next = args[i + 1];
300
+ if (!next || String(next).startsWith("--")) {
301
+ return { ...options, error: "--name requires a project display name" };
302
+ }
303
+ const validated = validateProjectDisplayName(next);
304
+ if (!validated.ok) return { ...options, error: validated.error };
305
+ options.name = validated.name;
306
+ i++;
307
+ continue;
308
+ }
309
+ if (arg.startsWith("--name=")) {
310
+ const validated = validateProjectDisplayName(arg.slice("--name=".length));
311
+ if (!validated.ok) return { ...options, error: validated.error };
312
+ options.name = validated.name;
313
+ }
314
+ }
315
+ return options;
316
+ }
317
+
247
318
  async function cmdProjectBind(target, args = []) {
248
- const json = args.includes("--json");
319
+ const bindOptions = parseProjectBindOptions(args);
320
+ if (bindOptions.error) {
321
+ log(`error: ${bindOptions.error}`);
322
+ process.exit(1);
323
+ }
324
+ const json = bindOptions.json;
249
325
  const authStatus = await ensureAuthenticated("project bind");
250
326
  const license = await ensureLicenseActivation();
251
327
  const metadata = collectMetadata(target);
328
+ const existingBinding = loadProjectBinding(target);
329
+ const existingBoundIdentity = boundProjectIdentityFromBinding(target, existingBinding);
330
+ const explicitRebindProjectId = projectIdForExplicitRebindFromBinding(existingBinding);
252
331
  const identity = buildProjectIdentity(target, metadata);
332
+ if (bindOptions.name) {
333
+ identity.project_name = bindOptions.name;
334
+ identity.project_id = projectIdFor(target, bindOptions.name, identity.remote_origin || path.resolve(target));
335
+ }
336
+ if (existingBoundIdentity && existingBoundIdentity.project_id) {
337
+ identity.project_id = existingBoundIdentity.project_id;
338
+ } else if (explicitRebindProjectId) {
339
+ identity.project_id = explicitRebindProjectId;
340
+ }
253
341
  const boundProject = await bindProjectForCloud(target, metadata, identity);
342
+ const projectID = boundProject.project_id || identity.project_id;
343
+ const projectName = bindOptions.name || boundProject.name || identity.project_name;
344
+ identity.project_id = projectID;
345
+ identity.project_name = projectName;
254
346
  const stack = boundProject.stack || identity.stack || "unknown";
255
- if (await guardRegistryDrift(target, path.basename(target), args)) {
256
- registerProjectOrExit(target, path.basename(target), stack);
347
+ const registryName = bindOptions.name || path.basename(target);
348
+ if (await guardRegistryDrift(target, registryName, args)) {
349
+ registerProjectOrExit(target, registryName, stack);
257
350
  }
258
351
  const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
259
- project_id: boundProject.project_id || identity.project_id,
352
+ project_id: projectID,
260
353
  }).catch((err) => ({ error: err.message || String(err) }));
354
+ const savedBinding = writeProjectBinding(target, {
355
+ identity,
356
+ server_project: {
357
+ ...boundProject,
358
+ project_id: projectID,
359
+ name: projectName,
360
+ stack,
361
+ binding_status: boundProject.binding_status || "bound",
362
+ },
363
+ project_id: projectID,
364
+ project_name: projectName,
365
+ stack,
366
+ binding_status: boundProject.binding_status || "bound",
367
+ binding_source: bindOptions.name ? "project bind --name" : "project bind",
368
+ account_email: authStatus.email || "",
369
+ account_plan: authStatus.plan || license.plan || "free",
370
+ activation_status: license.status || "active",
371
+ activation_id: license.activation_id || "",
372
+ });
261
373
  const payload = {
262
374
  ok: true,
263
375
  account: {
@@ -266,12 +378,18 @@ async function cmdProjectBind(target, args = []) {
266
378
  activation: license.status || "active",
267
379
  },
268
380
  project: {
269
- project_id: boundProject.project_id || identity.project_id,
270
- name: boundProject.name || identity.project_name,
381
+ project_id: projectID,
382
+ name: projectName,
271
383
  stack,
272
384
  binding_status: boundProject.binding_status || "bound",
273
385
  },
274
386
  heartbeat: !(heartbeat && heartbeat.error),
387
+ host_registry: projectBindHostRegistryInfo(target),
388
+ local_binding: {
389
+ target: savedBinding.target,
390
+ target_aliases: savedBinding.target_aliases || [],
391
+ updated_at: savedBinding.updated_at,
392
+ },
275
393
  };
276
394
  if (json) {
277
395
  console.log(JSON.stringify(payload, null, 2));
@@ -279,25 +397,125 @@ async function cmdProjectBind(target, args = []) {
279
397
  log("project bound");
280
398
  console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
281
399
  console.log(` project: ${payload.project.project_id}`);
400
+ console.log(` registry: ${payload.host_registry.registry}`);
401
+ console.log(` ${D}${payload.host_registry.note} See #4084.${R}`);
282
402
  if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
283
403
  }
284
404
  return payload;
285
405
  }
286
406
 
407
+ // buildLocalDryRunPreview — pure, offline preview for `0dai init --local --dry-run`.
408
+ // Detects the stack and renders the configs the local (offline) init path
409
+ // produces, entirely IN MEMORY. Makes ZERO auth/license/server/network calls:
410
+ // it only reads the target dir via collectMetadata + the local detection helpers
411
+ // (inferProjectName / detectStackHint), all of which run on-disk with no egress.
412
+ // Returns { projectName, stack, clis, files, agentTargets } where:
413
+ // files — { relPath: content } the offline path would write
414
+ // agentTargets — [{ cli, files: [...] }] the per-CLI config paths a full
415
+ // (authenticated) `0dai init` would manage for each detected CLI
416
+ function buildLocalDryRunPreview(target) {
417
+ const metadata = collectMetadata(target);
418
+ const { projectFiles, manifestContents, clis } = metadata;
419
+ const projectName = inferProjectName(target, manifestContents);
420
+ const stack = detectStackHint(projectFiles, manifestContents);
421
+
422
+ // Mirror the offline generator (lib/wizard.js stepGenerate) so the preview
423
+ // matches what `0dai init --local` writes without auth — no fabricated bodies.
424
+ const claudeMd = `# ${projectName}\n\nStack: ${stack}\nGenerated by 0dai (local mode).\n\n## Commands\n\nSee ai/manifest/ for full configuration.\n`;
425
+ const agentsMd = `# Agent Configuration\n\nProject: ${projectName}\nStack: ${stack}\n\nSee ai/manifest/ for configuration details.\n`;
426
+ const discovery = {
427
+ project_name: projectName,
428
+ stack,
429
+ detected_stack: [stack],
430
+ selected_agents: clis.length ? clis : SUPPORTED_CLIS.map((c) => c.name),
431
+ local: true,
432
+ };
433
+ const files = {
434
+ "CLAUDE.md": claudeMd,
435
+ "AGENTS.md": agentsMd,
436
+ "ai/manifest/discovery.json": JSON.stringify(discovery, null, 2) + "\n",
437
+ "ai/manifest/project.yaml": `plan: free\nname: ${projectName}\nstack: ${stack}\n`,
438
+ // Live manifest scaffolds the offline path writes via ensureLiveManifestDefaults
439
+ // (lib/wizard.js stepGenerate). Use the canonical bodies, not fabricated ones.
440
+ ...LIVE_MANIFEST_DEFAULTS,
441
+ "ai/VERSION": `${VERSION}\n`,
442
+ };
443
+
444
+ // Per-CLI native config paths the full managed init would generate. We list
445
+ // paths (factual, from SUPPORTED_CLIS) rather than inventing offline bodies.
446
+ const agentTargets = SUPPORTED_CLIS
447
+ .filter((c) => Array.isArray(c.agentFiles) && c.agentFiles.length)
448
+ .map((c) => ({ cli: c.name, files: c.agentFiles }));
449
+
450
+ return { projectName, stack, clis, files, agentTargets };
451
+ }
452
+
453
+ // cmdInitLocalDryRun — offline `0dai init --local --dry-run`. Prints the
454
+ // preview to stdout and returns. Writes nothing to disk and makes no network
455
+ // calls. This is the only path that bypasses the auth/license preflight, and
456
+ // only when BOTH --local AND --dry-run are set (see cmdInit).
457
+ function cmdInitLocalDryRun(target) {
458
+ const { projectName, stack, clis, files, agentTargets } = buildLocalDryRunPreview(target);
459
+ log(`local dry-run: no account or network needed`);
460
+ console.log(` project: ${projectName}`);
461
+ console.log(` stack: ${stack}`);
462
+ console.log(` agent CLIs detected: ${clis.length ? clis.join(", ") : "none"}`);
463
+ console.log("");
464
+ const names = Object.keys(files);
465
+ log(`local dry-run: would write ${names.length} file(s) (nothing written):`);
466
+ for (const name of names) {
467
+ console.log(` ${D}+ ${name}${R}`);
468
+ }
469
+ console.log("");
470
+ log(`local dry-run: per-CLI native configs a full 0dai init would manage:`);
471
+ for (const target_ of agentTargets) {
472
+ console.log(` ${D}${target_.cli}: ${target_.files.join(", ")}${R}`);
473
+ }
474
+ console.log("");
475
+ for (const name of names) {
476
+ console.log(`${T}--- ${name} ---${R}`);
477
+ console.log(files[name].replace(/\n$/, ""));
478
+ console.log("");
479
+ }
480
+ console.log(` ${D}Sign in to write these for real: 0dai init --local${R}`);
481
+ return { projectName, stack, clis, files, agentTargets };
482
+ }
483
+
287
484
  async function cmdInit(target, args = []) {
288
485
  const dryRun = args.includes("--dry-run");
289
486
  const minimal = args.includes("--minimal");
290
487
  const noWizard = args.includes("--no-wizard");
291
488
  const localMode = args.includes("--local");
292
489
 
490
+ // Offline preview: `0dai init --local --dry-run` renders configs in memory,
491
+ // prints them, and returns. Bypasses the auth/license/server preflight ONLY
492
+ // when BOTH flags are present (issue #4475). Plain --dry-run (server plan)
493
+ // and plain --local (wizard, writes) are unchanged.
494
+ if (localMode && dryRun) {
495
+ cmdInitLocalDryRun(target);
496
+ return;
497
+ }
498
+
293
499
  if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
294
500
  const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
295
501
  log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
296
502
  if (args.includes("--resume")) clearCloudInitCheckpoint(target);
297
- if (!dryRun) await runMcpBootstrap(target, args);
503
+ if (!dryRun) {
504
+ ensureRuntimeGitignore(target);
505
+ await runMcpBootstrap(target, args);
506
+ }
298
507
  return;
299
508
  }
300
509
 
510
+ if (!dryRun && !args.includes("--json")) {
511
+ await checkVersion({
512
+ force: true,
513
+ background: false,
514
+ timeoutMs: 1500,
515
+ initHint: true,
516
+ });
517
+ }
518
+
301
519
  // Pre-check: verify init quota before starting wizard (avoid 10 min wizard → "limit reached")
302
520
  if (!dryRun) {
303
521
  try {
@@ -319,6 +537,7 @@ async function cmdInit(target, args = []) {
319
537
  if (isInteractive()) {
320
538
  const result = await runWizard(target);
321
539
  if (result.completed) {
540
+ ensureRuntimeGitignore(target);
322
541
  await runMcpBootstrap(target, args);
323
542
  try {
324
543
  const ob = require("../onboarding");
@@ -335,6 +554,7 @@ async function cmdInit(target, args = []) {
335
554
  const { runWizard } = require("../wizard");
336
555
  const result = await runWizard(target, { forceLocal: true });
337
556
  if (result.completed) {
557
+ ensureRuntimeGitignore(target);
338
558
  await runMcpBootstrap(target, args);
339
559
  try {
340
560
  const ob = require("../onboarding");
@@ -357,7 +577,9 @@ async function cmdInit(target, args = []) {
357
577
  const { projectFiles, manifestContents, clis } = metadata;
358
578
  const authStatus = await ensureAccountForActivation("init", args);
359
579
  const license = await ensureLicenseActivation();
580
+ const explicitRebindProjectId = projectIdForExplicitRebindFromBinding(loadProjectBinding(target));
360
581
  const identity = buildProjectIdentity(target, metadata);
582
+ if (explicitRebindProjectId) identity.project_id = explicitRebindProjectId;
361
583
  const boundProject = await bindProjectForCloud(target, metadata, identity);
362
584
  if (dryRun) log(`${D}dry-run: would generate ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)${R}`);
363
585
  if (spinner) spinner.start(`${dryRun ? "[dry-run] " : ""}Generating ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)...`);
@@ -399,6 +621,7 @@ async function cmdInit(target, args = []) {
399
621
  return;
400
622
  }
401
623
  writeFiles(target, result.files || {});
624
+ ensureLiveManifestDefaults(target);
402
625
  const envManifest = normalizeEnvironmentManifest(target);
403
626
  if (envManifest.changed) {
404
627
  log("environment manifest target normalized: ai/manifest/environment.yaml");
@@ -410,12 +633,7 @@ async function cmdInit(target, args = []) {
410
633
  fs.mkdirSync(path.dirname(versionFile), { recursive: true });
411
634
  fs.writeFileSync(versionFile, VERSION + "\n", "utf8");
412
635
 
413
- // Add to .gitignore
414
- const gi = path.join(target, ".gitignore");
415
- try {
416
- const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
417
- if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
418
- } catch {}
636
+ ensureRuntimeGitignore(target);
419
637
 
420
638
  const gitPolicy = ensureGithubFlowPolicy(target);
421
639
  if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
@@ -501,6 +719,7 @@ async function runMcpBootstrap(target, args = []) {
501
719
  const verbs = [];
502
720
  if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
503
721
  if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
722
+ if (result.config.removed && result.config.removed.length) verbs.push(`${result.config.removed.length} server(s) removed`);
504
723
  if (result.config.changed) {
505
724
  log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
506
725
  }
@@ -516,7 +735,7 @@ async function runMcpBootstrap(target, args = []) {
516
735
  } else if (result.auth.status === "disabled") {
517
736
  log("MCP cloud auth skipped (--no-mcp-auth)");
518
737
  } else if (result.auth.status === "skipped") {
519
- console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude.ai 0dai${R}`);
738
+ console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude_ai_0dai${R}`);
520
739
  }
521
740
  }
522
741
  for (const warning of result.warnings || []) log(`warn: ${warning}`);
@@ -620,10 +839,33 @@ function runDocLinkCheck(target, args = [], options = {}) {
620
839
  return { ran: true, status: 0, strict, broken: false };
621
840
  }
622
841
 
842
+ function printProtectedSyncUpdates(items, { quiet = false } = {}) {
843
+ if (quiet || !Array.isArray(items) || items.length === 0) return;
844
+ log(`${W}sync protected ${items.length} project-owned update(s)${R}`);
845
+ for (const item of items) {
846
+ const rel = item && item.path ? String(item.path) : "(unknown)";
847
+ const reason = item && item.reason ? String(item.reason) : "protected by default";
848
+ console.log(` ${D}! ${rel} — ${reason}${R}`);
849
+ }
850
+ console.log(` ${D}Pass --force-template-reset to apply these reset-style updates.${R}`);
851
+ }
852
+
853
+ function isGenericStackName(value) {
854
+ return ["", "generic", "unknown", "auto"].includes(String(value || "").trim().toLowerCase());
855
+ }
856
+
857
+ function chooseSyncStack(target, discoveryStack) {
858
+ const manifestStack = readProjectManifest(target).stack || "";
859
+ if (!isGenericStackName(manifestStack)) return manifestStack;
860
+ return discoveryStack || manifestStack || "generic";
861
+ }
862
+
623
863
  async function cmdSync(target, args = []) {
624
- const dryRun = args.includes("--dry-run");
625
- const quiet = args.includes("--quiet") || args.includes("-q");
864
+ const check = args.includes("--check");
865
+ const dryRun = args.includes("--dry-run") || check;
866
+ const quiet = args.includes("--quiet") || args.includes("-q") || check;
626
867
  const force = args.includes("--force");
868
+ const forceTemplateReset = args.includes("--force-template-reset");
627
869
 
628
870
  // Quick local check: skip API if already at current version (unless dry-run or force)
629
871
  let version = "unknown";
@@ -631,12 +873,13 @@ async function cmdSync(target, args = []) {
631
873
 
632
874
  const metadata = collectMetadata(target);
633
875
  const { manifestContents, clis } = metadata;
634
- let stack = "generic", agents = [];
876
+ let discoveryStack = "generic", agents = [];
635
877
  try {
636
878
  const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
637
- stack = d.stack || "generic";
879
+ discoveryStack = d.stack || "generic";
638
880
  agents = d.selected_agents || [];
639
881
  } catch {}
882
+ const stack = chooseSyncStack(target, discoveryStack);
640
883
  const identity = buildProjectIdentity(target, metadata, stack);
641
884
 
642
885
  if (dryRun) {
@@ -677,6 +920,7 @@ async function cmdSync(target, args = []) {
677
920
  // server needs no raw manifest content — mirrors the /v1/init fix from #4030.
678
921
  manifest_files: hashManifestFiles(manifestContents),
679
922
  dry_run: dryRun, quiet, force,
923
+ force_template_reset: forceTemplateReset,
680
924
  project_name: identity.project_name,
681
925
  project_id: boundProject.project_id || identity.project_id,
682
926
  remote_origin: identity.remote_origin,
@@ -690,7 +934,11 @@ async function cmdSync(target, args = []) {
690
934
  process.exit(1);
691
935
  }
692
936
 
693
- const updated = result.files_updated || {};
937
+ const updated = {
938
+ ...(result.files_updated || {}),
939
+ ...missingLiveManifestDefaults(target, result.files_updated || {}),
940
+ };
941
+ const protectedUpdates = Array.isArray(result.protected_updates) ? result.protected_updates : [];
694
942
  if (dryRun) {
695
943
  const files = Object.keys(updated);
696
944
  if (files.length) {
@@ -706,8 +954,10 @@ async function cmdSync(target, args = []) {
706
954
  } else {
707
955
  log(`${D}dry-run: nothing to update${R}`);
708
956
  }
957
+ printProtectedSyncUpdates(protectedUpdates, { quiet });
709
958
  return;
710
959
  }
960
+ printProtectedSyncUpdates(protectedUpdates, { quiet });
711
961
  const changedCount = Object.keys(updated).length;
712
962
  if (changedCount) {
713
963
  if (!quiet && !shouldAutoYes(args)) {
@@ -772,12 +1022,28 @@ async function cmdSync(target, args = []) {
772
1022
  if (force && result.native_configs) {
773
1023
  const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
774
1024
  let overwritten = 0;
1025
+ let backupDir = null;
775
1026
  for (const name of NATIVE_CONFIGS) {
776
- if (result.native_configs[name]) {
777
- fs.writeFileSync(path.join(target, name), result.native_configs[name], "utf8");
778
- overwritten++;
779
- if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
1027
+ if (!result.native_configs[name]) continue;
1028
+ const dest = path.join(target, name);
1029
+ const next = result.native_configs[name];
1030
+ // Back up a pre-existing, differing native config before overwriting, so
1031
+ // a hand-edited CLAUDE.md/AGENTS.md/… is recoverable from ai/.backups
1032
+ // (sync --force used to clobber these with no backup, #4363).
1033
+ if (fs.existsSync(dest)) {
1034
+ const existing = fs.readFileSync(dest, "utf8");
1035
+ if (existing === next) continue;
1036
+ if (!backupDir) {
1037
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1038
+ backupDir = path.join(target, "ai", ".backups", timestamp);
1039
+ fs.mkdirSync(backupDir, { recursive: true });
1040
+ }
1041
+ fs.writeFileSync(path.join(backupDir, name), existing, "utf8");
1042
+ if (!quiet) console.log(` [force] backed up ${name} to ${path.relative(target, path.join(backupDir, name))}`);
780
1043
  }
1044
+ fs.writeFileSync(dest, next, "utf8");
1045
+ overwritten++;
1046
+ if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
781
1047
  }
782
1048
  if (overwritten && !quiet) {
783
1049
  log(`force: ${overwritten} native config file(s) overwritten`);
@@ -834,6 +1100,8 @@ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
834
1100
  "ai/manifest/project.yaml",
835
1101
  "ai/manifest/commands.yaml",
836
1102
  "ai/manifest/discovery.json",
1103
+ "ai/manifest/current_state.json",
1104
+ "ai/manifest/current_task.json",
837
1105
  ];
838
1106
  for (const rel of expectedAiFiles) {
839
1107
  if (!fs.existsSync(path.join(target, rel))) changes.push(`${rel} (missing)`);
@@ -885,6 +1153,8 @@ module.exports = {
885
1153
  cmdInit,
886
1154
  cmdSync,
887
1155
  cmdProjectBind,
1156
+ buildLocalDryRunPreview,
1157
+ cmdInitLocalDryRun,
888
1158
  buildLocalSyncPreview,
889
1159
  runMcpBootstrap,
890
1160
  bindProjectForCloud,
@@ -892,6 +1162,7 @@ module.exports = {
892
1162
  writeCloudInitCheckpoint,
893
1163
  clearCloudInitCheckpoint,
894
1164
  buildCloudInitResumeCommand,
1165
+ ensureRuntimeGitignore,
895
1166
  collectCurrentAiFiles,
896
1167
  hashFile,
897
1168
  detectRegistryDrift,