@0dai-dev/cli 4.3.7 → 4.3.9

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/bin/0dai.js CHANGED
@@ -201,7 +201,11 @@ const { cmdTrust } = require("../lib/commands/trust");
201
201
  function printHelp() {
202
202
  const counts = loadCanonicalCounts();
203
203
  console.log(`\n ${T}0dai${R} v${VERSION} — One config for ${counts.agent_clis_total} AI agent CLIs · ${mcpToolsLabel(counts)}\n`);
204
- console.log("First-run sequence (canonical):");
204
+ console.log("Try it now — no account, no network:");
205
+ console.log(" 0dai init --local --dry-run # preview configs for this repo, writes nothing");
206
+ console.log(" 0dai init --local # generate them on disk, still no account");
207
+ console.log("");
208
+ console.log("First-run sequence (canonical — full per-CLI configs + sync):");
205
209
  console.log(" npm install -g @0dai-dev/cli # install once, globally");
206
210
  console.log(" 0dai auth login # sign in (OAuth / device code)");
207
211
  console.log(" 0dai activate free # claim free-tier license");
@@ -210,7 +214,7 @@ function printHelp() {
210
214
  console.log("");
211
215
  console.log("Start (first 5 minutes):");
212
216
  console.log(" init Create ai/ layer + MCP [--local] [--dry-run] [--minimal]");
213
- console.log(" doctor Check health, credentials, and drift [--drift]");
217
+ console.log(" doctor Check health, credentials, and drift [--drift] [--security]");
214
218
  console.log(" status Show maturity, swarm, and session state [--json]");
215
219
  console.log(" quickstart Run auth, init, doctor, and status checks in order");
216
220
  console.log(" detect Show detected stack");
@@ -341,6 +345,7 @@ function printInitHelp(commandName = "init") {
341
345
  console.log(" --local Generate the ai/ layer offline without signing in");
342
346
  console.log(" --minimal Generate the smallest starter layer");
343
347
  console.log(" --dry-run Preview init changes without writing them");
348
+ console.log(" (pair with --local to preview configs with no account or network)");
344
349
  console.log(" --no-wizard Skip the interactive first-run wizard");
345
350
  console.log(" --auth-code Exchange a browser/device auth code before init");
346
351
  console.log(" --code Redeem an activation/license code before init");
@@ -503,13 +508,15 @@ async function main() {
503
508
  case "detect": await cmdDetect(target); break;
504
509
  case "doctor": {
505
510
  const driftMode = args.includes("--drift");
511
+ const securityMode = args.includes("--security");
506
512
  // Go fast-path covers only the base read-only doctor (no --drift, no
507
- // network). Anything else falls through to the full Node implementation.
513
+ // --security, no network). Anything else falls through to the full Node
514
+ // implementation so the flag is not silently swallowed.
508
515
  const layerFreshness = collectLayerVersionFreshness(target);
509
- if (!driftMode && layerFreshness.status !== "stale") {
516
+ if (!driftMode && !securityMode && layerFreshness.status !== "stale") {
510
517
  tryGoHotPath("doctor", target, args.slice(1));
511
518
  }
512
- cmdDoctor(target, { drift: driftMode, json: args.includes("--json") });
519
+ cmdDoctor(target, { drift: driftMode, security: securityMode, json: args.includes("--json") });
513
520
  break;
514
521
  }
515
522
  case "drift": {
@@ -481,6 +481,197 @@ function printDriftReport(target, options = {}) {
481
481
  return payload;
482
482
  }
483
483
 
484
+ // --- security mode (0dai doctor --security) -------------------------------
485
+ //
486
+ // Static, code-derived disclosure of the data boundary so a reviewer can see,
487
+ // without reading source, what leaves the box and which routes need operator
488
+ // authZ. Three sections:
489
+ // 1. egress — network endpoints the CLI may call (reused verbatim from
490
+ // trust.js _buildColdPayload so there is ONE egress list).
491
+ // 2. local sinks — files the CLI writes locally (NOT egress) + the host-side
492
+ // secret vault dir, live-detected and labeled.
493
+ // 3. authZ — deny-by-default operator-gate summary, enforced server-side
494
+ // (web/src/lib/auth.ts requireOperator); described, not probed.
495
+ //
496
+ // This mode performs NO network calls and reads NO secret values — it only
497
+ // reports paths and presence. Run `0dai trust` for the live enforced scope.
498
+
499
+ // authZ summary mirrors web/src/lib/auth.ts. Tiers are described, not probed —
500
+ // the CLI cannot reach into the deployment's route guards at runtime.
501
+ const AUTHZ_SUMMARY = Object.freeze({
502
+ enforced_by: "web/src/lib/auth.ts (Next.js API routes)",
503
+ default: "deny",
504
+ note:
505
+ "Operator-state routes require authN + authZ. With ODAI_OPERATOR_EMAILS unset, "
506
+ + "every authenticated user is denied (403) — default-deny, not default-allow.",
507
+ tiers: Object.freeze([
508
+ {
509
+ level: "operator",
510
+ guard: "requireOperator",
511
+ rule: "authN + email in ODAI_OPERATOR_EMAILS allowlist; unset allowlist = deny all",
512
+ routes: "swarm/*, cli/[action], team",
513
+ },
514
+ {
515
+ level: "authenticated",
516
+ guard: "requireAuth",
517
+ rule: "any valid token (401 if absent/invalid)",
518
+ routes: "plugins",
519
+ },
520
+ {
521
+ level: "auth-forward",
522
+ guard: "forward",
523
+ rule: "web layer forwards any Authorization header to the Python API; downstream authZ enforced there (verify)",
524
+ routes: "admin/* (adminForwardAuth), events",
525
+ },
526
+ {
527
+ level: "open",
528
+ guard: "none",
529
+ rule: "intentionally unauthenticated public endpoints",
530
+ routes: "contact, public/*",
531
+ },
532
+ ]),
533
+ });
534
+
535
+ function _secretVaultProbe(home = process.env.HOME || process.env.USERPROFILE || "") {
536
+ // Host-side secret vault written by scripts/telegram_secrets.py and
537
+ // scripts/secrets_decrypt.sh — NOT by this CLI. We only report presence.
538
+ const dir = home ? path.join(home, ".config", "secrets") : "";
539
+ const present = Boolean(dir && fs.existsSync(dir));
540
+ return {
541
+ name: "~/.config/secrets",
542
+ path: dir,
543
+ present,
544
+ written_by_cli: false,
545
+ note: present
546
+ ? "present on this host; populated by server-side secret sync, not the CLI"
547
+ : "absent on this host; populated by server-side secret sync when configured (verify)",
548
+ };
549
+ }
550
+
551
+ function _mcpEgressEntry(target, env = process.env) {
552
+ // MCP cloud transport host — DEFAULT_MCP_HOST from lib/utils/mcp-auth.js,
553
+ // overridable via MCP_HOST / ODAI_MCP_HOST. The local 0dai + filesystem MCP
554
+ // servers run as on-box subprocesses (no egress); only the cloud server
555
+ // ("claude_ai_0dai") talks to the network. We also surface the configured
556
+ // server names from .mcp.json if present (no values, just names).
557
+ const host = env.MCP_HOST || env.ODAI_MCP_HOST || "https://mcp.0dai.dev";
558
+ let configured = [];
559
+ try {
560
+ configured = _readMcpConfigSummary(target).servers || [];
561
+ } catch { configured = []; }
562
+ return {
563
+ endpoint: `${host}/mcp`,
564
+ trigger: "MCP cloud server (claude_ai_0dai) when an agent CLI connects to it",
565
+ data: "MCP tool calls over an authenticated session; local 0dai + filesystem servers are on-box subprocesses (no egress)",
566
+ configured_servers: configured,
567
+ };
568
+ }
569
+
570
+ function collectSecurityPayload(target, options = {}) {
571
+ const home = options.home || process.env.HOME || process.env.USERPROFILE || "";
572
+ const env = options.env || process.env;
573
+ let egress;
574
+ try {
575
+ egress = require("./trust")._buildColdPayload(target).egress;
576
+ } catch {
577
+ egress = { status: "unavailable", note: "trust.js egress facts not loadable" };
578
+ }
579
+ // Augment the trust.js subset with the two surfaces the issue names that are
580
+ // not in that subset: the MCP cloud transport and the activation telemetry
581
+ // POST. Both are derived from real CLI code (mcp-auth.js, auth.js).
582
+ egress = JSON.parse(JSON.stringify(egress));
583
+ egress.note =
584
+ "Egress surfaces relevant to this disclosure (MCP, telemetry, on-command "
585
+ + "pushes). Not an exhaustive list of every /v1/* endpoint the CLI can reach.";
586
+ egress.mcp = _mcpEgressEntry(target, env);
587
+ if (Array.isArray(egress.on_explicit_command_only)) {
588
+ egress.on_explicit_command_only.push({
589
+ endpoint: "POST /v1/events (api.0dai.dev)",
590
+ trigger: "0dai activate free (best-effort, on successful activation)",
591
+ data: "activation event {event, timestamp, path, source}; no file contents, no secrets",
592
+ });
593
+ }
594
+ return {
595
+ target,
596
+ generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
597
+ note:
598
+ "Static, code-derived data boundary. This mode makes NO network calls and "
599
+ + "reads NO secret values. Run `0dai trust` for the live enforced scope.",
600
+ egress,
601
+ local_sinks: [
602
+ {
603
+ name: "ai/meta/telemetry/activation.jsonl",
604
+ kind: "local file write",
605
+ note: "activation funnel timing written to disk; a summary event is POSTed to /v1/events only on `0dai activate free` (see egress)",
606
+ },
607
+ {
608
+ name: "ai/meta/telemetry/*.jsonl",
609
+ kind: "local file write",
610
+ note: "MCP tool usage, operator-ack, and other local telemetry; written to disk, not auto-POSTed",
611
+ },
612
+ ],
613
+ secret_vault: _secretVaultProbe(home),
614
+ authz: AUTHZ_SUMMARY,
615
+ docs: "docs/data-boundary.md",
616
+ };
617
+ }
618
+
619
+ function printSecurityReport(target, options = {}) {
620
+ const payload = collectSecurityPayload(target, options);
621
+ const W = process.stdout.isTTY ? "\x1b[33m" : "";
622
+ const G = process.stdout.isTTY ? "\x1b[32m" : "";
623
+ const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
624
+
625
+ console.log("\n 0dai doctor --security — data boundary");
626
+ console.log(` ${D}${payload.note}${R2}`);
627
+
628
+ console.log("\n egress (network):");
629
+ const eg = payload.egress || {};
630
+ if (Array.isArray(eg.unconditional)) {
631
+ console.log(" unconditional (every CLI run):");
632
+ for (const ep of eg.unconditional) {
633
+ console.log(` * ${ep.endpoint}`);
634
+ console.log(` ${D}trigger: ${ep.trigger}${R2}`);
635
+ }
636
+ }
637
+ if (eg.mcp) {
638
+ console.log(" MCP:");
639
+ console.log(` * ${eg.mcp.endpoint}`);
640
+ console.log(` ${D}trigger: ${eg.mcp.trigger}${R2}`);
641
+ if (Array.isArray(eg.mcp.configured_servers) && eg.mcp.configured_servers.length) {
642
+ console.log(` ${D}configured servers: ${eg.mcp.configured_servers.join(", ")}${R2}`);
643
+ }
644
+ }
645
+ if (Array.isArray(eg.on_explicit_command_only)) {
646
+ console.log(" on explicit command only:");
647
+ for (const ep of eg.on_explicit_command_only) {
648
+ console.log(` * ${ep.endpoint} ${D}(${ep.trigger})${R2}`);
649
+ console.log(` ${D}data: ${ep.data}${R2}`);
650
+ }
651
+ }
652
+ if (!eg.unconditional && !eg.on_explicit_command_only) {
653
+ console.log(` ${D}${eg.note || "egress facts unavailable"}${R2}`);
654
+ }
655
+
656
+ console.log("\n local sinks (no egress):");
657
+ for (const sink of payload.local_sinks) {
658
+ console.log(` * ${sink.name} ${D}(${sink.note})${R2}`);
659
+ }
660
+ const vault = payload.secret_vault;
661
+ const vaultMark = vault.present ? `${G}present${R2}` : `${D}absent${R2}`;
662
+ console.log(` ${vaultMark} ${vault.name} ${D}(${vault.note})${R2}`);
663
+
664
+ console.log("\n authZ (enforced server-side, deny-by-default):");
665
+ console.log(` ${W}default: ${payload.authz.default}${R2} ${D}— ${payload.authz.note}${R2}`);
666
+ for (const tier of payload.authz.tiers) {
667
+ console.log(` * ${tier.level.padEnd(13)} ${tier.guard} ${D}→ ${tier.routes}${R2}`);
668
+ console.log(` ${D}${tier.rule}${R2}`);
669
+ }
670
+
671
+ console.log(`\n ${D}Full doc: ${payload.docs} Live enforced scope: 0dai trust${R2}`);
672
+ return payload;
673
+ }
674
+
484
675
  function collectDoctorPayload(target, options = {}) {
485
676
  const payload = {
486
677
  binary: "node",
@@ -499,6 +690,14 @@ function collectDoctorPayload(target, options = {}) {
499
690
  function cmdDoctor(target, options = {}) {
500
691
  const ai = path.join(target, "ai");
501
692
  if (!fs.existsSync(ai)) { log("No 0dai config found. Run: 0dai init"); return; }
693
+ if (options.security) {
694
+ if (options.json) {
695
+ const payload = collectSecurityPayload(target, options.securityOptions || {});
696
+ console.log(JSON.stringify(payload, null, 2));
697
+ return payload;
698
+ }
699
+ return printSecurityReport(target, options.securityOptions || {});
700
+ }
502
701
  if (options.json) {
503
702
  const payload = collectDoctorPayload(target, {
504
703
  drift: options.drift,
@@ -794,4 +993,6 @@ module.exports = {
794
993
  collectGitHubIdentityPayload,
795
994
  collectMcpExposurePayload,
796
995
  formatMcpExposureLine,
996
+ collectSecurityPayload,
997
+ printSecurityReport,
797
998
  };
@@ -7,10 +7,11 @@ const {
7
7
  VERSION, SUPPORTED_CLIS,
8
8
  CONFIG_DIR, PROJECTS_FILE,
9
9
  apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
10
- collectMetadata, buildProjectIdentity, registerProject, projectIdFor, hashManifestFiles,
10
+ collectMetadata, inferProjectName, detectStackHint,
11
+ buildProjectIdentity, registerProject, projectIdFor, hashManifestFiles,
11
12
  loadProjectBinding, boundProjectIdentityFromBinding, validateProjectDisplayName,
12
13
  writeProjectBinding,
13
- writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults,
14
+ writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults, LIVE_MANIFEST_DEFAULTS,
14
15
  sendProjectHeartbeat, recordExperienceEvent,
15
16
  logFirstRunSuccess, checkVersion,
16
17
  } = shared;
@@ -403,12 +404,98 @@ async function cmdProjectBind(target, args = []) {
403
404
  return payload;
404
405
  }
405
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
+
406
484
  async function cmdInit(target, args = []) {
407
485
  const dryRun = args.includes("--dry-run");
408
486
  const minimal = args.includes("--minimal");
409
487
  const noWizard = args.includes("--no-wizard");
410
488
  const localMode = args.includes("--local");
411
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
+
412
499
  if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
413
500
  const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
414
501
  log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
@@ -1066,6 +1153,8 @@ module.exports = {
1066
1153
  cmdInit,
1067
1154
  cmdSync,
1068
1155
  cmdProjectBind,
1156
+ buildLocalDryRunPreview,
1157
+ cmdInitLocalDryRun,
1069
1158
  buildLocalSyncPreview,
1070
1159
  runMcpBootstrap,
1071
1160
  bindProjectForCloud,
@@ -30,7 +30,7 @@ function findRepoScript(target, name) {
30
30
 
31
31
  function runLocal(target, args) {
32
32
  const script = findRepoScript(target, "play_book.py");
33
- if (!script) return { ok: false, error: "play_book.py not found locally" };
33
+ if (!script) return { ok: false, repoRequired: true, error: "play_book.py not found locally" };
34
34
  const res = spawnSync("python3", [script, "--root", target, ...args], {
35
35
  encoding: "utf8",
36
36
  stdio: ["ignore", "pipe", "pipe"],
@@ -116,7 +116,7 @@ function cmdFork(target, slug, args) {
116
116
  const author = argAfter(args, "--author");
117
117
  if (!newSlug || !author) { log("fork requires --as <new-slug> --author <handle>"); return; }
118
118
  const res = runLocal(target, ["fork", slug, "--as", newSlug, "--author", author]);
119
- if (!res.ok) { log(`fork failed: ${res.error}`); process.exit(1); }
119
+ if (!handleLocal(res, "fork")) return;
120
120
  console.log(typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2));
121
121
  }
122
122
 
@@ -127,7 +127,7 @@ function cmdRun(target, slug, args) {
127
127
  if (args[i] === "--var" && args[i + 1]) { fwdArgs.push("--var", args[i + 1]); i++; }
128
128
  }
129
129
  const res = runLocal(target, fwdArgs);
130
- if (!res.ok) { log(`run failed: ${res.error}`); process.exit(1); }
130
+ if (!handleLocal(res, "run")) return;
131
131
  console.log(typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2));
132
132
  }
133
133
 
@@ -136,7 +136,7 @@ function cmdRank(target, args) {
136
136
  const outcomes = argAfter(args, "--outcomes");
137
137
  if (outcomes) fwdArgs.push("--outcomes", outcomes);
138
138
  const res = runLocal(target, fwdArgs);
139
- if (!res.ok) { log(`rank failed: ${res.error}`); process.exit(1); }
139
+ if (!handleLocal(res, "rank")) return;
140
140
  console.log(typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2));
141
141
  }
142
142
 
@@ -145,6 +145,22 @@ function argAfter(args, flag) {
145
145
  return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
146
146
  }
147
147
 
148
+ // fork/run/rank drive the bundled play_book.py, which only ships inside a 0dai
149
+ // project checkout (it needs ai/plays/ on disk + PyYAML). A bare `npm install -g`
150
+ // has neither, so findRepoScript returns null. That is an expected environment
151
+ // limitation, not a crash — say so honestly and exit 0 instead of a misleading
152
+ // "X failed: play_book.py not found locally" + exit 1.
153
+ function handleLocal(res, verb) {
154
+ if (res.repoRequired) {
155
+ log(`'play ${verb}' runs locally against ai/plays/ and needs a 0dai project checkout (it is not bundled in the npm package).`);
156
+ log(`Run it from a cloned project — e.g. 'git clone https://github.com/iGeezmo/0dai && cd 0dai && 0dai play ${verb} …'.`);
157
+ log(`The server-backed 'play list / get / search' work anywhere.`);
158
+ return false;
159
+ }
160
+ if (!res.ok) { log(`${verb} failed: ${res.error}`); process.exit(1); }
161
+ return true;
162
+ }
163
+
148
164
  async function cmdPlay(target, sub, args) {
149
165
  const remaining = args.slice(2);
150
166
  if (!sub || sub === "help") {
@@ -26,6 +26,115 @@ function loadProjectBinding(target) {
26
26
  return shared.loadProjectBinding(target);
27
27
  }
28
28
 
29
+ // Open sprint:current issues are the authoritative work queue (SPRINT.md
30
+ // retired as SoT). Probe gh; degrade quietly when gh is absent or the token
31
+ // is not authenticated — never throw out of status.
32
+ function collectSprintPayload(target, options = {}) {
33
+ const out = { available: false, authenticated: false, issues: [], reason: "" };
34
+ try {
35
+ const r = spawnSync(
36
+ "gh",
37
+ [
38
+ "issue", "list",
39
+ "--label", "sprint:current",
40
+ "--state", "open",
41
+ "--json", "number,title",
42
+ "--limit", "20",
43
+ ],
44
+ {
45
+ cwd: target,
46
+ stdio: ["ignore", "pipe", "pipe"],
47
+ encoding: "utf8",
48
+ timeout: options.ghTimeout || 8000,
49
+ },
50
+ );
51
+ if (r.error && r.error.code === "ENOENT") {
52
+ out.reason = "gh CLI not installed";
53
+ return out;
54
+ }
55
+ out.available = true;
56
+ if (r.status !== 0) {
57
+ const err = String(r.stderr || "").trim();
58
+ const lastLine = (err.split("\n").slice(-1)[0] || "").replace(/^(fatal:|error:|failed to run git:)\s*/i, "");
59
+ if (/not a git repository/i.test(err)) {
60
+ out.reason = "not a git repository — run inside a repo";
61
+ } else if (/auth|logged in|token/i.test(err)) {
62
+ out.reason = "gh not authenticated — run: gh auth login";
63
+ } else {
64
+ out.reason = lastLine || "gh issue list failed";
65
+ }
66
+ return out;
67
+ }
68
+ out.authenticated = true;
69
+ const parsed = JSON.parse(String(r.stdout || "[]").trim() || "[]");
70
+ out.issues = Array.isArray(parsed)
71
+ ? parsed.map((i) => ({ number: i.number, title: i.title || "" }))
72
+ : [];
73
+ } catch (e) {
74
+ const msg = (String((e && e.message) || e).split("\n").slice(-1)[0] || "").replace(/^(fatal:|error:)\s*/i, "");
75
+ out.reason = /not a git repository/i.test(msg)
76
+ ? "not a git repository — run inside a repo"
77
+ : (msg || "sprint probe failed");
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // Active flight = newest flight plan under ai/meta/reports/flight-F*.md, ranked
83
+ // by flight number (F17 > F6) then by the trailing date in the filename.
84
+ function findActiveFlight(target) {
85
+ const dir = path.join(target, "ai", "meta", "reports");
86
+ let names;
87
+ try {
88
+ names = fs.readdirSync(dir).filter((f) => /^flight-F\d+.*\.md$/.test(f));
89
+ } catch {
90
+ return null;
91
+ }
92
+ if (!names.length) return null;
93
+ const parse = (name) => {
94
+ const m = name.match(/^flight-F(\d+)/);
95
+ const dateM = name.match(/(\d{4}-\d{2}-\d{2})/);
96
+ return { name, num: m ? Number(m[1]) : -1, date: dateM ? dateM[1] : "" };
97
+ };
98
+ const newest = names
99
+ .map(parse)
100
+ .sort((a, b) => (b.num - a.num) || b.date.localeCompare(a.date))[0];
101
+ const file = path.join(dir, newest.name);
102
+ let id = `F${newest.num}`, title = "", flightStatus = "";
103
+ try {
104
+ const text = fs.readFileSync(file, "utf8");
105
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
106
+ const block = fm ? fm[1] : text;
107
+ const idM = block.match(/^id:\s*(.+)$/m);
108
+ const titleM = block.match(/^title:\s*(.+)$/m);
109
+ const statusM = block.match(/^status:\s*(.+)$/m);
110
+ if (idM) id = idM[1].trim().replace(/^["']|["']$/g, "");
111
+ if (titleM) title = titleM[1].trim().replace(/^["']|["']$/g, "");
112
+ if (statusM) flightStatus = statusM[1].trim().replace(/^["']|["']$/g, "");
113
+ } catch {}
114
+ return { id, title, status: flightStatus, file: path.relative(target, file) };
115
+ }
116
+
117
+ // Drift signal: roadmap.json is generated from ROADMAP.md. If the source is
118
+ // newer than the generated artifact, the JSON is stale and should be regenerated.
119
+ function collectRoadmapDrift(target) {
120
+ const out = { detected: false, reason: "" };
121
+ const jsonPath = path.join(target, "ai", "meta", "manifest", "roadmap.json");
122
+ const mdPath = path.join(target, "ROADMAP.md");
123
+ let jsonMtime, mdMtime;
124
+ try { jsonMtime = fs.statSync(jsonPath).mtimeMs; } catch { return out; }
125
+ try { mdMtime = fs.statSync(mdPath).mtimeMs; } catch { return out; }
126
+ // Prefer an explicit updated_at/generated_at in the JSON over file mtime when present.
127
+ const j = loadJson(jsonPath);
128
+ const stamp = j && (j.updated_at || j.generated_at);
129
+ const jsonTime = stamp ? Date.parse(stamp) : NaN;
130
+ const jsonEffective = Number.isFinite(jsonTime) ? jsonTime : jsonMtime;
131
+ if (mdMtime > jsonEffective) {
132
+ out.detected = true;
133
+ out.reason = "ROADMAP.md is newer than roadmap.json — run: python3 scripts/roadmap_to_json.py";
134
+ }
135
+ return out;
136
+ }
137
+
29
138
  function collectStatusPayload(target, options = {}) {
30
139
  const ai = path.join(target, "ai");
31
140
  const baseIdentity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
@@ -139,6 +248,9 @@ function collectStatusPayload(target, options = {}) {
139
248
  drift: {
140
249
  detected: driftDetected,
141
250
  },
251
+ sprint: collectSprintPayload(target, options),
252
+ flight: findActiveFlight(target),
253
+ roadmap_drift: collectRoadmapDrift(target),
142
254
  mcp_exposure: collectMcpExposurePayload(target, options.mcpExposureOptions || {}),
143
255
  warnings: warningCount,
144
256
  activation_ttfv: getActivationDurationStats(target),
@@ -159,6 +271,33 @@ function cmdStatus(target, options = {}) {
159
271
  console.log(` swarm: ${payload.swarm.queued} queued, ${payload.swarm.active} active, ${payload.swarm.done} done`);
160
272
  }
161
273
 
274
+ const sprint = payload.sprint || {};
275
+ if (sprint.authenticated) {
276
+ if (sprint.issues.length) {
277
+ console.log(` sprint:current — ${sprint.issues.length} open:`);
278
+ for (const i of sprint.issues.slice(0, 8)) {
279
+ console.log(` #${i.number} ${i.title}`);
280
+ }
281
+ if (sprint.issues.length > 8) {
282
+ console.log(` …and ${sprint.issues.length - 8} more`);
283
+ }
284
+ } else {
285
+ console.log(` sprint:current — none open`);
286
+ }
287
+ } else if (sprint.reason) {
288
+ console.log(` sprint:current — ${D}unavailable (${sprint.reason})${R}`);
289
+ }
290
+
291
+ if (payload.flight) {
292
+ const ftitle = payload.flight.title ? ` ${payload.flight.title}` : "";
293
+ const fstatus = payload.flight.status ? ` [${payload.flight.status}]` : "";
294
+ console.log(` flight: ${payload.flight.id}${ftitle}${fstatus}`);
295
+ }
296
+
297
+ if (payload.roadmap_drift && payload.roadmap_drift.detected) {
298
+ console.log(` roadmap: ${D}${payload.roadmap_drift.reason}${R}`);
299
+ }
300
+
162
301
  if (payload.swarm.quota_state === "locked") {
163
302
  console.log(` swarm quota: ${D}locked (Free) — upgrade for ${payload.swarm.daily_limit} tasks/day${R}`);
164
303
  } else {
@@ -198,4 +337,10 @@ function cmdStatus(target, options = {}) {
198
337
  return payload;
199
338
  }
200
339
 
201
- module.exports = { cmdStatus, collectStatusPayload };
340
+ module.exports = {
341
+ cmdStatus,
342
+ collectStatusPayload,
343
+ collectSprintPayload,
344
+ findActiveFlight,
345
+ collectRoadmapDrift,
346
+ };
@@ -283,4 +283,4 @@ function cmdTrust(target, args) {
283
283
  process.exit(1);
284
284
  }
285
285
 
286
- module.exports = { cmdTrust, renderColdScope };
286
+ module.exports = { cmdTrust, renderColdScope, _buildColdPayload };
@@ -26,7 +26,10 @@ from collections import defaultdict
26
26
  from dataclasses import dataclass, field
27
27
  from typing import Any, Iterable
28
28
 
29
- from PIL import Image, ImageColor, ImageDraw, ImageFont
29
+ try: # Pillow is needed ONLY for `--png`; the text + `--json` paths never touch it.
30
+ from PIL import Image, ImageColor, ImageDraw, ImageFont
31
+ except ModuleNotFoundError: # npm-installed users may lack Pillow — degrade, don't crash at import.
32
+ Image = ImageColor = ImageDraw = ImageFont = None # type: ignore[assignment]
30
33
 
31
34
 
32
35
  SUPPORTED_MODES = ("edits", "failures", "authorship")
@@ -781,6 +784,12 @@ def _draw_wrapped_text(draw: ImageDraw.ImageDraw, text: str, box: tuple[float, f
781
784
 
782
785
 
783
786
  def render_png(payload: dict[str, Any], output_path: pathlib.Path | str) -> pathlib.Path:
787
+ if Image is None: # Pillow absent (e.g. a fresh `npm install -g` without `pip install pillow`).
788
+ raise SystemExit(
789
+ "0dai heatmap --png needs Pillow, which is not installed.\n"
790
+ "Install it (pip install pillow) for PNG export; the default text and --json "
791
+ "summaries work without it."
792
+ )
784
793
  width = int(payload.get("width") or DEFAULT_WIDTH)
785
794
  height = int(payload.get("height") or DEFAULT_HEIGHT)
786
795
  out = pathlib.Path(output_path).expanduser().resolve()
package/lib/shared.js CHANGED
@@ -492,6 +492,7 @@ module.exports = {
492
492
  sendProjectHeartbeat, recordExperienceEvent, logFirstRunSuccess,
493
493
  // Files
494
494
  mergeSettingsJson, writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults,
495
+ LIVE_MANIFEST_DEFAULTS,
495
496
  findRepoScript, repoScriptCandidates, resolveBundledScript, resolvePythonScript,
496
497
  // Version
497
498
  checkVersion,
@@ -314,17 +314,34 @@ function inferProjectName(target, manifestContents) {
314
314
  }
315
315
 
316
316
  function detectStackHint(projectFiles, manifestContents) {
317
+ // Config-file signals first (strongest).
317
318
  if (manifestContents["next.config.js"] || manifestContents["next.config.mjs"] || manifestContents["next.config.ts"]) return "nextjs";
319
+ // Flutter: a pubspec.yaml that pulls the flutter SDK (a plain Dart pubspec has no flutter dep).
320
+ if (manifestContents["pubspec.yaml"] && /(^|\n)\s*flutter\s*:|sdk:\s*flutter/i.test(manifestContents["pubspec.yaml"])) return "flutter";
321
+
322
+ // Node deps fallback (#4493): when config files are absent, infer the stack from
323
+ // package.json dependencies so an account-free `init --local` still gets a real stack
324
+ // instead of "unknown". Order matters — react-native ships react, so it must win first.
318
325
  if (manifestContents["package.json"]) {
319
326
  try {
320
327
  const pkg = JSON.parse(manifestContents["package.json"]);
321
328
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
329
+ if (deps["react-native"] || deps.expo) return "react-native";
322
330
  if (deps.next) return "nextjs";
323
- if (deps.react || deps.vue || deps.svelte) return "frontend";
331
+ if (deps.react || deps.vue || deps.svelte || deps["@angular/core"]) return "frontend";
332
+ if (deps.express || deps.fastify || deps.koa || deps["@nestjs/core"] || deps["@hapi/hapi"]) return "backend-api";
324
333
  } catch {}
325
334
  }
335
+
326
336
  if (manifestContents["go.mod"]) return "go-service";
327
- if (manifestContents["pyproject.toml"] || manifestContents["requirements.txt"]) return "python-service";
337
+
338
+ // Python: a data/ML signal wins over the generic python service hint.
339
+ if (manifestContents["pyproject.toml"] || manifestContents["requirements.txt"]) {
340
+ const pyText = `${manifestContents["requirements.txt"] || ""}\n${manifestContents["pyproject.toml"] || ""}`;
341
+ if (/\b(pandas|numpy|scikit-learn|sklearn|torch|tensorflow|keras|jupyter|transformers)\b/i.test(pyText)) return "data-ml";
342
+ return "python-service";
343
+ }
344
+
328
345
  if (projectFiles.some((name) => name.startsWith("apps/") || name.startsWith("packages/"))) return "monorepo";
329
346
  return "unknown";
330
347
  }
package/lib/wizard.js CHANGED
@@ -149,6 +149,41 @@ async function stepDetect(rl, target) {
149
149
  return detected;
150
150
  }
151
151
 
152
+ // Best-effort offline command detection for the account-free scaffold (#4493 follow-up).
153
+ // Surfaces real, runnable commands so the generated CLAUDE.md/AGENTS.md is useful without
154
+ // an account. Only emits commands actually present (package.json scripts / Makefile) or
155
+ // safe stack conventions — never invents project-specific commands.
156
+ function detectProjectCommands(target, stack = []) {
157
+ const cmds = [];
158
+ const seen = new Set();
159
+ const add = (cmd) => { if (cmd && !seen.has(cmd)) { seen.add(cmd); cmds.push(cmd); } };
160
+ try {
161
+ const pkgPath = path.join(target, "package.json");
162
+ if (fs.existsSync(pkgPath)) {
163
+ const scripts = JSON.parse(fs.readFileSync(pkgPath, "utf8")).scripts || {};
164
+ for (const name of ["dev", "start", "build", "test", "lint", "typecheck"]) {
165
+ if (scripts[name]) add(`npm run ${name}`);
166
+ }
167
+ }
168
+ } catch {}
169
+ try {
170
+ const mkPath = path.join(target, "Makefile");
171
+ if (fs.existsSync(mkPath)) {
172
+ const targets = fs.readFileSync(mkPath, "utf8").split("\n")
173
+ .map((l) => (l.match(/^([a-zA-Z][\w-]*):/) || [])[1]).filter(Boolean);
174
+ for (const t of ["build", "test", "lint", "run", "dev"]) {
175
+ if (targets.includes(t)) add(`make ${t}`);
176
+ }
177
+ }
178
+ } catch {}
179
+ if (!cmds.length) {
180
+ const s = stack[0] || "";
181
+ if (s === "go-service") { add("go build ./..."); add("go test ./..."); }
182
+ else if (s === "python-service" || s === "fastapi" || s === "data-ml") { add("pytest"); add("ruff check ."); }
183
+ }
184
+ return cmds;
185
+ }
186
+
152
187
  function stepGenerate(target, agent, stack) {
153
188
  console.log("");
154
189
  console.log(" Generating AI agent configs...");
@@ -184,9 +219,15 @@ function stepGenerate(target, agent, stack) {
184
219
  );
185
220
  ensureLiveManifestDefaults(target);
186
221
 
187
- // CLAUDE.md
188
- const claudeMd = `# ${discovery.project_name}\n\nStack: ${stack.join(", ") || "unknown"}\nGenerated by 0dai wizard.\n\n## Commands\n\nSee ai/manifest/ for full configuration.\n`;
189
- const agentsMd = `# Agent Configuration\n\nProject: ${discovery.project_name}\nStack: ${stack.join(", ") || "unknown"}\n\nSee ai/manifest/ for configuration details.\n`;
222
+ // CLAUDE.md / AGENTS.md — enriched offline scaffold (#4493 follow-up): real detected
223
+ // commands + an honest pointer to the account-gated full config, instead of a stub.
224
+ const stackLabel = stack.join(", ") || "unknown";
225
+ const commands = detectProjectCommands(target, stack);
226
+ const cmdBlock = commands.length
227
+ ? commands.map((c) => `- \`${c}\``).join("\n")
228
+ : "_No build/test commands detected. Add them to package.json scripts or a Makefile, then re-run._";
229
+ const claudeMd = `# ${discovery.project_name}\n\nStack: ${stackLabel}\nGenerated offline by \`0dai init --local\`.\n\n## Commands\n\n${cmdBlock}\n\n## Project memory\n\nDurable context (manifest, decisions, roadmap) lives in \`ai/\`. Run \`0dai sync\` with a free account to generate the full per-CLI config set tailored to ${stackLabel}.\n`;
230
+ const agentsMd = `# Agent Configuration\n\nProject: ${discovery.project_name}\nStack: ${stackLabel}\n\n## Commands\n\n${cmdBlock}\n\nProject context lives in \`ai/\`. Run \`0dai sync\` (free account) for the full per-CLI configuration.\n`;
190
231
  writeFiles(target, {
191
232
  "CLAUDE.md": claudeMd,
192
233
  "AGENTS.md": agentsMd,
@@ -317,5 +358,6 @@ module.exports = {
317
358
  needsWizard,
318
359
  isInteractive,
319
360
  stepGenerate,
361
+ detectProjectCommands,
320
362
  AGENTS,
321
363
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "4.3.7",
3
+ "version": "4.3.9",
4
4
  "description": "One config layer for seven AI coding agents \u2014 Claude Code, Codex, OpenCode, Gemini, Aider, Qoder, Cursor",
5
5
  "bin": {
6
6
  "0dai": "./bin/0dai.js"