@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 +12 -5
- 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 +146 -1
- package/lib/commands/trust.js +1 -1
- package/lib/python/heatmap.py +10 -1
- package/lib/shared.js +1 -0
- package/lib/utils/identity.js +19 -2
- package/lib/wizard.js +45 -3
- package/package.json +1 -1
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("
|
|
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
|
|
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": {
|
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,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 = {
|
|
340
|
+
module.exports = {
|
|
341
|
+
cmdStatus,
|
|
342
|
+
collectStatusPayload,
|
|
343
|
+
collectSprintPayload,
|
|
344
|
+
findActiveFlight,
|
|
345
|
+
collectRoadmapDrift,
|
|
346
|
+
};
|
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/lib/utils/identity.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
189
|
-
const
|
|
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