@0dai-dev/cli 4.2.0 → 4.3.5
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/README.md +98 -10
- package/bin/0dai.js +298 -60
- package/lib/commands/audit.js +13 -0
- package/lib/commands/auth.js +344 -98
- package/lib/commands/boneyard.js +44 -0
- package/lib/commands/ci.js +329 -0
- package/lib/commands/compliance.js +20 -0
- package/lib/commands/doctor.js +39 -1
- package/lib/commands/experience.js +5 -1
- package/lib/commands/feedback.js +92 -5
- package/lib/commands/gh.js +506 -0
- package/lib/commands/graph.js +78 -10
- package/lib/commands/heatmap.js +17 -0
- package/lib/commands/import_claude_code_agents.js +367 -0
- package/lib/commands/init.js +504 -28
- package/lib/commands/loop.js +108 -0
- package/lib/commands/mcp.js +410 -0
- package/lib/commands/models.js +27 -3
- package/lib/commands/paste.js +114 -0
- package/lib/commands/play.js +173 -0
- package/lib/commands/provider.js +69 -0
- package/lib/commands/quota.js +76 -0
- package/lib/commands/receipt.js +53 -0
- package/lib/commands/report.js +29 -2
- package/lib/commands/run.js +104 -7
- package/lib/commands/runner.js +527 -0
- package/lib/commands/session.js +1 -7
- package/lib/commands/standup.js +40 -0
- package/lib/commands/status.js +30 -1
- package/lib/commands/swarm.js +97 -4
- package/lib/commands/tui.js +81 -13
- package/lib/commands/upgrade.js +58 -0
- package/lib/commands/usage.js +87 -0
- package/lib/commands/vault.js +246 -0
- package/lib/onboarding.js +9 -3
- package/lib/shared.js +29 -14
- package/lib/utils/activation_telemetry.js +156 -0
- package/lib/utils/auth.js +1 -0
- package/lib/utils/canonical-counts.js +54 -0
- package/lib/utils/constants.js +7 -0
- package/lib/utils/diff-preview.js +192 -0
- package/lib/utils/identity.js +76 -18
- package/lib/utils/mcp-auth.js +607 -0
- package/lib/utils/plan.js +47 -2
- package/lib/utils/run_cost.js +91 -0
- package/lib/vault/cipher.js +125 -0
- package/lib/vault/identity.js +122 -0
- package/lib/vault/index.js +184 -0
- package/lib/vault/storage.js +84 -0
- package/lib/wizard.js +19 -12
- package/package.json +8 -4
- package/lib/tui/index.mjs +0 -34610
package/lib/commands/init.js
CHANGED
|
@@ -1,17 +1,148 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
const crypto = require("crypto");
|
|
2
3
|
const shared = require("../shared");
|
|
3
4
|
const {
|
|
4
|
-
T, R, D, log,
|
|
5
|
+
T, R, D, W, log,
|
|
5
6
|
fs, path,
|
|
6
7
|
VERSION, SUPPORTED_CLIS,
|
|
8
|
+
CONFIG_DIR, PROJECTS_FILE,
|
|
7
9
|
apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
|
|
8
10
|
collectMetadata, buildProjectIdentity, registerProject,
|
|
9
11
|
writeFiles, sendProjectHeartbeat, recordExperienceEvent,
|
|
10
12
|
logFirstRunSuccess,
|
|
11
13
|
} = shared;
|
|
12
|
-
const { cmdAuthLogin } = require("./auth");
|
|
14
|
+
const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = require("./auth");
|
|
15
|
+
const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
|
|
16
|
+
const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
|
|
17
|
+
const { bootstrapMcp } = require("../utils/mcp-auth");
|
|
18
|
+
const { recordActivationInit } = require("../utils/activation_telemetry");
|
|
13
19
|
|
|
14
20
|
const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
|
|
21
|
+
const SYNC_FULL_CONTENT_LIMIT = 10000;
|
|
22
|
+
const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
|
|
23
|
+
|
|
24
|
+
function hashFile(filePath) {
|
|
25
|
+
const hash = crypto.createHash("sha256");
|
|
26
|
+
const fd = fs.openSync(filePath, "r");
|
|
27
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
28
|
+
try {
|
|
29
|
+
let bytesRead = 0;
|
|
30
|
+
do {
|
|
31
|
+
bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
32
|
+
if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
|
|
33
|
+
} while (bytesRead > 0);
|
|
34
|
+
} finally {
|
|
35
|
+
fs.closeSync(fd);
|
|
36
|
+
}
|
|
37
|
+
return hash.digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function describeCurrentFile(filePath, stat) {
|
|
41
|
+
if (stat.size < SYNC_FULL_CONTENT_LIMIT) return fs.readFileSync(filePath, "utf8");
|
|
42
|
+
return {
|
|
43
|
+
size: stat.size,
|
|
44
|
+
sha256: hashFile(filePath),
|
|
45
|
+
compare: "hash-only",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// detectRegistryDrift — Phase 1.5 of #2237 registry self-heal.
|
|
50
|
+
// Returns { drifted: bool, existingPath?: string, currentPath: string } when an
|
|
51
|
+
// entry with the same `name` is already in ~/.0dai/projects.json but points at
|
|
52
|
+
// a different directory than the one we're about to register. Caller decides
|
|
53
|
+
// what to do (warn, prompt, or auto-overwrite based on flags).
|
|
54
|
+
function detectRegistryDrift(target, name) {
|
|
55
|
+
const currentPath = path.resolve(target);
|
|
56
|
+
try {
|
|
57
|
+
if (!fs.existsSync(PROJECTS_FILE)) return { drifted: false, currentPath };
|
|
58
|
+
const raw = fs.readFileSync(PROJECTS_FILE, "utf8");
|
|
59
|
+
const data = JSON.parse(raw);
|
|
60
|
+
const projects = Array.isArray(data && data.projects) ? data.projects : [];
|
|
61
|
+
for (const entry of projects) {
|
|
62
|
+
if (!entry || typeof entry !== "object") continue;
|
|
63
|
+
if (entry.name !== name) continue;
|
|
64
|
+
if (!entry.path || typeof entry.path !== "string") continue;
|
|
65
|
+
if (entry.path === currentPath) continue;
|
|
66
|
+
return { drifted: true, existingPath: entry.path, currentPath, archived: entry.archived === true };
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
return { drifted: false, currentPath };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// guardRegistryDrift — returns true when caller may proceed with registration,
|
|
73
|
+
// false if the operator declined the overwrite. Async to support interactive
|
|
74
|
+
// prompt; honours --non-interactive (and --yes / common CI flags) by
|
|
75
|
+
// auto-overwriting silently. ODAI_REGISTRY_DRIFT_OVERWRITE=1 also bypasses
|
|
76
|
+
// the prompt for scripted runs.
|
|
77
|
+
async function guardRegistryDrift(target, name, args) {
|
|
78
|
+
const drift = detectRegistryDrift(target, name);
|
|
79
|
+
if (!drift.drifted) return true;
|
|
80
|
+
// Archived entries are not a real conflict — registry_audit.py archives
|
|
81
|
+
// missing paths; let init silently overwrite them.
|
|
82
|
+
if (drift.archived) return true;
|
|
83
|
+
|
|
84
|
+
const nonInteractive =
|
|
85
|
+
args.includes("--non-interactive") ||
|
|
86
|
+
args.includes("--yes") ||
|
|
87
|
+
args.includes("-y") ||
|
|
88
|
+
!process.stdout.isTTY ||
|
|
89
|
+
process.env.ODAI_REGISTRY_DRIFT_OVERWRITE === "1";
|
|
90
|
+
|
|
91
|
+
log(`${W}registry drift: project '${name}' is registered at ${drift.existingPath}${R}`);
|
|
92
|
+
console.log(` current cwd: ${drift.currentPath}`);
|
|
93
|
+
if (nonInteractive) {
|
|
94
|
+
console.log(` ${D}auto-overwriting registry entry (non-interactive mode)${R}`);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const readline = require("readline");
|
|
100
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
101
|
+
const answer = await new Promise((resolve) => {
|
|
102
|
+
rl.question(` Overwrite registry entry to point at ${drift.currentPath}? [y/N] `, (a) => {
|
|
103
|
+
rl.close();
|
|
104
|
+
resolve(String(a || "").trim().toLowerCase());
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
if (answer === "y" || answer === "yes") return true;
|
|
108
|
+
} catch {
|
|
109
|
+
// readline failed (e.g. no TTY) — fall through to skip
|
|
110
|
+
}
|
|
111
|
+
console.log(
|
|
112
|
+
` ${D}skipping registry update. Set ODAI_REGISTRY_DRIFT_OVERWRITE=1 or pass --non-interactive to overwrite.${R}`,
|
|
113
|
+
);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function registerProjectOrExit(target, name, stack) {
|
|
118
|
+
try {
|
|
119
|
+
return registerProject(target, name, stack, CONFIG_DIR, PROJECTS_FILE);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
log(`error: failed to update project registry: ${err.message}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collectCurrentAiFiles(target) {
|
|
127
|
+
const currentFiles = {};
|
|
128
|
+
const aiDir = path.join(target, "ai");
|
|
129
|
+
if (fs.existsSync(aiDir)) {
|
|
130
|
+
const walk = (dir) => {
|
|
131
|
+
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
132
|
+
const p = path.join(dir, f.name);
|
|
133
|
+
if (f.isDirectory()) walk(p);
|
|
134
|
+
else {
|
|
135
|
+
try {
|
|
136
|
+
const stat = fs.statSync(p);
|
|
137
|
+
if (stat.isFile()) currentFiles[path.relative(target, p)] = describeCurrentFile(p, stat);
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(aiDir);
|
|
143
|
+
}
|
|
144
|
+
return currentFiles;
|
|
145
|
+
}
|
|
15
146
|
|
|
16
147
|
// bindProjectForCloud — binds project to cloud via /v1/projects/bind
|
|
17
148
|
async function bindProjectForCloud(target, metadata, identity) {
|
|
@@ -32,14 +163,138 @@ async function bindProjectForCloud(target, metadata, identity) {
|
|
|
32
163
|
};
|
|
33
164
|
}
|
|
34
165
|
|
|
166
|
+
function cloudInitCheckpointPath(target) {
|
|
167
|
+
return path.join(target, CLOUD_INIT_CHECKPOINT_REL);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _hasAuthToken() {
|
|
171
|
+
const auth = loadAuthState();
|
|
172
|
+
return !!(auth && (auth.api_key || auth.access_token || auth.token));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function _quoteCommandArg(value) {
|
|
176
|
+
const text = String(value || "");
|
|
177
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
|
|
178
|
+
return `'${text.replace(/'/g, "'\"'\"'")}'`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _initCommandName(args = []) {
|
|
182
|
+
const first = String((Array.isArray(args) ? args : [])[0] || "");
|
|
183
|
+
return first === "init-existing" ? "init-existing" : "init";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _resumeArgs(args = []) {
|
|
187
|
+
const raw = Array.isArray(args) ? args : [];
|
|
188
|
+
const withoutCommand = raw[0] === "init" || raw[0] === "init-existing" ? raw.slice(1) : raw.slice();
|
|
189
|
+
const skipWithValue = new Set(["--auth-code", "--oauth-code", "--exchange-code", "--code", "--activation-code", "--redeem-code", "--plan-code"]);
|
|
190
|
+
const filtered = [];
|
|
191
|
+
for (let i = 0; i < withoutCommand.length; i++) {
|
|
192
|
+
const arg = String(withoutCommand[i] || "");
|
|
193
|
+
if (!arg || arg === "--resume") continue;
|
|
194
|
+
if (skipWithValue.has(arg)) { i++; continue; }
|
|
195
|
+
if ([...skipWithValue].some((name) => arg.startsWith(`${name}=`))) continue;
|
|
196
|
+
filtered.push(arg);
|
|
197
|
+
}
|
|
198
|
+
return filtered;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildCloudInitResumeCommand(target, args = []) {
|
|
202
|
+
const parts = ["0dai", _initCommandName(args), "--target", _quoteCommandArg(path.resolve(target)), "--resume"];
|
|
203
|
+
for (const arg of _resumeArgs(args)) parts.push(_quoteCommandArg(arg));
|
|
204
|
+
return parts.join(" ");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function writeCloudInitCheckpoint(target, details = {}) {
|
|
208
|
+
const checkpointPath = cloudInitCheckpointPath(target);
|
|
209
|
+
const checkpoint = {
|
|
210
|
+
version: 1,
|
|
211
|
+
command: _initCommandName(details.args || []),
|
|
212
|
+
stage: details.stage || "auth_required",
|
|
213
|
+
reason: details.reason || "missing_auth",
|
|
214
|
+
target: path.resolve(target),
|
|
215
|
+
created_at: new Date().toISOString(),
|
|
216
|
+
next_command: "0dai auth login --device --no-browser",
|
|
217
|
+
resume_command: buildCloudInitResumeCommand(target, details.args || []),
|
|
218
|
+
};
|
|
219
|
+
fs.mkdirSync(path.dirname(checkpointPath), { recursive: true, mode: 0o700 });
|
|
220
|
+
fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", { mode: 0o600 });
|
|
221
|
+
return checkpoint;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function clearCloudInitCheckpoint(target) {
|
|
225
|
+
try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function maybePauseCloudInitForAuth(target, args = [], options = {}) {
|
|
229
|
+
if (options.dryRun || options.localMode) return false;
|
|
230
|
+
if (_hasAuthToken()) return false;
|
|
231
|
+
const parsed = parseActivationArgs(args, { genericCode: "activation" });
|
|
232
|
+
if (parsed.authCode) return false;
|
|
233
|
+
if (process.stdout.isTTY && process.stdin.isTTY) return false;
|
|
234
|
+
|
|
235
|
+
const checkpoint = writeCloudInitCheckpoint(target, {
|
|
236
|
+
args,
|
|
237
|
+
stage: "auth_required",
|
|
238
|
+
reason: "missing_auth",
|
|
239
|
+
});
|
|
240
|
+
log("authentication required for init");
|
|
241
|
+
console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
|
|
242
|
+
console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
|
|
243
|
+
console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function cmdProjectBind(target, args = []) {
|
|
248
|
+
const json = args.includes("--json");
|
|
249
|
+
const authStatus = await ensureAuthenticated("project bind");
|
|
250
|
+
const license = await ensureLicenseActivation();
|
|
251
|
+
const metadata = collectMetadata(target);
|
|
252
|
+
const identity = buildProjectIdentity(target, metadata);
|
|
253
|
+
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
254
|
+
const stack = boundProject.stack || identity.stack || "unknown";
|
|
255
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
256
|
+
registerProjectOrExit(target, path.basename(target), stack);
|
|
257
|
+
}
|
|
258
|
+
const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
|
|
259
|
+
project_id: boundProject.project_id || identity.project_id,
|
|
260
|
+
}).catch((err) => ({ error: err.message || String(err) }));
|
|
261
|
+
const payload = {
|
|
262
|
+
ok: true,
|
|
263
|
+
account: {
|
|
264
|
+
email: authStatus.email || null,
|
|
265
|
+
plan: authStatus.plan || license.plan || "free",
|
|
266
|
+
activation: license.status || "active",
|
|
267
|
+
},
|
|
268
|
+
project: {
|
|
269
|
+
project_id: boundProject.project_id || identity.project_id,
|
|
270
|
+
name: boundProject.name || identity.project_name,
|
|
271
|
+
stack,
|
|
272
|
+
binding_status: boundProject.binding_status || "bound",
|
|
273
|
+
},
|
|
274
|
+
heartbeat: !(heartbeat && heartbeat.error),
|
|
275
|
+
};
|
|
276
|
+
if (json) {
|
|
277
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
278
|
+
} else {
|
|
279
|
+
log("project bound");
|
|
280
|
+
console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
|
|
281
|
+
console.log(` project: ${payload.project.project_id}`);
|
|
282
|
+
if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
|
|
283
|
+
}
|
|
284
|
+
return payload;
|
|
285
|
+
}
|
|
286
|
+
|
|
35
287
|
async function cmdInit(target, args = []) {
|
|
36
288
|
const dryRun = args.includes("--dry-run");
|
|
37
289
|
const minimal = args.includes("--minimal");
|
|
38
290
|
const noWizard = args.includes("--no-wizard");
|
|
291
|
+
const localMode = args.includes("--local");
|
|
39
292
|
|
|
40
293
|
if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
|
|
41
294
|
const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
|
|
42
295
|
log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
|
|
296
|
+
if (args.includes("--resume")) clearCloudInitCheckpoint(target);
|
|
297
|
+
if (!dryRun) await runMcpBootstrap(target, args);
|
|
43
298
|
return;
|
|
44
299
|
}
|
|
45
300
|
|
|
@@ -58,12 +313,13 @@ async function cmdInit(target, args = []) {
|
|
|
58
313
|
}
|
|
59
314
|
|
|
60
315
|
// First-run wizard (unless --no-wizard or non-interactive)
|
|
61
|
-
if (!noWizard && !dryRun && !minimal) {
|
|
316
|
+
if (!noWizard && !dryRun && !minimal && !localMode) {
|
|
62
317
|
try {
|
|
63
318
|
const { runWizard, isInteractive } = require("../wizard");
|
|
64
319
|
if (isInteractive()) {
|
|
65
320
|
const result = await runWizard(target);
|
|
66
321
|
if (result.completed) {
|
|
322
|
+
await runMcpBootstrap(target, args);
|
|
67
323
|
try {
|
|
68
324
|
const ob = require("../onboarding");
|
|
69
325
|
ob.trackFirstInit(target);
|
|
@@ -71,9 +327,25 @@ async function cmdInit(target, args = []) {
|
|
|
71
327
|
} catch {}
|
|
72
328
|
return;
|
|
73
329
|
}
|
|
330
|
+
if (result.cancelled) return;
|
|
74
331
|
}
|
|
75
332
|
} catch {}
|
|
76
333
|
}
|
|
334
|
+
if (localMode) {
|
|
335
|
+
const { runWizard } = require("../wizard");
|
|
336
|
+
const result = await runWizard(target, { forceLocal: true });
|
|
337
|
+
if (result.completed) {
|
|
338
|
+
await runMcpBootstrap(target, args);
|
|
339
|
+
try {
|
|
340
|
+
const ob = require("../onboarding");
|
|
341
|
+
ob.trackFirstInit(target);
|
|
342
|
+
ob.showWhatsNext("local", false);
|
|
343
|
+
} catch {}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
maybePauseCloudInitForAuth(target, args, { dryRun, localMode });
|
|
77
349
|
|
|
78
350
|
const isTTY = process.stdout.isTTY;
|
|
79
351
|
let spinner = null;
|
|
@@ -83,7 +355,7 @@ async function cmdInit(target, args = []) {
|
|
|
83
355
|
|
|
84
356
|
const metadata = collectMetadata(target);
|
|
85
357
|
const { projectFiles, manifestContents, clis } = metadata;
|
|
86
|
-
const authStatus = await
|
|
358
|
+
const authStatus = await ensureAccountForActivation("init", args);
|
|
87
359
|
const license = await ensureLicenseActivation();
|
|
88
360
|
const identity = buildProjectIdentity(target, metadata);
|
|
89
361
|
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
@@ -123,6 +395,11 @@ async function cmdInit(target, args = []) {
|
|
|
123
395
|
return;
|
|
124
396
|
}
|
|
125
397
|
writeFiles(target, result.files || {});
|
|
398
|
+
const envManifest = normalizeEnvironmentManifest(target);
|
|
399
|
+
if (envManifest.changed) {
|
|
400
|
+
log("environment manifest target normalized: ai/manifest/environment.yaml");
|
|
401
|
+
}
|
|
402
|
+
await runMcpBootstrap(target, args);
|
|
126
403
|
|
|
127
404
|
// Ensure ai/VERSION matches CLI version
|
|
128
405
|
const versionFile = path.join(target, "ai", "VERSION");
|
|
@@ -136,8 +413,15 @@ async function cmdInit(target, args = []) {
|
|
|
136
413
|
if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
|
|
137
414
|
} catch {}
|
|
138
415
|
|
|
139
|
-
|
|
140
|
-
|
|
416
|
+
const gitPolicy = ensureGithubFlowPolicy(target);
|
|
417
|
+
if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
|
|
418
|
+
console.log(` ${D}branch protection: ${gitPolicy.protection.reason}${R}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Register in global portfolio (Phase 1.5 #2237: drift guard)
|
|
422
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
423
|
+
registerProjectOrExit(target, path.basename(target), result.stack);
|
|
424
|
+
}
|
|
141
425
|
|
|
142
426
|
log(`initialized (${result.file_count || "?"} files)`);
|
|
143
427
|
console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
|
|
@@ -180,6 +464,8 @@ async function cmdInit(target, args = []) {
|
|
|
180
464
|
// First-run proof gate (issue #342). All 4 gates pass once we reach here:
|
|
181
465
|
// license active (line above), project bound, ai/ layer written, heartbeat sent.
|
|
182
466
|
// Idempotent — only fires once per project. See docs/first-run.md.
|
|
467
|
+
recordActivationInit(target, boundProject.project_id || identity.project_id);
|
|
468
|
+
|
|
183
469
|
const firstRun = logFirstRunSuccess(target, {
|
|
184
470
|
license: true,
|
|
185
471
|
project_bound: true,
|
|
@@ -196,6 +482,136 @@ async function cmdInit(target, args = []) {
|
|
|
196
482
|
stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
|
|
197
483
|
_cli_version: VERSION, _files_generated: result.file_count || 0,
|
|
198
484
|
}}).catch(() => {});
|
|
485
|
+
clearCloudInitCheckpoint(target);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function runMcpBootstrap(target, args = []) {
|
|
489
|
+
const result = await bootstrapMcp(target, args, (message) => log(message));
|
|
490
|
+
if (!result.ok) {
|
|
491
|
+
log(`warn: MCP bootstrap skipped: ${result.warnings.join("; ")}`);
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
if (result.config) {
|
|
495
|
+
const verbs = [];
|
|
496
|
+
if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
|
|
497
|
+
if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
|
|
498
|
+
if (result.config.changed) {
|
|
499
|
+
log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (result.settings && result.settings.changed) {
|
|
503
|
+
log("Claude project MCP auto-load enabled: .claude/settings.json");
|
|
504
|
+
}
|
|
505
|
+
if (result.auth) {
|
|
506
|
+
if (result.auth.status === "preserved") {
|
|
507
|
+
log("MCP auth token preserved");
|
|
508
|
+
} else if (result.auth.status === "written") {
|
|
509
|
+
log(`MCP auth token stored: ${result.auth.tokenPath}`);
|
|
510
|
+
} else if (result.auth.status === "disabled") {
|
|
511
|
+
log("MCP cloud auth skipped (--no-mcp-auth)");
|
|
512
|
+
} else if (result.auth.status === "skipped") {
|
|
513
|
+
console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude.ai 0dai${R}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const warning of result.warnings || []) log(`warn: ${warning}`);
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function normalizeEnvironmentManifest(target) {
|
|
521
|
+
const filePath = path.join(target, "ai", "manifest", "environment.yaml");
|
|
522
|
+
if (!fs.existsSync(filePath)) return { path: filePath, changed: false };
|
|
523
|
+
const original = fs.readFileSync(filePath, "utf8");
|
|
524
|
+
const lines = original.split(/\n/);
|
|
525
|
+
let inWorkspace = false;
|
|
526
|
+
let sawTarget = false;
|
|
527
|
+
let sawCwd = false;
|
|
528
|
+
const next = [];
|
|
529
|
+
for (const line of lines) {
|
|
530
|
+
if (/^\S/.test(line) && !line.startsWith("workspace:")) inWorkspace = false;
|
|
531
|
+
if (line.trim() === "workspace:") {
|
|
532
|
+
inWorkspace = true;
|
|
533
|
+
next.push(line);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (inWorkspace && /^ target:\s*/.test(line)) {
|
|
537
|
+
next.push(" target: .");
|
|
538
|
+
sawTarget = true;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (inWorkspace && /^ cwd:\s*/.test(line)) {
|
|
542
|
+
next.push(" cwd: .");
|
|
543
|
+
sawCwd = true;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (inWorkspace && /^available_clis:\s*$/.test(line)) {
|
|
547
|
+
if (!sawTarget) next.push(" target: .");
|
|
548
|
+
if (!sawCwd) next.push(" cwd: .");
|
|
549
|
+
inWorkspace = false;
|
|
550
|
+
}
|
|
551
|
+
next.push(line);
|
|
552
|
+
}
|
|
553
|
+
if (inWorkspace) {
|
|
554
|
+
if (!sawTarget) next.push(" target: .");
|
|
555
|
+
if (!sawCwd) next.push(" cwd: .");
|
|
556
|
+
}
|
|
557
|
+
const rendered = next.join("\n");
|
|
558
|
+
if (rendered === original) return { path: filePath, changed: false };
|
|
559
|
+
fs.writeFileSync(filePath, rendered, "utf8");
|
|
560
|
+
return { path: filePath, changed: true };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// runDocLinkCheck — SPEC-028 Phase 2 (#2689). After the managed layer has been
|
|
564
|
+
// written, run scripts/doc_link_check.py to surface broken/closed cross-refs
|
|
565
|
+
// in ai/ and docs/. Warn-only by default so a flaky reference never blocks an
|
|
566
|
+
// otherwise successful sync; --strict-links promotes failures to a non-zero
|
|
567
|
+
// exit, and --skip-link-check bypasses the hook entirely.
|
|
568
|
+
//
|
|
569
|
+
// The hook intentionally stays silent when the script is missing (Phase 1 may
|
|
570
|
+
// not have shipped to a downstream project) and when python3 is unavailable.
|
|
571
|
+
// Output is streamed inline via stdio: "inherit" so authors see exact paths.
|
|
572
|
+
function runDocLinkCheck(target, args = [], options = {}) {
|
|
573
|
+
if (args.includes("--skip-link-check")) {
|
|
574
|
+
return { skipped: true, reason: "flag" };
|
|
575
|
+
}
|
|
576
|
+
const script = path.join(target, "scripts", "doc_link_check.py");
|
|
577
|
+
if (!fs.existsSync(script)) {
|
|
578
|
+
return { skipped: true, reason: "missing-script" };
|
|
579
|
+
}
|
|
580
|
+
const quiet = !!options.quiet;
|
|
581
|
+
const strict = args.includes("--strict-links");
|
|
582
|
+
const { spawnSync } = require("child_process");
|
|
583
|
+
if (!quiet) log("doc-link-check: scanning ai/**/*.md docs/**/*.md");
|
|
584
|
+
const result = spawnSync(
|
|
585
|
+
"python3",
|
|
586
|
+
[
|
|
587
|
+
script,
|
|
588
|
+
"--repo-root",
|
|
589
|
+
target,
|
|
590
|
+
"--paths",
|
|
591
|
+
"ai/**/*.md",
|
|
592
|
+
"docs/**/*.md",
|
|
593
|
+
"--format",
|
|
594
|
+
"md",
|
|
595
|
+
"--fail-on",
|
|
596
|
+
"broken,closed",
|
|
597
|
+
],
|
|
598
|
+
{ stdio: "inherit", cwd: target },
|
|
599
|
+
);
|
|
600
|
+
if (result.error && result.error.code === "ENOENT") {
|
|
601
|
+
if (!quiet) console.log(` ${D}doc-link-check skipped: python3 not found${R}`);
|
|
602
|
+
return { skipped: true, reason: "no-python" };
|
|
603
|
+
}
|
|
604
|
+
const status = typeof result.status === "number" ? result.status : 0;
|
|
605
|
+
if (status !== 0) {
|
|
606
|
+
if (strict) {
|
|
607
|
+
log(`${W}doc-link-check failed (exit ${status}); --strict-links is set${R}`);
|
|
608
|
+
process.exit(status);
|
|
609
|
+
}
|
|
610
|
+
if (!quiet) log(`${W}doc-link-check found issues (exit ${status}); warn-only (pass --strict-links to fail sync)${R}`);
|
|
611
|
+
return { ran: true, status, strict, broken: true };
|
|
612
|
+
}
|
|
613
|
+
if (!quiet) log("doc-link-check: clean");
|
|
614
|
+
return { ran: true, status: 0, strict, broken: false };
|
|
199
615
|
}
|
|
200
616
|
|
|
201
617
|
async function cmdSync(target, args = []) {
|
|
@@ -240,24 +656,9 @@ async function cmdSync(target, args = []) {
|
|
|
240
656
|
const license = await ensureLicenseActivation();
|
|
241
657
|
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
242
658
|
|
|
243
|
-
// Collect current ai/ files
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
if (fs.existsSync(aiDir)) {
|
|
247
|
-
const walk = (dir) => {
|
|
248
|
-
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
249
|
-
const p = path.join(dir, f.name);
|
|
250
|
-
if (f.isDirectory()) walk(p);
|
|
251
|
-
else {
|
|
252
|
-
try {
|
|
253
|
-
const stat = fs.statSync(p);
|
|
254
|
-
if (stat.size < 10000) currentFiles[path.relative(target, p)] = fs.readFileSync(p, "utf8");
|
|
255
|
-
} catch {}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
walk(aiDir);
|
|
260
|
-
}
|
|
659
|
+
// Collect current ai/ files. Small files send content; larger files send
|
|
660
|
+
// hash descriptors so the server can compare without a full upload.
|
|
661
|
+
const currentFiles = collectCurrentAiFiles(target);
|
|
261
662
|
|
|
262
663
|
if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
|
|
263
664
|
if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
|
|
@@ -284,7 +685,14 @@ async function cmdSync(target, args = []) {
|
|
|
284
685
|
const files = Object.keys(updated);
|
|
285
686
|
if (files.length) {
|
|
286
687
|
log(`${D}dry-run: would update ${files.length} file(s):${R}`);
|
|
287
|
-
|
|
688
|
+
const { diff, summary } = renderFileMapDiff(updated, currentFiles);
|
|
689
|
+
for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
|
|
690
|
+
for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
|
|
691
|
+
for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
|
|
692
|
+
if (diff && !args.includes("--no-diff")) {
|
|
693
|
+
console.log("");
|
|
694
|
+
console.log(diff);
|
|
695
|
+
}
|
|
288
696
|
} else {
|
|
289
697
|
log(`${D}dry-run: nothing to update${R}`);
|
|
290
698
|
}
|
|
@@ -292,6 +700,23 @@ async function cmdSync(target, args = []) {
|
|
|
292
700
|
}
|
|
293
701
|
const changedCount = Object.keys(updated).length;
|
|
294
702
|
if (changedCount) {
|
|
703
|
+
if (!quiet && !shouldAutoYes(args)) {
|
|
704
|
+
const { diff, summary } = renderFileMapDiff(updated, currentFiles);
|
|
705
|
+
log(`sync would change ${changedCount} file(s):`);
|
|
706
|
+
for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
|
|
707
|
+
for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
|
|
708
|
+
for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
|
|
709
|
+
if (diff && !args.includes("--no-diff")) {
|
|
710
|
+
console.log("");
|
|
711
|
+
console.log(diff);
|
|
712
|
+
console.log("");
|
|
713
|
+
}
|
|
714
|
+
const ok = await confirmOrExit({ args, quiet, message: "Apply sync?", defaultYes: false });
|
|
715
|
+
if (!ok) {
|
|
716
|
+
log("aborted by user (no files changed). Re-run with --yes to skip this prompt.");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
295
720
|
writeFiles(target, updated);
|
|
296
721
|
if (!quiet) {
|
|
297
722
|
for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
|
|
@@ -302,6 +727,37 @@ async function cmdSync(target, args = []) {
|
|
|
302
727
|
log("already up to date");
|
|
303
728
|
}
|
|
304
729
|
|
|
730
|
+
// SPEC-028 Phase 2 (#2689) — scan managed docs for broken cross-refs after
|
|
731
|
+
// the layer is on disk. Warn-only unless --strict-links; --skip-link-check
|
|
732
|
+
// bypasses. Runs even when no files changed so stale local refs surface on
|
|
733
|
+
// every sync.
|
|
734
|
+
runDocLinkCheck(target, args, { quiet });
|
|
735
|
+
const envManifest = normalizeEnvironmentManifest(target);
|
|
736
|
+
if (!quiet && envManifest.changed) {
|
|
737
|
+
log("environment manifest target normalized: ai/manifest/environment.yaml");
|
|
738
|
+
}
|
|
739
|
+
await runMcpBootstrap(target, [...args, "--no-mcp-auth"]);
|
|
740
|
+
|
|
741
|
+
// Round-trip imported personas back to native agent dirs (Phase 2 of #2197).
|
|
742
|
+
// Currently supports claude-code only. Persona files marked
|
|
743
|
+
// `imported_from: "claude-code"` are mirrored into `.claude/agents/<name>.md`
|
|
744
|
+
// so users can edit either side without losing parity.
|
|
745
|
+
// Phase 2.5 (deferred): full export — generate agents for personas that
|
|
746
|
+
// were authored in 0dai but not imported. Tracked in #2197.
|
|
747
|
+
try {
|
|
748
|
+
const { syncImportedClaudeCodeAgents } = require("./import_claude_code_agents");
|
|
749
|
+
const rt = syncImportedClaudeCodeAgents(target, { dryRun });
|
|
750
|
+
if (!quiet && rt.written.length) {
|
|
751
|
+
log(`round-trip: wrote ${rt.written.length} claude-code agent file(s) from imported personas`);
|
|
752
|
+
for (const w of rt.written) {
|
|
753
|
+
const rel = path.relative(target, w.outPath).replace(/\\/g, "/");
|
|
754
|
+
console.log(` -> ${rel}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
} catch (err) {
|
|
758
|
+
if (!quiet) console.log(` ${D}round-trip skipped: ${err.message}${R}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
305
761
|
// --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
|
|
306
762
|
if (force && result.native_configs) {
|
|
307
763
|
const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
|
|
@@ -332,6 +788,7 @@ async function cmdSync(target, args = []) {
|
|
|
332
788
|
if (!quiet) {
|
|
333
789
|
console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
|
|
334
790
|
console.log(` project: ${boundProject.project_id || identity.project_id}`);
|
|
791
|
+
warnHooksPathDrift(target);
|
|
335
792
|
}
|
|
336
793
|
|
|
337
794
|
// Ensure ai/VERSION matches CLI version after successful sync
|
|
@@ -343,8 +800,10 @@ async function cmdSync(target, args = []) {
|
|
|
343
800
|
}
|
|
344
801
|
} catch {}
|
|
345
802
|
|
|
346
|
-
// Update portfolio registry
|
|
347
|
-
|
|
803
|
+
// Update portfolio registry (Phase 1.5 #2237: drift guard)
|
|
804
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
805
|
+
registerProjectOrExit(target, path.basename(target), stack);
|
|
806
|
+
}
|
|
348
807
|
await sendProjectHeartbeat(target, identity, result, {
|
|
349
808
|
project_id: boundProject.project_id || identity.project_id,
|
|
350
809
|
}).catch(() => {});
|
|
@@ -412,4 +871,21 @@ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
|
|
|
412
871
|
};
|
|
413
872
|
}
|
|
414
873
|
|
|
415
|
-
module.exports = {
|
|
874
|
+
module.exports = {
|
|
875
|
+
cmdInit,
|
|
876
|
+
cmdSync,
|
|
877
|
+
cmdProjectBind,
|
|
878
|
+
buildLocalSyncPreview,
|
|
879
|
+
runMcpBootstrap,
|
|
880
|
+
bindProjectForCloud,
|
|
881
|
+
cloudInitCheckpointPath,
|
|
882
|
+
writeCloudInitCheckpoint,
|
|
883
|
+
clearCloudInitCheckpoint,
|
|
884
|
+
buildCloudInitResumeCommand,
|
|
885
|
+
collectCurrentAiFiles,
|
|
886
|
+
hashFile,
|
|
887
|
+
detectRegistryDrift,
|
|
888
|
+
guardRegistryDrift,
|
|
889
|
+
runDocLinkCheck,
|
|
890
|
+
SYNC_FULL_CONTENT_LIMIT,
|
|
891
|
+
};
|