@0dai-dev/cli 4.1.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.
package/bin/0dai.js CHANGED
@@ -33,6 +33,10 @@ const { cmdGraph } = require("../lib/commands/graph");
33
33
  const { cmdReport } = require("../lib/commands/report");
34
34
  const { cmdExperience } = require("../lib/commands/experience");
35
35
  const { cmdWorkspace } = require("../lib/commands/workspace");
36
+ const { cmdSsh } = require("../lib/commands/ssh");
37
+ const { cmdTui } = require("../lib/commands/tui");
38
+ const { cmdPersonaSimulate } = require("../lib/commands/persona-simulate");
39
+ const { cmdProvider } = require("../lib/commands/provider");
36
40
 
37
41
  async function main() {
38
42
  const args = process.argv.slice(2);
@@ -103,13 +107,21 @@ async function main() {
103
107
  case "init": await cmdInit(target, args); break;
104
108
  case "sync": await cmdSync(target, args); break;
105
109
  case "detect": await cmdDetect(target); break;
106
- case "doctor":
107
- cmdDoctor(target);
110
+ case "doctor": {
111
+ const driftMode = args.includes("--drift");
112
+ cmdDoctor(target, { drift: driftMode });
108
113
  if (args.includes("--drift")) {
109
114
  const ds = findRepoScript(target, "drift_detector.py");
110
- if (ds) spawnSync("python3", [ds, "report", "--target", target], { stdio: "inherit" });
115
+ console.log("\n drift report:");
116
+ if (ds) {
117
+ const result = spawnSync("python3", [ds, "report", "--target", target], { stdio: "inherit" });
118
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
119
+ } else {
120
+ console.log(` ${D}drift detector unavailable in this environment${R}`);
121
+ }
111
122
  }
112
123
  break;
124
+ }
113
125
  case "drift": {
114
126
  const ds = findRepoScript(target, "drift_detector.py");
115
127
  if (!ds) { log("drift detector unavailable"); break; }
@@ -127,7 +139,7 @@ async function main() {
127
139
  case "update": cmdUpdate(args); break;
128
140
  case "metrics": cmdMetrics(target); break;
129
141
  case "portfolio": cmdPortfolio(); break;
130
- case "status": cmdStatus(target); break;
142
+ case "status": cmdStatus(target, { json: args.includes("--json") }); break;
131
143
  case "auth":
132
144
  if (sub === "login") await cmdAuthLogin();
133
145
  else if (sub === "logout") cmdAuthLogout();
@@ -142,9 +154,13 @@ async function main() {
142
154
  case "session": cmdSession(target, sub, args); break;
143
155
  case "swarm": cmdSwarm(target, sub, args); break;
144
156
  case "workspace": cmdWorkspace(target, sub, args.slice(2)); break;
157
+ case "ssh": await cmdSsh(target, sub, args); break;
158
+ case "provider": cmdProvider(target, args.slice(1)); break;
159
+ case "tui": case "dashboard": await cmdTui(target, args.slice(1)); break;
145
160
  case "feedback": await cmdFeedback(target, sub, args); break;
146
161
  case "report": cmdReport(target, sub, args); break;
147
162
  case "experience": cmdExperience(target, sub, args); break;
163
+ case "persona-simulate": cmdPersonaSimulate(target, args.slice(1)); break;
148
164
  case "graph": await cmdGraph(target, sub, args); break;
149
165
  case "models": cmdModels(sub || args[1]); break;
150
166
  case "delegate": case "delegation": {
@@ -245,17 +261,19 @@ async function main() {
245
261
  console.log(" init Initialize ai/ layer (via API) [--dry-run] [--minimal]");
246
262
  console.log(" sync Update ai/ layer (via API) [--dry-run] [--quiet] [--force]");
247
263
  console.log(" detect Show detected stack");
248
- console.log(" doctor Check health + credentials checklist");
264
+ console.log(" doctor Check health + credentials checklist [--drift]");
249
265
  console.log(" update Update all installed agent CLIs to latest [--dry-run]");
250
266
  console.log(" validate Validate ai/ layer completeness");
251
267
  console.log(" reflect Session reflection: delivered, delegation rate, blockers");
252
268
  console.log(" metrics Effectiveness score: adoption funnel, sessions, delegation");
253
269
  console.log(" portfolio All tracked projects: score, sessions, agents, last activity");
254
- console.log(" status Show maturity, swarm, session");
270
+ console.log(" status Show maturity, swarm, session [--json]");
255
271
  console.log(" session save Save session for roaming");
256
272
  console.log(" swarm status Task queue & delegation");
257
273
  console.log(" swarm webhook add Register webhook (fires on task done/failed)");
258
274
  console.log(" swarm webhook list Show registered webhooks");
275
+ console.log(" ssh Manage SSH keys, hosts, grants, and host-side sync");
276
+ console.log(" provider Local provider profiles, bindings, and direct invoke");
259
277
  console.log(" swarm webhook test Send test ping to a webhook URL");
260
278
  console.log(" workspace init Create tmux workspace config (auto-detect services)");
261
279
  console.log(" workspace up Start all workspace sessions");
@@ -264,6 +282,7 @@ async function main() {
264
282
  console.log(" report preview Preview privacy-safe project report");
265
283
  console.log(" report push Send report to 0dai (with offline queue)");
266
284
  console.log(" report status Show last report, queue, and auto-report status");
285
+ console.log(" persona-simulate Produce a focus-group report and optional issue drafts");
267
286
  console.log(" experience list Show recent structured experience events");
268
287
  console.log(" experience stats Show success and cost stats by agent/model/type");
269
288
  console.log(" graph push Upload local graph to server (Pro: edges, Free: nodes)");
@@ -8,6 +8,40 @@ const {
8
8
  makeEnsureAuthenticated, ensureLicenseActivation,
9
9
  } = shared;
10
10
 
11
+ async function resolveSessionState() {
12
+ const auth = loadAuthState();
13
+ const hasCachedToken = !!(auth && (auth.api_key || auth.access_token || auth.token));
14
+ if (!hasCachedToken) {
15
+ return {
16
+ ok: false,
17
+ degraded: false,
18
+ auth: null,
19
+ status: null,
20
+ message: "Not logged in. Run: 0dai auth login",
21
+ };
22
+ }
23
+
24
+ const status = await fetchAuthStatus();
25
+ if (!status || status.error || !status.email) {
26
+ const email = auth.email || auth.user || "unknown";
27
+ return {
28
+ ok: false,
29
+ degraded: true,
30
+ auth,
31
+ status,
32
+ message: `Saved session for ${email}, but cloud validation failed. Run: 0dai auth login`,
33
+ };
34
+ }
35
+
36
+ return {
37
+ ok: true,
38
+ degraded: false,
39
+ auth,
40
+ status,
41
+ message: "",
42
+ };
43
+ }
44
+
11
45
  async function cmdAuthLogin() {
12
46
  const isTTY = process.stdout.isTTY && process.stdin.isTTY;
13
47
 
@@ -131,8 +165,8 @@ async function cmdAuthLogin() {
131
165
  process.exit(1);
132
166
  }
133
167
  }
134
- s.stop("Timed out");
135
- p.log.error("Try again.");
168
+ s.stop("Device code expired");
169
+ p.log.error("The code expired after 10 minutes. Run '0dai auth login' to get a new code.");
136
170
  process.exit(1);
137
171
 
138
172
  } else {
@@ -158,7 +192,7 @@ async function cmdAuthLogin() {
158
192
  return;
159
193
  }
160
194
  }
161
- log("Timed out");
195
+ log("Device code expired after 10 minutes. Run '0dai auth login' again.");
162
196
  process.exit(1);
163
197
  }
164
198
  }
@@ -196,26 +230,25 @@ async function cmdRedeem(code) {
196
230
  }
197
231
 
198
232
  async function cmdAuthStatus() {
199
- try {
200
- const auth = loadAuthState();
201
- if (!auth) throw new Error("missing auth");
202
- // Backwards compat: old auth.json used `user`, new uses `email`
203
- const email = auth.email || auth.user || "unknown";
204
- log(`${email} (${auth.plan || "free"} plan)`);
205
- // Get usage from API
206
- const status = await fetchAuthStatus();
207
- if (status.usage_today) {
208
- console.log(" Usage today:");
209
- for (const [k, v] of Object.entries(status.usage_today))
210
- console.log(` ${k}: ${v} / ${status.limits[k]}`);
211
- }
212
- const license = status.license || auth.license || { status: "inactive" };
213
- console.log(` Activation: ${license.status || "inactive"}${license.activation_id ? ` (${license.activation_id})` : ""}`);
214
- if (status.projects && status.projects.length) {
215
- console.log(` Projects bound: ${status.projects.length} / ${status.project_limit || "?"}`);
216
- }
217
- } catch {
218
- log("Not logged in. Run: 0dai auth login");
233
+ const session = await resolveSessionState();
234
+ if (!session.ok) {
235
+ log(session.message);
236
+ process.exitCode = 1;
237
+ return;
238
+ }
239
+
240
+ const { auth, status } = session;
241
+ const email = status.email || auth.email || auth.user || "unknown";
242
+ log(`${email} (${status.plan || auth.plan || "free"} plan)`);
243
+ if (status.usage_today) {
244
+ console.log(" Usage today:");
245
+ for (const [k, v] of Object.entries(status.usage_today))
246
+ console.log(` ${k}: ${v} / ${status.limits[k]}`);
247
+ }
248
+ const license = status.license || auth.license || { status: "inactive" };
249
+ console.log(` Activation: ${license.status || "inactive"}${license.activation_id ? ` (${license.activation_id})` : ""}`);
250
+ if (status.projects && status.projects.length) {
251
+ console.log(` Projects bound: ${status.projects.length} / ${status.project_limit || "?"}`);
219
252
  }
220
253
  }
221
254
 
@@ -229,13 +262,19 @@ async function cmdActivateFree() {
229
262
  }
230
263
 
231
264
  async function cmdActivateStatus() {
232
- const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
233
- const status = await ensureAuthenticated("activation status");
234
- const license = status.license || (await apiCall("/v1/licenses/status")).license || { status: "inactive" };
265
+ const session = await resolveSessionState();
266
+ if (!session.ok) {
267
+ log(session.message);
268
+ process.exitCode = 1;
269
+ return;
270
+ }
271
+
272
+ const licenseResult = await apiCall("/v1/licenses/status");
273
+ const license = licenseResult.license || session.status.license || session.auth.license || { status: "inactive" };
235
274
  updateAuthState({ license });
236
275
  log(`license ${license.status || "inactive"}`);
237
276
  if (license.activation_id) console.log(` activation id: ${license.activation_id}`);
238
- console.log(` plan: ${license.plan || status.plan || "free"}`);
277
+ console.log(` plan: ${license.plan || session.status.plan || session.auth.plan || "free"}`);
239
278
  }
240
279
 
241
- module.exports = { cmdAuthLogin, cmdAuthLogout, cmdRedeem, cmdAuthStatus, cmdActivateFree, cmdActivateStatus };
280
+ module.exports = { cmdAuthLogin, cmdAuthLogout, cmdRedeem, cmdAuthStatus, cmdActivateFree, cmdActivateStatus, resolveSessionState };
@@ -2,7 +2,7 @@
2
2
  const shared = require("../shared");
3
3
  const { log, T, R, D, fs, path, spawnSync, findRepoScript, SUPPORTED_CLIS, recordExperienceEvent } = shared;
4
4
 
5
- function cmdDoctor(target) {
5
+ function cmdDoctor(target, options = {}) {
6
6
  const ai = path.join(target, "ai");
7
7
  if (!fs.existsSync(ai)) { log("No 0dai config found. Run: 0dai init"); return; }
8
8
  let v = "?", stack = "generic";
@@ -108,6 +108,36 @@ function cmdDoctor(target) {
108
108
  console.log(` ${mark.padEnd(22)} ${c.name}${hint}`);
109
109
  }
110
110
 
111
+ console.log("\n provider profiles:");
112
+ try {
113
+ const providerScript = findRepoScript(target, "provider_profiles.py");
114
+ if (!providerScript) {
115
+ console.log(` ${D}—${R2} unavailable in this environment`);
116
+ } else {
117
+ const pr = spawnSync(
118
+ "python3",
119
+ [providerScript, "status", "--target", target, "--json"],
120
+ { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 },
121
+ );
122
+ if (pr.stdout) {
123
+ const payload = JSON.parse(pr.stdout);
124
+ const bindings = payload.bindings && typeof payload.bindings === "object" ? payload.bindings : {};
125
+ const resolved = Object.entries(bindings).filter(([, profileId]) => Boolean(profileId));
126
+ if (!resolved.length) {
127
+ console.log(` ${D}—${R2} no provider profiles bound`);
128
+ } else {
129
+ for (const [agent, profileId] of resolved.sort((a, b) => a[0].localeCompare(b[0]))) {
130
+ console.log(` ${G}ok${R2} ${agent} ${D}→ ${profileId}${R2}`);
131
+ }
132
+ }
133
+ } else {
134
+ console.log(` ${D}—${R2} no provider profiles bound`);
135
+ }
136
+ }
137
+ } catch {
138
+ console.log(` ${D}—${R2} unable to resolve provider profile status`);
139
+ }
140
+
111
141
  // --- agent CLIs check ---
112
142
  const { execFileSync: _ef2 } = require("child_process");
113
143
  let updatesAvailable = 0;
@@ -172,23 +202,39 @@ function cmdDoctor(target) {
172
202
  review_needed: warnings > 0,
173
203
  },
174
204
  });
175
- if (errors) process.exitCode = 1;
205
+ if (errors && !options.suppressExitCode) process.exitCode = 1;
176
206
 
177
207
  // Drift summary (lightweight — full report via --drift flag)
178
- try {
179
- const ds = findRepoScript(target, "drift_detector.py");
180
- if (ds) {
181
- const dr = spawnSync("python3", [ds, "report", "--target", target],
182
- { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
183
- if (dr.stdout && dr.stdout.includes("MODIFIED")) {
184
- const lines = dr.stdout.trim().split("\n");
185
- const driftCount = lines.filter(l => l.includes("MODIFIED") || l.includes("CONTRADICTS")).length;
186
- if (driftCount > 0) {
187
- console.log(`\n config drift: ${driftCount} issue(s) — run: 0dai doctor --drift`);
208
+ if (!options.drift) {
209
+ try {
210
+ const ds = findRepoScript(target, "drift_detector.py");
211
+ if (ds) {
212
+ const dr = spawnSync("python3", [ds, "report", "--target", target],
213
+ { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
214
+ if (dr.stdout && dr.stdout.includes("MODIFIED")) {
215
+ const lines = dr.stdout.trim().split("\n");
216
+ const driftCount = lines.filter(l => l.includes("MODIFIED") || l.includes("CONTRADICTS")).length;
217
+ if (driftCount > 0) {
218
+ console.log(`\n config drift: ${driftCount} issue(s) — run: 0dai doctor --drift`);
219
+ }
188
220
  }
189
221
  }
222
+ } catch {}
223
+ }
224
+
225
+ // --fix: auto-repair by running sync
226
+ if (process.argv.includes("--fix") && errors) {
227
+ console.log(`\n ${G}auto-fix:${R2} running 0dai sync to regenerate missing files...`);
228
+ try {
229
+ const { cmdSync } = require("./init");
230
+ cmdSync(target, ["--quiet"]);
231
+ } catch (e) {
232
+ console.log(` ${E}auto-fix failed:${R2} ${e.message}`);
233
+ console.log(` ${D}Run manually: 0dai sync${R2}`);
190
234
  }
191
- } catch {}
235
+ } else if (errors) {
236
+ console.log(`\n ${D}Tip: run 0dai doctor --fix to auto-repair${R2}`);
237
+ }
192
238
  }
193
239
 
194
240
  module.exports = { cmdDoctor };
@@ -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
- writeFiles, writeManagedFiles, sendProjectHeartbeat, recordExperienceEvent,
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",
@@ -173,7 +202,6 @@ async function cmdSync(target, args = []) {
173
202
  const dryRun = args.includes("--dry-run");
174
203
  const quiet = args.includes("--quiet") || args.includes("-q");
175
204
  const force = args.includes("--force");
176
- const updateTemplates = args.includes("--update-templates");
177
205
 
178
206
  // Quick local check: skip API if already at current version (unless dry-run or force)
179
207
  let version = "unknown";
@@ -181,8 +209,6 @@ async function cmdSync(target, args = []) {
181
209
 
182
210
  const metadata = collectMetadata(target);
183
211
  const { manifestContents, clis } = metadata;
184
- const authStatus = await ensureAuthenticated("sync");
185
- const license = await ensureLicenseActivation();
186
212
  let stack = "generic", agents = [];
187
213
  try {
188
214
  const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
@@ -190,6 +216,28 @@ async function cmdSync(target, args = []) {
190
216
  agents = d.selected_agents || [];
191
217
  } catch {}
192
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();
193
241
  const boundProject = await bindProjectForCloud(target, metadata, identity);
194
242
 
195
243
  // Collect current ai/ files
@@ -213,12 +261,11 @@ async function cmdSync(target, args = []) {
213
261
 
214
262
  if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
215
263
  if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
216
- if (updateTemplates && !dryRun) log(`${T}template update mode: will refresh managed native configs from latest templates${R}`);
217
264
 
218
265
  const result = await apiCall("/v1/sync", {
219
266
  ai_version: version, stack, agents: agents.length ? agents : clis,
220
267
  current_files: currentFiles, manifest_contents: manifestContents,
221
- dry_run: dryRun, quiet, force, update_templates: updateTemplates,
268
+ dry_run: dryRun, quiet, force,
222
269
  project_name: identity.project_name,
223
270
  project_id: boundProject.project_id || identity.project_id,
224
271
  remote_origin: identity.remote_origin,
@@ -241,9 +288,6 @@ async function cmdSync(target, args = []) {
241
288
  } else {
242
289
  log(`${D}dry-run: nothing to update${R}`);
243
290
  }
244
- if (result.template_update_available) {
245
- console.log(` ${D}template update available: run 0dai sync --update-templates${R}`);
246
- }
247
291
  return;
248
292
  }
249
293
  const changedCount = Object.keys(updated).length;
@@ -258,18 +302,13 @@ async function cmdSync(target, args = []) {
258
302
  log("already up to date");
259
303
  }
260
304
 
261
- if (result.template_update_available && !updateTemplates && !quiet) {
262
- console.log(` ${D}Template update available: run 0dai sync --update-templates to refresh managed native configs${R}`);
263
- }
264
-
265
305
  // --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
266
306
  if (force && result.native_configs) {
307
+ const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
267
308
  let overwritten = 0;
268
- for (const [name, content] of Object.entries(result.native_configs)) {
269
- const targetPath = path.join(target, name);
270
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
271
- if (content) {
272
- fs.writeFileSync(targetPath, content, "utf8");
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");
273
312
  overwritten++;
274
313
  if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
275
314
  }
@@ -277,11 +316,6 @@ async function cmdSync(target, args = []) {
277
316
  if (overwritten && !quiet) {
278
317
  log(`force: ${overwritten} native config file(s) overwritten`);
279
318
  }
280
- } else if (updateTemplates && result.native_configs) {
281
- writeManagedFiles(target, result.native_configs);
282
- if (!quiet) {
283
- log("template update: managed native configs refreshed");
284
- }
285
319
  }
286
320
 
287
321
  // --force: update drift baseline hashes so drift clears after regeneration
@@ -311,7 +345,7 @@ async function cmdSync(target, args = []) {
311
345
 
312
346
  // Update portfolio registry
313
347
  registerProject(target, path.basename(target), stack);
314
- await sendProjectHeartbeat(identity, result, {
348
+ await sendProjectHeartbeat(target, identity, result, {
315
349
  project_id: boundProject.project_id || identity.project_id,
316
350
  }).catch(() => {});
317
351
  recordExperienceEvent(target, {
@@ -324,4 +358,58 @@ async function cmdSync(target, args = []) {
324
358
  });
325
359
  }
326
360
 
327
- 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,34 +28,35 @@ 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
 
@@ -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 };