@0dai-dev/cli 4.3.4 → 4.3.6

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.
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 0dai.dev
3
+ //
4
+ // F14 G4 Phase 2 — `0dai export --all` CLI command.
5
+ //
6
+ // Bundles user-owned tenant data into a tarball per the contract
7
+ // at docs/governance/data-export-contract.md (F14 G4 Phase 1 /
8
+ // PR #3569). Phase 2 ships personas + path-protect.yaml as real
9
+ // data, plus usage-ledger.jsonl when present; the other 6 surfaces emit placeholder JSON
10
+ // ({"_status": "not-implemented", "since": "F14 G4 Phase 2"}).
11
+ //
12
+ // Cosign signing is deferred to Phase 3 (stubs .sig + .crt as
13
+ // empty files alongside the tarball so the layout contract holds).
14
+ //
15
+ // Usage:
16
+ // 0dai export --all [--output PATH] [--target DIR]
17
+
18
+ "use strict";
19
+
20
+ const shared = require("../shared");
21
+ const { T, R, D, log } = shared;
22
+ const { buildExportTarball } = require("../utils/export-bundler");
23
+
24
+ function parseExportArgs(args) {
25
+ const out = { all: false, output: null, target: process.cwd() };
26
+ for (let i = 0; i < args.length; i++) {
27
+ const a = args[i];
28
+ if (a === "--all") out.all = true;
29
+ else if (a === "--output" && args[i + 1]) { out.output = args[i + 1]; i++; }
30
+ else if (a === "--target" && args[i + 1]) { out.target = args[i + 1]; i++; }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function defaultOutputName() {
36
+ const now = new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "Z");
37
+ return `0dai-export-${now}.tar.gz`;
38
+ }
39
+
40
+ async function cmdExport(target, args) {
41
+ const opts = parseExportArgs(args);
42
+ if (!opts.all) {
43
+ console.error(`${T}[0dai-export]${R} --all is required (partial export is a Phase 2 follow-up)`);
44
+ process.exit(2);
45
+ }
46
+ const sourceRoot = opts.target || target || process.cwd();
47
+ const outputPath = opts.output || defaultOutputName();
48
+
49
+ log(`tenant data source: ${sourceRoot}`);
50
+ log(`output: ${outputPath}`);
51
+
52
+ let result;
53
+ try {
54
+ result = await buildExportTarball({ sourceRoot, outputPath });
55
+ } catch (err) {
56
+ console.error(`${T}[0dai-export]${R} bundle failed: ${err.message}`);
57
+ process.exit(3);
58
+ }
59
+
60
+ log(`personas: ${result.counts.personas} bundled`);
61
+ log(`path-protect: ${result.counts.pathProtect} file(s)`);
62
+ log(`usage-ledger: ${result.counts.usageLedger} file(s)`);
63
+ log(`placeholders: ${result.counts.placeholders} surfaces (Phase 2 not-implemented)`);
64
+ log(`tarball: ${result.tarballPath}`);
65
+ log(`sha256: ${result.sha256}`);
66
+ if (result.signed) {
67
+ log(`signature: cosign keyless signed (${result.sigPath})`);
68
+ } else {
69
+ log(`${D}signature: ${result.signSkipReason} — stub .sig + .crt written${R}`);
70
+ }
71
+ }
72
+
73
+ module.exports = { cmdExport };
@@ -7,7 +7,7 @@ const {
7
7
  VERSION, SUPPORTED_CLIS,
8
8
  CONFIG_DIR, PROJECTS_FILE,
9
9
  apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
10
- collectMetadata, buildProjectIdentity, registerProject,
10
+ collectMetadata, buildProjectIdentity, registerProject, hashManifestFiles,
11
11
  writeFiles, sendProjectHeartbeat, recordExperienceEvent,
12
12
  logFirstRunSuccess,
13
13
  } = shared;
@@ -15,6 +15,7 @@ const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = requir
15
15
  const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
16
16
  const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
17
17
  const { bootstrapMcp } = require("../utils/mcp-auth");
18
+ const { recordActivationInit } = require("../utils/activation_telemetry");
18
19
 
19
20
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
20
21
  const SYNC_FULL_CONTENT_LIMIT = 10000;
@@ -363,11 +364,15 @@ async function cmdInit(target, args = []) {
363
364
  else if (!dryRun) log(`sending to API (${projectFiles.length} files, ${clis.length} CLIs)...`);
364
365
  const result = await apiCall("/v1/init", {
365
366
  project_files: projectFiles,
366
- manifest_contents: manifestContents,
367
+ // Privacy: send only filenames + SHA-256 hashes, never raw content (closes #4016).
368
+ // Local detection (inferProjectName, detectStackHint) still reads content locally —
369
+ // the results are passed as project_name and stack so the server needs no raw content.
370
+ manifest_files: hashManifestFiles(manifestContents),
367
371
  available_clis: clis,
368
372
  dry_run: dryRun,
369
373
  minimal: minimal,
370
374
  project_name: identity.project_name,
375
+ stack: identity.stack,
371
376
  project_id: boundProject.project_id || identity.project_id,
372
377
  remote_origin: identity.remote_origin,
373
378
  origin: identity.origin,
@@ -463,6 +468,10 @@ async function cmdInit(target, args = []) {
463
468
  // First-run proof gate (issue #342). All 4 gates pass once we reach here:
464
469
  // license active (line above), project bound, ai/ layer written, heartbeat sent.
465
470
  // Idempotent — only fires once per project. See docs/first-run.md.
471
+ recordActivationInit(target, boundProject.project_id || identity.project_id, {
472
+ stack: result.stack || identity.stack || "unknown",
473
+ });
474
+
466
475
  const firstRun = logFirstRunSuccess(target, {
467
476
  license: true,
468
477
  project_bound: true,
@@ -557,6 +566,60 @@ function normalizeEnvironmentManifest(target) {
557
566
  return { path: filePath, changed: true };
558
567
  }
559
568
 
569
+ // runDocLinkCheck — SPEC-028 Phase 2 (#2689). After the managed layer has been
570
+ // written, run scripts/doc_link_check.py to surface broken/closed cross-refs
571
+ // in ai/ and docs/. Warn-only by default so a flaky reference never blocks an
572
+ // otherwise successful sync; --strict-links promotes failures to a non-zero
573
+ // exit, and --skip-link-check bypasses the hook entirely.
574
+ //
575
+ // The hook intentionally stays silent when the script is missing (Phase 1 may
576
+ // not have shipped to a downstream project) and when python3 is unavailable.
577
+ // Output is streamed inline via stdio: "inherit" so authors see exact paths.
578
+ function runDocLinkCheck(target, args = [], options = {}) {
579
+ if (args.includes("--skip-link-check")) {
580
+ return { skipped: true, reason: "flag" };
581
+ }
582
+ const script = path.join(target, "scripts", "doc_link_check.py");
583
+ if (!fs.existsSync(script)) {
584
+ return { skipped: true, reason: "missing-script" };
585
+ }
586
+ const quiet = !!options.quiet;
587
+ const strict = args.includes("--strict-links");
588
+ const { spawnSync } = require("child_process");
589
+ if (!quiet) log("doc-link-check: scanning ai/**/*.md docs/**/*.md");
590
+ const result = spawnSync(
591
+ "python3",
592
+ [
593
+ script,
594
+ "--repo-root",
595
+ target,
596
+ "--paths",
597
+ "ai/**/*.md",
598
+ "docs/**/*.md",
599
+ "--format",
600
+ "md",
601
+ "--fail-on",
602
+ "broken,closed",
603
+ ],
604
+ { stdio: "inherit", cwd: target },
605
+ );
606
+ if (result.error && result.error.code === "ENOENT") {
607
+ if (!quiet) console.log(` ${D}doc-link-check skipped: python3 not found${R}`);
608
+ return { skipped: true, reason: "no-python" };
609
+ }
610
+ const status = typeof result.status === "number" ? result.status : 0;
611
+ if (status !== 0) {
612
+ if (strict) {
613
+ log(`${W}doc-link-check failed (exit ${status}); --strict-links is set${R}`);
614
+ process.exit(status);
615
+ }
616
+ if (!quiet) log(`${W}doc-link-check found issues (exit ${status}); warn-only (pass --strict-links to fail sync)${R}`);
617
+ return { ran: true, status, strict, broken: true };
618
+ }
619
+ if (!quiet) log("doc-link-check: clean");
620
+ return { ran: true, status: 0, strict, broken: false };
621
+ }
622
+
560
623
  async function cmdSync(target, args = []) {
561
624
  const dryRun = args.includes("--dry-run");
562
625
  const quiet = args.includes("--quiet") || args.includes("-q");
@@ -608,7 +671,11 @@ async function cmdSync(target, args = []) {
608
671
 
609
672
  const result = await apiCall("/v1/sync", {
610
673
  ai_version: version, stack, agents: agents.length ? agents : clis,
611
- current_files: currentFiles, manifest_contents: manifestContents,
674
+ current_files: currentFiles,
675
+ // Privacy: send only filenames + SHA-256 hashes, never raw content (closes #4031).
676
+ // Local detection (stack, agents) is derived locally and sent separately so the
677
+ // server needs no raw manifest content — mirrors the /v1/init fix from #4030.
678
+ manifest_files: hashManifestFiles(manifestContents),
612
679
  dry_run: dryRun, quiet, force,
613
680
  project_name: identity.project_name,
614
681
  project_id: boundProject.project_id || identity.project_id,
@@ -669,6 +736,12 @@ async function cmdSync(target, args = []) {
669
736
  } else {
670
737
  log("already up to date");
671
738
  }
739
+
740
+ // SPEC-028 Phase 2 (#2689) — scan managed docs for broken cross-refs after
741
+ // the layer is on disk. Warn-only unless --strict-links; --skip-link-check
742
+ // bypasses. Runs even when no files changed so stale local refs surface on
743
+ // every sync.
744
+ runDocLinkCheck(target, args, { quiet });
672
745
  const envManifest = normalizeEnvironmentManifest(target);
673
746
  if (!quiet && envManifest.changed) {
674
747
  log("environment manifest target normalized: ai/manifest/environment.yaml");
@@ -823,5 +896,6 @@ module.exports = {
823
896
  hashFile,
824
897
  detectRegistryDrift,
825
898
  guardRegistryDrift,
899
+ runDocLinkCheck,
826
900
  SYNC_FULL_CONTENT_LIMIT,
827
901
  };
@@ -72,7 +72,7 @@ function _toolsFromTierManifest(target, plan) {
72
72
  const section = exposure[plan] || exposure.free;
73
73
  if (!section) continue;
74
74
  const tools = Array.isArray(section.tools) ? section.tools.slice() : [];
75
- return { source: candidate, plan, tools, count: tools.length };
75
+ return { source: candidate, plan, tools, count: tools.length, exposure };
76
76
  }
77
77
  return null;
78
78
  }
@@ -203,12 +203,33 @@ function _cmdCatalog(target, args) {
203
203
  console.log(`\n ${T}available stacks${R}: ${available.join(", ")}\n`);
204
204
  }
205
205
 
206
+ // Flag MCP server entries left behind by a pre-4.3 `0dai sync` that no longer
207
+ // connect. Two dead patterns: the deprecated `/sse` operator endpoint (HTTP 404,
208
+ // 0dai-dev/docs#56) and the `transport: "sse"` form. `mergeMcpConfig` only manages
209
+ // its own servers and never prunes unknown user entries -- even with --reset -- so
210
+ // these persist after upgrade until removed by hand. The current generator connects
211
+ // operator-MCP via the headerless "claude.ai 0dai" (OAuth /mcp) entry, so the stale
212
+ // block is dead weight. Detection stays on the unambiguous /sse signal to avoid
213
+ // false positives on a legitimately-wired bearer host-pool config (#57).
214
+ function _detectStaleMcpServers(mcpServers) {
215
+ const stale = [];
216
+ for (const [name, cfg] of Object.entries(mcpServers || {})) {
217
+ if (!cfg || typeof cfg !== "object") continue;
218
+ const url = typeof cfg.url === "string" ? cfg.url : "";
219
+ const transport = typeof cfg.transport === "string" ? cfg.transport : "";
220
+ if (/\/sse\/?(\?|$)/.test(url) || transport === "sse") {
221
+ stale.push({ name, reason: "deprecated SSE endpoint/transport (HTTP 404)" });
222
+ }
223
+ }
224
+ return stale;
225
+ }
226
+
206
227
  function _cmdDoctor(target, args) {
207
228
  const wantJson = args.includes("--json");
208
229
  const report = {
209
230
  target,
210
231
  server_script: { path: "", ok: false },
211
- mcp_config: { path: "", ok: false, servers: [] },
232
+ mcp_config: { path: "", ok: false, servers: [], stale: [] },
212
233
  tier_manifest: { path: "", ok: false, free_count: 0, pro_count: 0 },
213
234
  catalog: { path: "", ok: false, stacks: 0 },
214
235
  };
@@ -226,6 +247,7 @@ function _cmdDoctor(target, args) {
226
247
  if (data && data.mcpServers && typeof data.mcpServers === "object") {
227
248
  report.mcp_config.ok = true;
228
249
  report.mcp_config.servers = Object.keys(data.mcpServers);
250
+ report.mcp_config.stale = _detectStaleMcpServers(data.mcpServers);
229
251
  }
230
252
  }
231
253
 
@@ -268,6 +290,12 @@ function _cmdDoctor(target, args) {
268
290
  if (report.mcp_config.ok) {
269
291
  console.log(` servers: ${report.mcp_config.servers.join(", ")}`);
270
292
  }
293
+ if (report.mcp_config.stale.length) {
294
+ console.log(` ${W}stale${R} (remove from .mcp.json — superseded by the "claude.ai 0dai" OAuth /mcp entry):`);
295
+ for (const s of report.mcp_config.stale) {
296
+ console.log(` ${W}!${R} ${s.name} ${D}— ${s.reason}${R}`);
297
+ }
298
+ }
271
299
  console.log(` tier manifest ${ok(report.tier_manifest.ok)} ${D}${report.tier_manifest.path || "(not found)"}${R}`);
272
300
  if (report.tier_manifest.ok) {
273
301
  console.log(` free=${report.tier_manifest.free_count} pro=${report.tier_manifest.pro_count}`);
@@ -278,7 +306,9 @@ function _cmdDoctor(target, args) {
278
306
  }
279
307
  console.log("");
280
308
 
281
- const allOk = report.server_script.ok && report.mcp_config.ok && (report.tier_manifest.ok || report.catalog.ok);
309
+ const allOk = report.server_script.ok && report.mcp_config.ok
310
+ && report.mcp_config.stale.length === 0
311
+ && (report.tier_manifest.ok || report.catalog.ok);
282
312
  if (!allOk) process.exitCode = 1;
283
313
  }
284
314
 
@@ -1,26 +1,43 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
3
  const { log, T, R, D, fs, path, apiCall } = shared;
4
+ const {
5
+ estimateRunCost,
6
+ formatTokens,
7
+ formatUsd,
8
+ formatUsdPer1k,
9
+ } = require("../utils/run_cost");
10
+ const { recordActivationFirstTask } = require("../utils/activation_telemetry");
4
11
 
5
12
  async function cmdRun(goal, target, args = []) {
6
13
  if (!goal) {
7
- console.log(`Usage: 0dai run <goal> [--dry-run] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
14
+ console.log(`Usage: 0dai run <goal> [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
8
15
  console.log(` Example: 0dai run "add dark mode to settings page"`);
16
+ console.log(` Example: 0dai run "fix login bug" --dry-cost`);
17
+ console.log(` Example: 0dai run "refactor auth" --max-cost 0.50`);
9
18
  return;
10
19
  }
11
20
 
12
21
  const dryRun = args.includes("--dry-run");
22
+ const dryCost = args.includes("--dry-cost");
13
23
  const agentIdx = args.indexOf("--agent");
14
24
  const agentOverride = agentIdx >= 0 ? args[agentIdx + 1] : null;
15
25
  const providerIdx = args.indexOf("--provider");
16
26
  const providerOverride = providerIdx >= 0 ? normalizeProvider(args[providerIdx + 1] || "") : null;
17
27
  const providerModelIdx = args.indexOf("--provider-model");
18
28
  const providerModel = providerModelIdx >= 0 ? (args[providerModelIdx + 1] || "") : "";
29
+ const maxCostIdx = args.indexOf("--max-cost");
30
+ const maxCostRaw = maxCostIdx >= 0 ? args[maxCostIdx + 1] : null;
31
+ const maxCost = maxCostRaw != null ? parseFloat(maxCostRaw) : null;
19
32
 
20
33
  if (providerIdx >= 0 && !providerOverride) {
21
34
  log("error: unsupported provider. Supported: deepseek, gemini-direct, codex, claude-opus, claude-sonnet");
22
35
  return;
23
36
  }
37
+ if (maxCostIdx >= 0 && (maxCostRaw == null || Number.isNaN(maxCost) || maxCost < 0)) {
38
+ log("error: --max-cost requires a non-negative number");
39
+ process.exit(1);
40
+ }
24
41
 
25
42
  // Read project context
26
43
  let stack = "generic", agents = ["claude"], commands = {};
@@ -30,9 +47,9 @@ async function cmdRun(goal, target, args = []) {
30
47
  agents = disc.selected_agents || ["claude"];
31
48
  } catch {}
32
49
 
33
- if (!dryRun) process.stdout.write(`${T}[0dai]${R} decomposing goal...`);
50
+ if (!dryRun && !dryCost) process.stdout.write(`${T}[0dai]${R} decomposing goal...`);
34
51
  const result = await apiCall("/v1/run", { goal, context: { stack, agents, commands } });
35
- if (!dryRun) process.stdout.write("\r" + " ".repeat(40) + "\r");
52
+ if (!dryRun && !dryCost) process.stdout.write("\r" + " ".repeat(40) + "\r");
36
53
 
37
54
  if (result.error) { log(`error: ${result.error}`); return; }
38
55
 
@@ -50,6 +67,23 @@ async function cmdRun(goal, target, args = []) {
50
67
  console.log(` ${D}→ ${agent}${providerText} [${t.model_tier}]${t.description ? " " + t.description : ""}${R}`);
51
68
  }
52
69
 
70
+ const costEstimate = estimateRunCost(tasks, {
71
+ providerOverride,
72
+ resolveAgent: (task) => agentOverride || providerAgent(providerOverride) || task.assigned_to,
73
+ });
74
+
75
+ if (dryCost) {
76
+ printCostPreview(costEstimate);
77
+ console.log(`\n ${D}[dry-cost] estimate only — no tasks queued${R}\n`);
78
+ return;
79
+ }
80
+
81
+ if (maxCost != null && costEstimate.totalUsd > maxCost) {
82
+ log(`error: estimated cost ${formatUsd(costEstimate.totalUsd)} exceeds --max-cost ${formatUsd(maxCost)}`);
83
+ console.log(` ${D}Re-run with --dry-cost to inspect the breakdown, or raise --max-cost${R}`);
84
+ process.exit(1);
85
+ }
86
+
53
87
  if (dryRun) {
54
88
  console.log(`\n ${D}[dry-run] would create ${tasks.length} task(s) in swarm queue${R}\n`);
55
89
  return;
@@ -88,6 +122,32 @@ async function cmdRun(goal, target, args = []) {
88
122
 
89
123
  console.log(`\n ${T}✓${R} ${created.length} task${created.length === 1 ? "" : "s"} added to swarm queue`);
90
124
  console.log(` ${D}Monitor: 0dai watch | Queue: 0dai swarm status${R}\n`);
125
+
126
+ if (created.length > 0) {
127
+ const identity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
128
+ recordActivationFirstTask(target, identity.project_id, {
129
+ outcome: "task_queued",
130
+ task_count: created.length,
131
+ });
132
+ }
133
+ }
134
+
135
+ function printCostPreview(costEstimate) {
136
+ console.log(`\n ${T}Cost preview${R} (estimate):\n`);
137
+ costEstimate.tasks.forEach((row, index) => {
138
+ console.log(
139
+ ` ${T}${index + 1}.${R} ${row.title || "task"}`
140
+ + `\n ${D}→ ${row.agent} [${row.tier}]`
141
+ + ` ~${formatTokens(row.tokens)} tokens`
142
+ + ` ~${formatUsd(row.estimatedUsd)}`
143
+ + ` (${formatUsdPer1k(row.usdPer1k)})${R}`,
144
+ );
145
+ });
146
+ console.log(
147
+ `\n ${T}Total:${R} ~${formatTokens(costEstimate.totalTokens)} tokens`
148
+ + ` ~${formatUsd(costEstimate.totalUsd)}`
149
+ + ` (blended ${formatUsdPer1k(costEstimate.blendedUsdPer1k)})`,
150
+ );
91
151
  }
92
152
 
93
153
  function normalizeProvider(provider) {
@@ -114,4 +174,4 @@ function providerAgent(provider) {
114
174
  return null;
115
175
  }
116
176
 
117
- module.exports = { cmdRun };
177
+ module.exports = { cmdRun, printCostPreview };
@@ -4,6 +4,11 @@ const {
4
4
  log, T, R, D, fs, path, spawnSync, findRepoScript,
5
5
  getSwarmQuotaLocal, _detectPlanLocal, PLAN_LEVELS, loadAuthState,
6
6
  } = shared;
7
+ const {
8
+ getActivationDurationStats,
9
+ getActivationMergedPrStats,
10
+ printActivationStats,
11
+ } = require("../utils/activation_telemetry");
7
12
 
8
13
  function countJson(dir) {
9
14
  try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; }
@@ -116,6 +121,8 @@ function collectStatusPayload(target) {
116
121
  detected: driftDetected,
117
122
  },
118
123
  warnings: warningCount,
124
+ activation_ttfv: getActivationDurationStats(target),
125
+ activation_first_merged_pr: getActivationMergedPrStats(target),
119
126
  };
120
127
  }
121
128
 
@@ -158,6 +165,8 @@ function cmdStatus(target, options = {}) {
158
165
  console.log(` drift: config changes detected — run: 0dai doctor --drift`);
159
166
  }
160
167
 
168
+ printActivationStats(target);
169
+
161
170
  return payload;
162
171
  }
163
172