@0dai-dev/cli 4.0.0 → 4.2.0

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.
@@ -4,9 +4,10 @@ const {
4
4
  T, R, D, log,
5
5
  fs, path,
6
6
  VERSION, SUPPORTED_CLIS,
7
- apiCall, makeEnsureAuthenticated, ensureLicenseActivation,
7
+ apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
8
8
  collectMetadata, buildProjectIdentity, registerProject,
9
9
  writeFiles, sendProjectHeartbeat, recordExperienceEvent,
10
+ logFirstRunSuccess,
10
11
  } = shared;
11
12
  const { cmdAuthLogin } = require("./auth");
12
13
 
@@ -42,6 +43,20 @@ async function cmdInit(target, args = []) {
42
43
  return;
43
44
  }
44
45
 
46
+ // Pre-check: verify init quota before starting wizard (avoid 10 min wizard → "limit reached")
47
+ if (!dryRun) {
48
+ try {
49
+ const precheck = await apiCall("/v1/projects/precheck", {
50
+ device_id: shared.deviceFingerprint(),
51
+ });
52
+ if (precheck.error && precheck.error.includes("limit")) {
53
+ log(`${precheck.error}`);
54
+ if (precheck.hint) console.log(` ${D}${precheck.hint}${R}`);
55
+ return;
56
+ }
57
+ } catch {}
58
+ }
59
+
45
60
  // First-run wizard (unless --no-wizard or non-interactive)
46
61
  if (!noWizard && !dryRun && !minimal) {
47
62
  try {
@@ -150,9 +165,9 @@ async function cmdInit(target, args = []) {
150
165
  }
151
166
  console.log(` ${D}3.${R} Open dashboard: ${D}https://0dai.dev/dashboard${R}`);
152
167
 
153
- await sendProjectHeartbeat(identity, result, {
168
+ const heartbeat = await sendProjectHeartbeat(target, identity, result, {
154
169
  project_id: boundProject.project_id || identity.project_id,
155
- }).catch(() => {});
170
+ }).catch(() => null);
156
171
  recordExperienceEvent(target, {
157
172
  event_type: "config_generated",
158
173
  agent: "cli",
@@ -162,6 +177,20 @@ async function cmdInit(target, args = []) {
162
177
  context: { stack: result.stack || identity.stack || "unknown", files_touched: Number(result.file_count || 0), tests_passed: true },
163
178
  });
164
179
 
180
+ // First-run proof gate (issue #342). All 4 gates pass once we reach here:
181
+ // license active (line above), project bound, ai/ layer written, heartbeat sent.
182
+ // Idempotent — only fires once per project. See docs/first-run.md.
183
+ const firstRun = logFirstRunSuccess(target, {
184
+ license: true,
185
+ project_bound: true,
186
+ layer_written: true,
187
+ heartbeat: !!heartbeat && !heartbeat.error,
188
+ });
189
+ if (firstRun.fired) {
190
+ const suffix = typeof firstRun.elapsedS === "number" ? ` (${firstRun.elapsedS}s)` : "";
191
+ console.log(` ${D}first-run gate: success${suffix}${R}`);
192
+ }
193
+
165
194
  // Send anonymous usage ping
166
195
  apiCall("/v1/feedback", { report: {
167
196
  stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
@@ -172,15 +201,14 @@ async function cmdInit(target, args = []) {
172
201
  async function cmdSync(target, args = []) {
173
202
  const dryRun = args.includes("--dry-run");
174
203
  const quiet = args.includes("--quiet") || args.includes("-q");
204
+ const force = args.includes("--force");
175
205
 
176
- // Quick local check: skip API if already at current version (unless dry-run)
206
+ // Quick local check: skip API if already at current version (unless dry-run or force)
177
207
  let version = "unknown";
178
208
  try { version = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim(); } catch {}
179
209
 
180
210
  const metadata = collectMetadata(target);
181
211
  const { manifestContents, clis } = metadata;
182
- const authStatus = await ensureAuthenticated("sync");
183
- const license = await ensureLicenseActivation();
184
212
  let stack = "generic", agents = [];
185
213
  try {
186
214
  const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
@@ -188,6 +216,28 @@ async function cmdSync(target, args = []) {
188
216
  agents = d.selected_agents || [];
189
217
  } catch {}
190
218
  const identity = buildProjectIdentity(target, metadata, stack);
219
+
220
+ if (dryRun) {
221
+ const auth = loadAuthState();
222
+ const hasAuth = !!(auth && (auth.api_key || auth.access_token || auth.token));
223
+ if (!hasAuth) {
224
+ const preview = buildLocalSyncPreview(target, { version, stack, cliVersion: VERSION });
225
+ log(`${D}dry-run: local preview without auth (exact cloud plan unavailable)${R}`);
226
+ console.log(` stack: ${preview.stack}`);
227
+ console.log(` ai version: ${preview.current_version} ${preview.version_matches ? `${D}(matches CLI ${preview.cli_version})${R}` : `${D}(CLI ${preview.cli_version})${R}`}`);
228
+ if (preview.changes.length) {
229
+ console.log(" likely changes:");
230
+ for (const change of preview.changes) console.log(` ~ ${change}`);
231
+ } else {
232
+ console.log(` ${D}no obvious local drift found${R}`);
233
+ }
234
+ console.log(` ${D}Run: 0dai auth login for exact managed diff and write-mode sync${R}`);
235
+ return;
236
+ }
237
+ }
238
+
239
+ const authStatus = await ensureAuthenticated("sync");
240
+ const license = await ensureLicenseActivation();
191
241
  const boundProject = await bindProjectForCloud(target, metadata, identity);
192
242
 
193
243
  // Collect current ai/ files
@@ -210,11 +260,12 @@ async function cmdSync(target, args = []) {
210
260
  }
211
261
 
212
262
  if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
263
+ if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
213
264
 
214
265
  const result = await apiCall("/v1/sync", {
215
266
  ai_version: version, stack, agents: agents.length ? agents : clis,
216
267
  current_files: currentFiles, manifest_contents: manifestContents,
217
- dry_run: dryRun, quiet,
268
+ dry_run: dryRun, quiet, force,
218
269
  project_name: identity.project_name,
219
270
  project_id: boundProject.project_id || identity.project_id,
220
271
  remote_origin: identity.remote_origin,
@@ -250,6 +301,34 @@ async function cmdSync(target, args = []) {
250
301
  } else {
251
302
  log("already up to date");
252
303
  }
304
+
305
+ // --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
306
+ if (force && result.native_configs) {
307
+ const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
308
+ let overwritten = 0;
309
+ for (const name of NATIVE_CONFIGS) {
310
+ if (result.native_configs[name]) {
311
+ fs.writeFileSync(path.join(target, name), result.native_configs[name], "utf8");
312
+ overwritten++;
313
+ if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
314
+ }
315
+ }
316
+ if (overwritten && !quiet) {
317
+ log(`force: ${overwritten} native config file(s) overwritten`);
318
+ }
319
+ }
320
+
321
+ // --force: update drift baseline hashes so drift clears after regeneration
322
+ if (force) {
323
+ try {
324
+ const { spawnSync } = require("child_process");
325
+ const driftScript = path.join(target, "scripts", "drift_detector.py");
326
+ if (fs.existsSync(driftScript)) {
327
+ spawnSync("python3", [driftScript, "record", "--target", target], { stdio: "inherit" });
328
+ }
329
+ } catch {}
330
+ }
331
+
253
332
  if (!quiet) {
254
333
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
255
334
  console.log(` project: ${boundProject.project_id || identity.project_id}`);
@@ -266,7 +345,7 @@ async function cmdSync(target, args = []) {
266
345
 
267
346
  // Update portfolio registry
268
347
  registerProject(target, path.basename(target), stack);
269
- await sendProjectHeartbeat(identity, result, {
348
+ await sendProjectHeartbeat(target, identity, result, {
270
349
  project_id: boundProject.project_id || identity.project_id,
271
350
  }).catch(() => {});
272
351
  recordExperienceEvent(target, {
@@ -279,4 +358,58 @@ async function cmdSync(target, args = []) {
279
358
  });
280
359
  }
281
360
 
282
- module.exports = { cmdInit, cmdSync };
361
+ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
362
+ const changes = [];
363
+ const expectedAiFiles = [
364
+ "ai/VERSION",
365
+ "ai/manifest/project.yaml",
366
+ "ai/manifest/commands.yaml",
367
+ "ai/manifest/discovery.json",
368
+ ];
369
+ for (const rel of expectedAiFiles) {
370
+ if (!fs.existsSync(path.join(target, rel))) changes.push(`${rel} (missing)`);
371
+ }
372
+
373
+ if (version !== "unknown" && version !== cliVersion) {
374
+ changes.push(`ai/VERSION (${version} -> ${cliVersion})`);
375
+ }
376
+
377
+ const driftTracked = [
378
+ "CLAUDE.md",
379
+ "AGENTS.md",
380
+ "GEMINI.md",
381
+ "opencode.json",
382
+ ".cursorrules",
383
+ ".windsurfrules",
384
+ ".aider.conf.yml",
385
+ ];
386
+ const hashesPath = path.join(target, "ai", "manifest", "config_hashes.json");
387
+ try {
388
+ const hashes = JSON.parse(fs.readFileSync(hashesPath, "utf8"));
389
+ const crypto = require("crypto");
390
+ for (const rel of driftTracked) {
391
+ const filePath = path.join(target, rel);
392
+ const recorded = hashes[rel];
393
+ const exists = fs.existsSync(filePath) && fs.statSync(filePath).isFile();
394
+ if (recorded && !exists) changes.push(`${rel} (missing from workspace)`);
395
+ if (recorded && exists) {
396
+ const currentHash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
397
+ if (currentHash !== String(recorded.hash || "")) changes.push(`${rel} (local edits)`);
398
+ }
399
+ }
400
+ } catch {}
401
+
402
+ if (!fs.existsSync(path.join(target, "ai"))) {
403
+ changes.push("ai/ layer missing — run 0dai init after auth");
404
+ }
405
+
406
+ return {
407
+ stack,
408
+ current_version: version,
409
+ cli_version: cliVersion,
410
+ version_matches: version === cliVersion,
411
+ changes,
412
+ };
413
+ }
414
+
415
+ module.exports = { cmdInit, cmdSync, buildLocalSyncPreview };
@@ -1,6 +1,11 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
- const { T, R } = shared;
3
+ const { T, R, SUPPORTED_CLIS } = shared;
4
+ const {
5
+ probeInstalledCliNames,
6
+ summarizeModelAvailability,
7
+ formatAvailableFooter,
8
+ } = require("../utils/model_ratings");
4
9
 
5
10
  function cmdModels(filter) {
6
11
  // Scores from benchmark_models.py (3-task: read/count/review, 2026-04-06)
@@ -23,35 +28,69 @@ function cmdModels(filter) {
23
28
  { name: "MiniMax M2.5", tier: "slow", score: 57, cli: "opencode", flag: "-m opencode-go/minimax-m2.5", tested: true },
24
29
  ];
25
30
 
26
- const { execFileSync } = require("child_process");
27
- const available = new Set();
28
- for (const cli of ["claude", "codex", "opencode", "gemini", "aider"]) {
29
- try { execFileSync("/bin/sh", ["-c", `command -v ${cli}`], { stdio: "ignore" }); available.add(cli); } catch {}
30
- }
31
+ const installedCliNames = probeInstalledCliNames(SUPPORTED_CLIS);
32
+ const availability = summarizeModelAvailability(MODELS, SUPPORTED_CLIS, installedCliNames);
31
33
 
32
34
  const isTTY = process.stdout.isTTY;
33
35
  const Y = isTTY ? "\x1b[33m" : "";
34
36
  const G = isTTY ? "\x1b[32m" : "";
35
37
  const DIM = isTTY ? "\x1b[2m" : "";
36
38
 
37
- let models = [...MODELS].sort((a, b) => b.score - a.score);
39
+ const allModels = [...MODELS].sort((a, b) => b.score - a.score);
40
+ let models = [...allModels];
38
41
  if (filter === "--fast") models = models.filter(m => m.tier === "fast");
39
42
  if (filter === "--balanced") models = models.filter(m => m.tier === "balanced");
40
43
  if (filter === "--deep") models = models.filter(m => m.tier === "deep");
41
- if (filter === "--available") models = models.filter(m => available.has(m.cli));
44
+ if (filter === "--available") models = models.filter(m => installedCliNames.has(m.cli));
42
45
 
43
46
  const tc = (t) => t === "deep" ? T : t === "balanced" ? G : DIM;
44
47
  console.log(`\n ${T}0dai${R} model ratings — ${models.length} models\n`);
45
48
  console.log(` ${"SCORE".padEnd(6)} ${"MODEL".padEnd(22)} ${"TIER".padEnd(10)} ${"CLI".padEnd(10)} FLAG`);
46
49
  console.log(` ${"-".repeat(64)}`);
47
50
  for (const m of models) {
48
- const dim = available.has(m.cli) ? "" : DIM;
51
+ const dim = installedCliNames.has(m.cli) ? "" : DIM;
49
52
  const mark = m.tested ? ` ${G}✓${R}` : "";
50
53
  console.log(`${dim} ${Y}${String(m.score).padEnd(6)}${R} ${m.name.padEnd(22)} ${tc(m.tier)}${m.tier.padEnd(10)}${R} ${m.cli.padEnd(10)} ${DIM}${m.flag}${R}${mark}${dim ? R : ""}`);
51
54
  }
52
55
  console.log(`\n ${DIM}✓ = swarm-benchmarked | dimmed = CLI not installed${R}`);
53
56
  console.log(` ${DIM}Filter: --fast --balanced --deep --available${R}`);
57
+ if (filter === "--available") {
58
+ console.log(` ${DIM}${formatAvailableFooter(availability, models.length)}${R}`);
59
+ }
54
60
  console.log(` ${DIM}Full table: https://0dai.dev/models${R}\n`);
55
61
  }
56
62
 
57
- module.exports = { cmdModels };
63
+ async function cmdModelsRecommend(target, args) {
64
+ const shared = require("../shared");
65
+ const { log, T, R, D, findRepoScript, spawnSync, requirePlan } = shared;
66
+
67
+ const gate = requirePlan("pro", "Model Recommend", target);
68
+ if (gate) { log(gate.error); log(gate.hint); return; }
69
+
70
+ const taskType = args.find((_, i) => args[i - 1] === "--task") || "";
71
+ const goal = args.find((_, i) => args[i - 1] === "--goal") || "";
72
+ const maxCost = parseFloat(args.find((_, i) => args[i - 1] === "--max-cost") || "0");
73
+ const minQuality = parseFloat(args.find((_, i) => args[i - 1] === "--min-quality") || "0");
74
+ const asJson = args.includes("--json");
75
+
76
+ if (!taskType && !goal) {
77
+ console.log("Usage: 0dai models recommend --task TYPE [--goal '...'] [--max-cost N] [--min-quality N] [--json]");
78
+ console.log(" TYPE: feat, fix, refactor, test, docs");
79
+ return;
80
+ }
81
+
82
+ const recScript = findRepoScript(target, "model_router.py");
83
+ if (!recScript) { log("model router unavailable"); return; }
84
+
85
+ const fwd = [recScript, "recommend", "--target", target];
86
+ if (taskType) fwd.push("--task", taskType);
87
+ if (goal) fwd.push("--goal", goal);
88
+ if (maxCost > 0) fwd.push("--max-cost", String(maxCost));
89
+ if (minQuality > 0) fwd.push("--min-quality", String(minQuality));
90
+ if (asJson) fwd.push("--json");
91
+
92
+ const result = spawnSync("python3", fwd, { stdio: "inherit" });
93
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
94
+ }
95
+
96
+ module.exports = { cmdModels, cmdModelsRecommend };
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ const shared = require("../shared");
3
+ const { log, D, R, spawnSync, findRepoScript } = shared;
4
+
5
+ function cmdPersonaSimulate(target, args) {
6
+ const script = findRepoScript(target, "persona_simulate.py");
7
+ if (!script) {
8
+ log("persona-simulate unavailable in this environment");
9
+ console.log(` ${D}Expected scripts/persona_simulate.py in repo checkout${R}`);
10
+ process.exit(1);
11
+ }
12
+
13
+ const forwarded = [script, ...args, "--target", target];
14
+ const result = spawnSync("python3", forwarded, { stdio: "inherit" });
15
+ if (typeof result.status === "number") process.exit(result.status);
16
+ process.exit(1);
17
+ }
18
+
19
+ module.exports = { cmdPersonaSimulate };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+
3
+ const shared = require("../shared");
4
+ const { log, spawnSync, findRepoScript } = shared;
5
+
6
+ function cmdProvider(target, args) {
7
+ const script = findRepoScript(target, "provider_profiles.py");
8
+ if (!script) {
9
+ log("provider profiles unavailable in this environment");
10
+ process.exit(1);
11
+ }
12
+ const forwarded = [script, ...args];
13
+ const result = spawnSync("python3", forwarded, { stdio: "inherit" });
14
+ if (typeof result.status === "number") process.exit(result.status);
15
+ process.exit(1);
16
+ }
17
+
18
+ module.exports = { cmdProvider };