@0dai-dev/cli 4.3.7 → 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.
- package/bin/0dai.js +7 -4
- package/lib/commands/doctor.js +201 -0
- package/lib/commands/init.js +91 -2
- package/lib/commands/play.js +20 -4
- package/lib/commands/status.js +138 -1
- package/lib/commands/trust.js +1 -1
- package/lib/python/heatmap.py +10 -1
- package/lib/shared.js +1 -0
- package/package.json +1 -1
package/bin/0dai.js
CHANGED
|
@@ -210,7 +210,7 @@ function printHelp() {
|
|
|
210
210
|
console.log("");
|
|
211
211
|
console.log("Start (first 5 minutes):");
|
|
212
212
|
console.log(" init Create ai/ layer + MCP [--local] [--dry-run] [--minimal]");
|
|
213
|
-
console.log(" doctor Check health, credentials, and drift [--drift]");
|
|
213
|
+
console.log(" doctor Check health, credentials, and drift [--drift] [--security]");
|
|
214
214
|
console.log(" status Show maturity, swarm, and session state [--json]");
|
|
215
215
|
console.log(" quickstart Run auth, init, doctor, and status checks in order");
|
|
216
216
|
console.log(" detect Show detected stack");
|
|
@@ -341,6 +341,7 @@ function printInitHelp(commandName = "init") {
|
|
|
341
341
|
console.log(" --local Generate the ai/ layer offline without signing in");
|
|
342
342
|
console.log(" --minimal Generate the smallest starter layer");
|
|
343
343
|
console.log(" --dry-run Preview init changes without writing them");
|
|
344
|
+
console.log(" (pair with --local to preview configs with no account or network)");
|
|
344
345
|
console.log(" --no-wizard Skip the interactive first-run wizard");
|
|
345
346
|
console.log(" --auth-code Exchange a browser/device auth code before init");
|
|
346
347
|
console.log(" --code Redeem an activation/license code before init");
|
|
@@ -503,13 +504,15 @@ async function main() {
|
|
|
503
504
|
case "detect": await cmdDetect(target); break;
|
|
504
505
|
case "doctor": {
|
|
505
506
|
const driftMode = args.includes("--drift");
|
|
507
|
+
const securityMode = args.includes("--security");
|
|
506
508
|
// Go fast-path covers only the base read-only doctor (no --drift, no
|
|
507
|
-
// network). Anything else falls through to the full Node
|
|
509
|
+
// --security, no network). Anything else falls through to the full Node
|
|
510
|
+
// implementation so the flag is not silently swallowed.
|
|
508
511
|
const layerFreshness = collectLayerVersionFreshness(target);
|
|
509
|
-
if (!driftMode && layerFreshness.status !== "stale") {
|
|
512
|
+
if (!driftMode && !securityMode && layerFreshness.status !== "stale") {
|
|
510
513
|
tryGoHotPath("doctor", target, args.slice(1));
|
|
511
514
|
}
|
|
512
|
-
cmdDoctor(target, { drift: driftMode, json: args.includes("--json") });
|
|
515
|
+
cmdDoctor(target, { drift: driftMode, security: securityMode, json: args.includes("--json") });
|
|
513
516
|
break;
|
|
514
517
|
}
|
|
515
518
|
case "drift": {
|
package/lib/commands/doctor.js
CHANGED
|
@@ -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
|
};
|
package/lib/commands/init.js
CHANGED
|
@@ -7,10 +7,11 @@ const {
|
|
|
7
7
|
VERSION, SUPPORTED_CLIS,
|
|
8
8
|
CONFIG_DIR, PROJECTS_FILE,
|
|
9
9
|
apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
|
|
10
|
-
collectMetadata,
|
|
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,
|
package/lib/commands/play.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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") {
|
package/lib/commands/status.js
CHANGED
|
@@ -26,6 +26,107 @@ 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
|
+
out.reason = /auth|logged in|token/i.test(err)
|
|
59
|
+
? "gh not authenticated — run: gh auth login"
|
|
60
|
+
: (err.split("\n").slice(-1)[0] || "gh issue list failed");
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
out.authenticated = true;
|
|
64
|
+
const parsed = JSON.parse(String(r.stdout || "[]").trim() || "[]");
|
|
65
|
+
out.issues = Array.isArray(parsed)
|
|
66
|
+
? parsed.map((i) => ({ number: i.number, title: i.title || "" }))
|
|
67
|
+
: [];
|
|
68
|
+
} catch (e) {
|
|
69
|
+
out.reason = String((e && e.message) || e).split("\n").slice(-1)[0] || "sprint probe failed";
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Active flight = newest flight plan under ai/meta/reports/flight-F*.md, ranked
|
|
75
|
+
// by flight number (F17 > F6) then by the trailing date in the filename.
|
|
76
|
+
function findActiveFlight(target) {
|
|
77
|
+
const dir = path.join(target, "ai", "meta", "reports");
|
|
78
|
+
let names;
|
|
79
|
+
try {
|
|
80
|
+
names = fs.readdirSync(dir).filter((f) => /^flight-F\d+.*\.md$/.test(f));
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (!names.length) return null;
|
|
85
|
+
const parse = (name) => {
|
|
86
|
+
const m = name.match(/^flight-F(\d+)/);
|
|
87
|
+
const dateM = name.match(/(\d{4}-\d{2}-\d{2})/);
|
|
88
|
+
return { name, num: m ? Number(m[1]) : -1, date: dateM ? dateM[1] : "" };
|
|
89
|
+
};
|
|
90
|
+
const newest = names
|
|
91
|
+
.map(parse)
|
|
92
|
+
.sort((a, b) => (b.num - a.num) || b.date.localeCompare(a.date))[0];
|
|
93
|
+
const file = path.join(dir, newest.name);
|
|
94
|
+
let id = `F${newest.num}`, title = "", flightStatus = "";
|
|
95
|
+
try {
|
|
96
|
+
const text = fs.readFileSync(file, "utf8");
|
|
97
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
98
|
+
const block = fm ? fm[1] : text;
|
|
99
|
+
const idM = block.match(/^id:\s*(.+)$/m);
|
|
100
|
+
const titleM = block.match(/^title:\s*(.+)$/m);
|
|
101
|
+
const statusM = block.match(/^status:\s*(.+)$/m);
|
|
102
|
+
if (idM) id = idM[1].trim().replace(/^["']|["']$/g, "");
|
|
103
|
+
if (titleM) title = titleM[1].trim().replace(/^["']|["']$/g, "");
|
|
104
|
+
if (statusM) flightStatus = statusM[1].trim().replace(/^["']|["']$/g, "");
|
|
105
|
+
} catch {}
|
|
106
|
+
return { id, title, status: flightStatus, file: path.relative(target, file) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Drift signal: roadmap.json is generated from ROADMAP.md. If the source is
|
|
110
|
+
// newer than the generated artifact, the JSON is stale and should be regenerated.
|
|
111
|
+
function collectRoadmapDrift(target) {
|
|
112
|
+
const out = { detected: false, reason: "" };
|
|
113
|
+
const jsonPath = path.join(target, "ai", "meta", "manifest", "roadmap.json");
|
|
114
|
+
const mdPath = path.join(target, "ROADMAP.md");
|
|
115
|
+
let jsonMtime, mdMtime;
|
|
116
|
+
try { jsonMtime = fs.statSync(jsonPath).mtimeMs; } catch { return out; }
|
|
117
|
+
try { mdMtime = fs.statSync(mdPath).mtimeMs; } catch { return out; }
|
|
118
|
+
// Prefer an explicit updated_at/generated_at in the JSON over file mtime when present.
|
|
119
|
+
const j = loadJson(jsonPath);
|
|
120
|
+
const stamp = j && (j.updated_at || j.generated_at);
|
|
121
|
+
const jsonTime = stamp ? Date.parse(stamp) : NaN;
|
|
122
|
+
const jsonEffective = Number.isFinite(jsonTime) ? jsonTime : jsonMtime;
|
|
123
|
+
if (mdMtime > jsonEffective) {
|
|
124
|
+
out.detected = true;
|
|
125
|
+
out.reason = "ROADMAP.md is newer than roadmap.json — run: python3 scripts/roadmap_to_json.py";
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
29
130
|
function collectStatusPayload(target, options = {}) {
|
|
30
131
|
const ai = path.join(target, "ai");
|
|
31
132
|
const baseIdentity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
|
|
@@ -139,6 +240,9 @@ function collectStatusPayload(target, options = {}) {
|
|
|
139
240
|
drift: {
|
|
140
241
|
detected: driftDetected,
|
|
141
242
|
},
|
|
243
|
+
sprint: collectSprintPayload(target, options),
|
|
244
|
+
flight: findActiveFlight(target),
|
|
245
|
+
roadmap_drift: collectRoadmapDrift(target),
|
|
142
246
|
mcp_exposure: collectMcpExposurePayload(target, options.mcpExposureOptions || {}),
|
|
143
247
|
warnings: warningCount,
|
|
144
248
|
activation_ttfv: getActivationDurationStats(target),
|
|
@@ -159,6 +263,33 @@ function cmdStatus(target, options = {}) {
|
|
|
159
263
|
console.log(` swarm: ${payload.swarm.queued} queued, ${payload.swarm.active} active, ${payload.swarm.done} done`);
|
|
160
264
|
}
|
|
161
265
|
|
|
266
|
+
const sprint = payload.sprint || {};
|
|
267
|
+
if (sprint.authenticated) {
|
|
268
|
+
if (sprint.issues.length) {
|
|
269
|
+
console.log(` sprint:current — ${sprint.issues.length} open:`);
|
|
270
|
+
for (const i of sprint.issues.slice(0, 8)) {
|
|
271
|
+
console.log(` #${i.number} ${i.title}`);
|
|
272
|
+
}
|
|
273
|
+
if (sprint.issues.length > 8) {
|
|
274
|
+
console.log(` …and ${sprint.issues.length - 8} more`);
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
console.log(` sprint:current — none open`);
|
|
278
|
+
}
|
|
279
|
+
} else if (sprint.reason) {
|
|
280
|
+
console.log(` sprint:current — ${D}unavailable (${sprint.reason})${R}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (payload.flight) {
|
|
284
|
+
const ftitle = payload.flight.title ? ` ${payload.flight.title}` : "";
|
|
285
|
+
const fstatus = payload.flight.status ? ` [${payload.flight.status}]` : "";
|
|
286
|
+
console.log(` flight: ${payload.flight.id}${ftitle}${fstatus}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (payload.roadmap_drift && payload.roadmap_drift.detected) {
|
|
290
|
+
console.log(` roadmap: ${D}${payload.roadmap_drift.reason}${R}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
162
293
|
if (payload.swarm.quota_state === "locked") {
|
|
163
294
|
console.log(` swarm quota: ${D}locked (Free) — upgrade for ${payload.swarm.daily_limit} tasks/day${R}`);
|
|
164
295
|
} else {
|
|
@@ -198,4 +329,10 @@ function cmdStatus(target, options = {}) {
|
|
|
198
329
|
return payload;
|
|
199
330
|
}
|
|
200
331
|
|
|
201
|
-
module.exports = {
|
|
332
|
+
module.exports = {
|
|
333
|
+
cmdStatus,
|
|
334
|
+
collectStatusPayload,
|
|
335
|
+
collectSprintPayload,
|
|
336
|
+
findActiveFlight,
|
|
337
|
+
collectRoadmapDrift,
|
|
338
|
+
};
|
package/lib/commands/trust.js
CHANGED
package/lib/python/heatmap.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/package.json
CHANGED