@0dai-dev/cli 4.2.0 → 4.3.4
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 +30 -5
- package/bin/0dai.js +289 -60
- package/lib/commands/audit.js +13 -0
- package/lib/commands/auth.js +341 -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 +20 -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 +440 -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 +44 -4
- 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 +26 -1
- package/lib/commands/swarm.js +97 -4
- package/lib/commands/tui.js +81 -13
- 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/tui/index.mjs +571 -187
- package/lib/utils/auth.js +1 -0
- package/lib/utils/canonical-counts.js +54 -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 +37 -2
- 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 +2 -2
package/lib/commands/init.js
CHANGED
|
@@ -1,17 +1,147 @@
|
|
|
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");
|
|
13
18
|
|
|
14
19
|
const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
|
|
20
|
+
const SYNC_FULL_CONTENT_LIMIT = 10000;
|
|
21
|
+
const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
|
|
22
|
+
|
|
23
|
+
function hashFile(filePath) {
|
|
24
|
+
const hash = crypto.createHash("sha256");
|
|
25
|
+
const fd = fs.openSync(filePath, "r");
|
|
26
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
27
|
+
try {
|
|
28
|
+
let bytesRead = 0;
|
|
29
|
+
do {
|
|
30
|
+
bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
31
|
+
if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
|
|
32
|
+
} while (bytesRead > 0);
|
|
33
|
+
} finally {
|
|
34
|
+
fs.closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
return hash.digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function describeCurrentFile(filePath, stat) {
|
|
40
|
+
if (stat.size < SYNC_FULL_CONTENT_LIMIT) return fs.readFileSync(filePath, "utf8");
|
|
41
|
+
return {
|
|
42
|
+
size: stat.size,
|
|
43
|
+
sha256: hashFile(filePath),
|
|
44
|
+
compare: "hash-only",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// detectRegistryDrift — Phase 1.5 of #2237 registry self-heal.
|
|
49
|
+
// Returns { drifted: bool, existingPath?: string, currentPath: string } when an
|
|
50
|
+
// entry with the same `name` is already in ~/.0dai/projects.json but points at
|
|
51
|
+
// a different directory than the one we're about to register. Caller decides
|
|
52
|
+
// what to do (warn, prompt, or auto-overwrite based on flags).
|
|
53
|
+
function detectRegistryDrift(target, name) {
|
|
54
|
+
const currentPath = path.resolve(target);
|
|
55
|
+
try {
|
|
56
|
+
if (!fs.existsSync(PROJECTS_FILE)) return { drifted: false, currentPath };
|
|
57
|
+
const raw = fs.readFileSync(PROJECTS_FILE, "utf8");
|
|
58
|
+
const data = JSON.parse(raw);
|
|
59
|
+
const projects = Array.isArray(data && data.projects) ? data.projects : [];
|
|
60
|
+
for (const entry of projects) {
|
|
61
|
+
if (!entry || typeof entry !== "object") continue;
|
|
62
|
+
if (entry.name !== name) continue;
|
|
63
|
+
if (!entry.path || typeof entry.path !== "string") continue;
|
|
64
|
+
if (entry.path === currentPath) continue;
|
|
65
|
+
return { drifted: true, existingPath: entry.path, currentPath, archived: entry.archived === true };
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
return { drifted: false, currentPath };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// guardRegistryDrift — returns true when caller may proceed with registration,
|
|
72
|
+
// false if the operator declined the overwrite. Async to support interactive
|
|
73
|
+
// prompt; honours --non-interactive (and --yes / common CI flags) by
|
|
74
|
+
// auto-overwriting silently. ODAI_REGISTRY_DRIFT_OVERWRITE=1 also bypasses
|
|
75
|
+
// the prompt for scripted runs.
|
|
76
|
+
async function guardRegistryDrift(target, name, args) {
|
|
77
|
+
const drift = detectRegistryDrift(target, name);
|
|
78
|
+
if (!drift.drifted) return true;
|
|
79
|
+
// Archived entries are not a real conflict — registry_audit.py archives
|
|
80
|
+
// missing paths; let init silently overwrite them.
|
|
81
|
+
if (drift.archived) return true;
|
|
82
|
+
|
|
83
|
+
const nonInteractive =
|
|
84
|
+
args.includes("--non-interactive") ||
|
|
85
|
+
args.includes("--yes") ||
|
|
86
|
+
args.includes("-y") ||
|
|
87
|
+
!process.stdout.isTTY ||
|
|
88
|
+
process.env.ODAI_REGISTRY_DRIFT_OVERWRITE === "1";
|
|
89
|
+
|
|
90
|
+
log(`${W}registry drift: project '${name}' is registered at ${drift.existingPath}${R}`);
|
|
91
|
+
console.log(` current cwd: ${drift.currentPath}`);
|
|
92
|
+
if (nonInteractive) {
|
|
93
|
+
console.log(` ${D}auto-overwriting registry entry (non-interactive mode)${R}`);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const readline = require("readline");
|
|
99
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
100
|
+
const answer = await new Promise((resolve) => {
|
|
101
|
+
rl.question(` Overwrite registry entry to point at ${drift.currentPath}? [y/N] `, (a) => {
|
|
102
|
+
rl.close();
|
|
103
|
+
resolve(String(a || "").trim().toLowerCase());
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
if (answer === "y" || answer === "yes") return true;
|
|
107
|
+
} catch {
|
|
108
|
+
// readline failed (e.g. no TTY) — fall through to skip
|
|
109
|
+
}
|
|
110
|
+
console.log(
|
|
111
|
+
` ${D}skipping registry update. Set ODAI_REGISTRY_DRIFT_OVERWRITE=1 or pass --non-interactive to overwrite.${R}`,
|
|
112
|
+
);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function registerProjectOrExit(target, name, stack) {
|
|
117
|
+
try {
|
|
118
|
+
return registerProject(target, name, stack, CONFIG_DIR, PROJECTS_FILE);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log(`error: failed to update project registry: ${err.message}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collectCurrentAiFiles(target) {
|
|
126
|
+
const currentFiles = {};
|
|
127
|
+
const aiDir = path.join(target, "ai");
|
|
128
|
+
if (fs.existsSync(aiDir)) {
|
|
129
|
+
const walk = (dir) => {
|
|
130
|
+
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
131
|
+
const p = path.join(dir, f.name);
|
|
132
|
+
if (f.isDirectory()) walk(p);
|
|
133
|
+
else {
|
|
134
|
+
try {
|
|
135
|
+
const stat = fs.statSync(p);
|
|
136
|
+
if (stat.isFile()) currentFiles[path.relative(target, p)] = describeCurrentFile(p, stat);
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
walk(aiDir);
|
|
142
|
+
}
|
|
143
|
+
return currentFiles;
|
|
144
|
+
}
|
|
15
145
|
|
|
16
146
|
// bindProjectForCloud — binds project to cloud via /v1/projects/bind
|
|
17
147
|
async function bindProjectForCloud(target, metadata, identity) {
|
|
@@ -32,14 +162,138 @@ async function bindProjectForCloud(target, metadata, identity) {
|
|
|
32
162
|
};
|
|
33
163
|
}
|
|
34
164
|
|
|
165
|
+
function cloudInitCheckpointPath(target) {
|
|
166
|
+
return path.join(target, CLOUD_INIT_CHECKPOINT_REL);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _hasAuthToken() {
|
|
170
|
+
const auth = loadAuthState();
|
|
171
|
+
return !!(auth && (auth.api_key || auth.access_token || auth.token));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _quoteCommandArg(value) {
|
|
175
|
+
const text = String(value || "");
|
|
176
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
|
|
177
|
+
return `'${text.replace(/'/g, "'\"'\"'")}'`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _initCommandName(args = []) {
|
|
181
|
+
const first = String((Array.isArray(args) ? args : [])[0] || "");
|
|
182
|
+
return first === "init-existing" ? "init-existing" : "init";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _resumeArgs(args = []) {
|
|
186
|
+
const raw = Array.isArray(args) ? args : [];
|
|
187
|
+
const withoutCommand = raw[0] === "init" || raw[0] === "init-existing" ? raw.slice(1) : raw.slice();
|
|
188
|
+
const skipWithValue = new Set(["--auth-code", "--oauth-code", "--exchange-code", "--code", "--activation-code", "--redeem-code", "--plan-code"]);
|
|
189
|
+
const filtered = [];
|
|
190
|
+
for (let i = 0; i < withoutCommand.length; i++) {
|
|
191
|
+
const arg = String(withoutCommand[i] || "");
|
|
192
|
+
if (!arg || arg === "--resume") continue;
|
|
193
|
+
if (skipWithValue.has(arg)) { i++; continue; }
|
|
194
|
+
if ([...skipWithValue].some((name) => arg.startsWith(`${name}=`))) continue;
|
|
195
|
+
filtered.push(arg);
|
|
196
|
+
}
|
|
197
|
+
return filtered;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildCloudInitResumeCommand(target, args = []) {
|
|
201
|
+
const parts = ["0dai", _initCommandName(args), "--target", _quoteCommandArg(path.resolve(target)), "--resume"];
|
|
202
|
+
for (const arg of _resumeArgs(args)) parts.push(_quoteCommandArg(arg));
|
|
203
|
+
return parts.join(" ");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function writeCloudInitCheckpoint(target, details = {}) {
|
|
207
|
+
const checkpointPath = cloudInitCheckpointPath(target);
|
|
208
|
+
const checkpoint = {
|
|
209
|
+
version: 1,
|
|
210
|
+
command: _initCommandName(details.args || []),
|
|
211
|
+
stage: details.stage || "auth_required",
|
|
212
|
+
reason: details.reason || "missing_auth",
|
|
213
|
+
target: path.resolve(target),
|
|
214
|
+
created_at: new Date().toISOString(),
|
|
215
|
+
next_command: "0dai auth login --device --no-browser",
|
|
216
|
+
resume_command: buildCloudInitResumeCommand(target, details.args || []),
|
|
217
|
+
};
|
|
218
|
+
fs.mkdirSync(path.dirname(checkpointPath), { recursive: true, mode: 0o700 });
|
|
219
|
+
fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", { mode: 0o600 });
|
|
220
|
+
return checkpoint;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function clearCloudInitCheckpoint(target) {
|
|
224
|
+
try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function maybePauseCloudInitForAuth(target, args = [], options = {}) {
|
|
228
|
+
if (options.dryRun || options.localMode) return false;
|
|
229
|
+
if (_hasAuthToken()) return false;
|
|
230
|
+
const parsed = parseActivationArgs(args, { genericCode: "activation" });
|
|
231
|
+
if (parsed.authCode) return false;
|
|
232
|
+
if (process.stdout.isTTY && process.stdin.isTTY) return false;
|
|
233
|
+
|
|
234
|
+
const checkpoint = writeCloudInitCheckpoint(target, {
|
|
235
|
+
args,
|
|
236
|
+
stage: "auth_required",
|
|
237
|
+
reason: "missing_auth",
|
|
238
|
+
});
|
|
239
|
+
log("authentication required for init");
|
|
240
|
+
console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
|
|
241
|
+
console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
|
|
242
|
+
console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function cmdProjectBind(target, args = []) {
|
|
247
|
+
const json = args.includes("--json");
|
|
248
|
+
const authStatus = await ensureAuthenticated("project bind");
|
|
249
|
+
const license = await ensureLicenseActivation();
|
|
250
|
+
const metadata = collectMetadata(target);
|
|
251
|
+
const identity = buildProjectIdentity(target, metadata);
|
|
252
|
+
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
253
|
+
const stack = boundProject.stack || identity.stack || "unknown";
|
|
254
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
255
|
+
registerProjectOrExit(target, path.basename(target), stack);
|
|
256
|
+
}
|
|
257
|
+
const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
|
|
258
|
+
project_id: boundProject.project_id || identity.project_id,
|
|
259
|
+
}).catch((err) => ({ error: err.message || String(err) }));
|
|
260
|
+
const payload = {
|
|
261
|
+
ok: true,
|
|
262
|
+
account: {
|
|
263
|
+
email: authStatus.email || null,
|
|
264
|
+
plan: authStatus.plan || license.plan || "free",
|
|
265
|
+
activation: license.status || "active",
|
|
266
|
+
},
|
|
267
|
+
project: {
|
|
268
|
+
project_id: boundProject.project_id || identity.project_id,
|
|
269
|
+
name: boundProject.name || identity.project_name,
|
|
270
|
+
stack,
|
|
271
|
+
binding_status: boundProject.binding_status || "bound",
|
|
272
|
+
},
|
|
273
|
+
heartbeat: !(heartbeat && heartbeat.error),
|
|
274
|
+
};
|
|
275
|
+
if (json) {
|
|
276
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
277
|
+
} else {
|
|
278
|
+
log("project bound");
|
|
279
|
+
console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
|
|
280
|
+
console.log(` project: ${payload.project.project_id}`);
|
|
281
|
+
if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
|
|
282
|
+
}
|
|
283
|
+
return payload;
|
|
284
|
+
}
|
|
285
|
+
|
|
35
286
|
async function cmdInit(target, args = []) {
|
|
36
287
|
const dryRun = args.includes("--dry-run");
|
|
37
288
|
const minimal = args.includes("--minimal");
|
|
38
289
|
const noWizard = args.includes("--no-wizard");
|
|
290
|
+
const localMode = args.includes("--local");
|
|
39
291
|
|
|
40
292
|
if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
|
|
41
293
|
const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
|
|
42
294
|
log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
|
|
295
|
+
if (args.includes("--resume")) clearCloudInitCheckpoint(target);
|
|
296
|
+
if (!dryRun) await runMcpBootstrap(target, args);
|
|
43
297
|
return;
|
|
44
298
|
}
|
|
45
299
|
|
|
@@ -58,12 +312,13 @@ async function cmdInit(target, args = []) {
|
|
|
58
312
|
}
|
|
59
313
|
|
|
60
314
|
// First-run wizard (unless --no-wizard or non-interactive)
|
|
61
|
-
if (!noWizard && !dryRun && !minimal) {
|
|
315
|
+
if (!noWizard && !dryRun && !minimal && !localMode) {
|
|
62
316
|
try {
|
|
63
317
|
const { runWizard, isInteractive } = require("../wizard");
|
|
64
318
|
if (isInteractive()) {
|
|
65
319
|
const result = await runWizard(target);
|
|
66
320
|
if (result.completed) {
|
|
321
|
+
await runMcpBootstrap(target, args);
|
|
67
322
|
try {
|
|
68
323
|
const ob = require("../onboarding");
|
|
69
324
|
ob.trackFirstInit(target);
|
|
@@ -71,9 +326,25 @@ async function cmdInit(target, args = []) {
|
|
|
71
326
|
} catch {}
|
|
72
327
|
return;
|
|
73
328
|
}
|
|
329
|
+
if (result.cancelled) return;
|
|
74
330
|
}
|
|
75
331
|
} catch {}
|
|
76
332
|
}
|
|
333
|
+
if (localMode) {
|
|
334
|
+
const { runWizard } = require("../wizard");
|
|
335
|
+
const result = await runWizard(target, { forceLocal: true });
|
|
336
|
+
if (result.completed) {
|
|
337
|
+
await runMcpBootstrap(target, args);
|
|
338
|
+
try {
|
|
339
|
+
const ob = require("../onboarding");
|
|
340
|
+
ob.trackFirstInit(target);
|
|
341
|
+
ob.showWhatsNext("local", false);
|
|
342
|
+
} catch {}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
maybePauseCloudInitForAuth(target, args, { dryRun, localMode });
|
|
77
348
|
|
|
78
349
|
const isTTY = process.stdout.isTTY;
|
|
79
350
|
let spinner = null;
|
|
@@ -83,7 +354,7 @@ async function cmdInit(target, args = []) {
|
|
|
83
354
|
|
|
84
355
|
const metadata = collectMetadata(target);
|
|
85
356
|
const { projectFiles, manifestContents, clis } = metadata;
|
|
86
|
-
const authStatus = await
|
|
357
|
+
const authStatus = await ensureAccountForActivation("init", args);
|
|
87
358
|
const license = await ensureLicenseActivation();
|
|
88
359
|
const identity = buildProjectIdentity(target, metadata);
|
|
89
360
|
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
@@ -123,6 +394,11 @@ async function cmdInit(target, args = []) {
|
|
|
123
394
|
return;
|
|
124
395
|
}
|
|
125
396
|
writeFiles(target, result.files || {});
|
|
397
|
+
const envManifest = normalizeEnvironmentManifest(target);
|
|
398
|
+
if (envManifest.changed) {
|
|
399
|
+
log("environment manifest target normalized: ai/manifest/environment.yaml");
|
|
400
|
+
}
|
|
401
|
+
await runMcpBootstrap(target, args);
|
|
126
402
|
|
|
127
403
|
// Ensure ai/VERSION matches CLI version
|
|
128
404
|
const versionFile = path.join(target, "ai", "VERSION");
|
|
@@ -136,8 +412,15 @@ async function cmdInit(target, args = []) {
|
|
|
136
412
|
if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
|
|
137
413
|
} catch {}
|
|
138
414
|
|
|
139
|
-
|
|
140
|
-
|
|
415
|
+
const gitPolicy = ensureGithubFlowPolicy(target);
|
|
416
|
+
if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
|
|
417
|
+
console.log(` ${D}branch protection: ${gitPolicy.protection.reason}${R}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Register in global portfolio (Phase 1.5 #2237: drift guard)
|
|
421
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
422
|
+
registerProjectOrExit(target, path.basename(target), result.stack);
|
|
423
|
+
}
|
|
141
424
|
|
|
142
425
|
log(`initialized (${result.file_count || "?"} files)`);
|
|
143
426
|
console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
|
|
@@ -196,6 +479,82 @@ async function cmdInit(target, args = []) {
|
|
|
196
479
|
stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
|
|
197
480
|
_cli_version: VERSION, _files_generated: result.file_count || 0,
|
|
198
481
|
}}).catch(() => {});
|
|
482
|
+
clearCloudInitCheckpoint(target);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function runMcpBootstrap(target, args = []) {
|
|
486
|
+
const result = await bootstrapMcp(target, args, (message) => log(message));
|
|
487
|
+
if (!result.ok) {
|
|
488
|
+
log(`warn: MCP bootstrap skipped: ${result.warnings.join("; ")}`);
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
if (result.config) {
|
|
492
|
+
const verbs = [];
|
|
493
|
+
if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
|
|
494
|
+
if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
|
|
495
|
+
if (result.config.changed) {
|
|
496
|
+
log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (result.settings && result.settings.changed) {
|
|
500
|
+
log("Claude project MCP auto-load enabled: .claude/settings.json");
|
|
501
|
+
}
|
|
502
|
+
if (result.auth) {
|
|
503
|
+
if (result.auth.status === "preserved") {
|
|
504
|
+
log("MCP auth token preserved");
|
|
505
|
+
} else if (result.auth.status === "written") {
|
|
506
|
+
log(`MCP auth token stored: ${result.auth.tokenPath}`);
|
|
507
|
+
} else if (result.auth.status === "disabled") {
|
|
508
|
+
log("MCP cloud auth skipped (--no-mcp-auth)");
|
|
509
|
+
} else if (result.auth.status === "skipped") {
|
|
510
|
+
console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude.ai 0dai${R}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const warning of result.warnings || []) log(`warn: ${warning}`);
|
|
514
|
+
return result;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function normalizeEnvironmentManifest(target) {
|
|
518
|
+
const filePath = path.join(target, "ai", "manifest", "environment.yaml");
|
|
519
|
+
if (!fs.existsSync(filePath)) return { path: filePath, changed: false };
|
|
520
|
+
const original = fs.readFileSync(filePath, "utf8");
|
|
521
|
+
const lines = original.split(/\n/);
|
|
522
|
+
let inWorkspace = false;
|
|
523
|
+
let sawTarget = false;
|
|
524
|
+
let sawCwd = false;
|
|
525
|
+
const next = [];
|
|
526
|
+
for (const line of lines) {
|
|
527
|
+
if (/^\S/.test(line) && !line.startsWith("workspace:")) inWorkspace = false;
|
|
528
|
+
if (line.trim() === "workspace:") {
|
|
529
|
+
inWorkspace = true;
|
|
530
|
+
next.push(line);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (inWorkspace && /^ target:\s*/.test(line)) {
|
|
534
|
+
next.push(" target: .");
|
|
535
|
+
sawTarget = true;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (inWorkspace && /^ cwd:\s*/.test(line)) {
|
|
539
|
+
next.push(" cwd: .");
|
|
540
|
+
sawCwd = true;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (inWorkspace && /^available_clis:\s*$/.test(line)) {
|
|
544
|
+
if (!sawTarget) next.push(" target: .");
|
|
545
|
+
if (!sawCwd) next.push(" cwd: .");
|
|
546
|
+
inWorkspace = false;
|
|
547
|
+
}
|
|
548
|
+
next.push(line);
|
|
549
|
+
}
|
|
550
|
+
if (inWorkspace) {
|
|
551
|
+
if (!sawTarget) next.push(" target: .");
|
|
552
|
+
if (!sawCwd) next.push(" cwd: .");
|
|
553
|
+
}
|
|
554
|
+
const rendered = next.join("\n");
|
|
555
|
+
if (rendered === original) return { path: filePath, changed: false };
|
|
556
|
+
fs.writeFileSync(filePath, rendered, "utf8");
|
|
557
|
+
return { path: filePath, changed: true };
|
|
199
558
|
}
|
|
200
559
|
|
|
201
560
|
async function cmdSync(target, args = []) {
|
|
@@ -240,24 +599,9 @@ async function cmdSync(target, args = []) {
|
|
|
240
599
|
const license = await ensureLicenseActivation();
|
|
241
600
|
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
242
601
|
|
|
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
|
-
}
|
|
602
|
+
// Collect current ai/ files. Small files send content; larger files send
|
|
603
|
+
// hash descriptors so the server can compare without a full upload.
|
|
604
|
+
const currentFiles = collectCurrentAiFiles(target);
|
|
261
605
|
|
|
262
606
|
if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
|
|
263
607
|
if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
|
|
@@ -284,7 +628,14 @@ async function cmdSync(target, args = []) {
|
|
|
284
628
|
const files = Object.keys(updated);
|
|
285
629
|
if (files.length) {
|
|
286
630
|
log(`${D}dry-run: would update ${files.length} file(s):${R}`);
|
|
287
|
-
|
|
631
|
+
const { diff, summary } = renderFileMapDiff(updated, currentFiles);
|
|
632
|
+
for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
|
|
633
|
+
for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
|
|
634
|
+
for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
|
|
635
|
+
if (diff && !args.includes("--no-diff")) {
|
|
636
|
+
console.log("");
|
|
637
|
+
console.log(diff);
|
|
638
|
+
}
|
|
288
639
|
} else {
|
|
289
640
|
log(`${D}dry-run: nothing to update${R}`);
|
|
290
641
|
}
|
|
@@ -292,6 +643,23 @@ async function cmdSync(target, args = []) {
|
|
|
292
643
|
}
|
|
293
644
|
const changedCount = Object.keys(updated).length;
|
|
294
645
|
if (changedCount) {
|
|
646
|
+
if (!quiet && !shouldAutoYes(args)) {
|
|
647
|
+
const { diff, summary } = renderFileMapDiff(updated, currentFiles);
|
|
648
|
+
log(`sync would change ${changedCount} file(s):`);
|
|
649
|
+
for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
|
|
650
|
+
for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
|
|
651
|
+
for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
|
|
652
|
+
if (diff && !args.includes("--no-diff")) {
|
|
653
|
+
console.log("");
|
|
654
|
+
console.log(diff);
|
|
655
|
+
console.log("");
|
|
656
|
+
}
|
|
657
|
+
const ok = await confirmOrExit({ args, quiet, message: "Apply sync?", defaultYes: false });
|
|
658
|
+
if (!ok) {
|
|
659
|
+
log("aborted by user (no files changed). Re-run with --yes to skip this prompt.");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
295
663
|
writeFiles(target, updated);
|
|
296
664
|
if (!quiet) {
|
|
297
665
|
for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
|
|
@@ -301,6 +669,31 @@ async function cmdSync(target, args = []) {
|
|
|
301
669
|
} else {
|
|
302
670
|
log("already up to date");
|
|
303
671
|
}
|
|
672
|
+
const envManifest = normalizeEnvironmentManifest(target);
|
|
673
|
+
if (!quiet && envManifest.changed) {
|
|
674
|
+
log("environment manifest target normalized: ai/manifest/environment.yaml");
|
|
675
|
+
}
|
|
676
|
+
await runMcpBootstrap(target, [...args, "--no-mcp-auth"]);
|
|
677
|
+
|
|
678
|
+
// Round-trip imported personas back to native agent dirs (Phase 2 of #2197).
|
|
679
|
+
// Currently supports claude-code only. Persona files marked
|
|
680
|
+
// `imported_from: "claude-code"` are mirrored into `.claude/agents/<name>.md`
|
|
681
|
+
// so users can edit either side without losing parity.
|
|
682
|
+
// Phase 2.5 (deferred): full export — generate agents for personas that
|
|
683
|
+
// were authored in 0dai but not imported. Tracked in #2197.
|
|
684
|
+
try {
|
|
685
|
+
const { syncImportedClaudeCodeAgents } = require("./import_claude_code_agents");
|
|
686
|
+
const rt = syncImportedClaudeCodeAgents(target, { dryRun });
|
|
687
|
+
if (!quiet && rt.written.length) {
|
|
688
|
+
log(`round-trip: wrote ${rt.written.length} claude-code agent file(s) from imported personas`);
|
|
689
|
+
for (const w of rt.written) {
|
|
690
|
+
const rel = path.relative(target, w.outPath).replace(/\\/g, "/");
|
|
691
|
+
console.log(` -> ${rel}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch (err) {
|
|
695
|
+
if (!quiet) console.log(` ${D}round-trip skipped: ${err.message}${R}`);
|
|
696
|
+
}
|
|
304
697
|
|
|
305
698
|
// --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
|
|
306
699
|
if (force && result.native_configs) {
|
|
@@ -332,6 +725,7 @@ async function cmdSync(target, args = []) {
|
|
|
332
725
|
if (!quiet) {
|
|
333
726
|
console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
|
|
334
727
|
console.log(` project: ${boundProject.project_id || identity.project_id}`);
|
|
728
|
+
warnHooksPathDrift(target);
|
|
335
729
|
}
|
|
336
730
|
|
|
337
731
|
// Ensure ai/VERSION matches CLI version after successful sync
|
|
@@ -343,8 +737,10 @@ async function cmdSync(target, args = []) {
|
|
|
343
737
|
}
|
|
344
738
|
} catch {}
|
|
345
739
|
|
|
346
|
-
// Update portfolio registry
|
|
347
|
-
|
|
740
|
+
// Update portfolio registry (Phase 1.5 #2237: drift guard)
|
|
741
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
742
|
+
registerProjectOrExit(target, path.basename(target), stack);
|
|
743
|
+
}
|
|
348
744
|
await sendProjectHeartbeat(target, identity, result, {
|
|
349
745
|
project_id: boundProject.project_id || identity.project_id,
|
|
350
746
|
}).catch(() => {});
|
|
@@ -412,4 +808,20 @@ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
|
|
|
412
808
|
};
|
|
413
809
|
}
|
|
414
810
|
|
|
415
|
-
module.exports = {
|
|
811
|
+
module.exports = {
|
|
812
|
+
cmdInit,
|
|
813
|
+
cmdSync,
|
|
814
|
+
cmdProjectBind,
|
|
815
|
+
buildLocalSyncPreview,
|
|
816
|
+
runMcpBootstrap,
|
|
817
|
+
bindProjectForCloud,
|
|
818
|
+
cloudInitCheckpointPath,
|
|
819
|
+
writeCloudInitCheckpoint,
|
|
820
|
+
clearCloudInitCheckpoint,
|
|
821
|
+
buildCloudInitResumeCommand,
|
|
822
|
+
collectCurrentAiFiles,
|
|
823
|
+
hashFile,
|
|
824
|
+
detectRegistryDrift,
|
|
825
|
+
guardRegistryDrift,
|
|
826
|
+
SYNC_FULL_CONTENT_LIMIT,
|
|
827
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `0dai loop ack` — operator acknowledgement stub for the proactive loop
|
|
5
|
+
* (issue #2235).
|
|
6
|
+
*
|
|
7
|
+
* This is intentionally a sketch. v1 only writes one JSON line to
|
|
8
|
+
* `ai/meta/telemetry/operator-ack.jsonl`. There is no server round-trip,
|
|
9
|
+
* no signed token, no rate-limit. The dead man's switch (Python side,
|
|
10
|
+
* `scripts/dead_man_switch.py`) reads that file and pauses the loop if
|
|
11
|
+
* the most recent ack is older than 24h.
|
|
12
|
+
*
|
|
13
|
+
* TODO (v1.1): authenticate the ack against the 0dai control plane so an
|
|
14
|
+
* ack from a stolen developer machine cannot un-pause the loop.
|
|
15
|
+
* TODO (v1.1): print the most recent ack timestamp + the time until the
|
|
16
|
+
* dead-man would trip again, so the operator sees the runway.
|
|
17
|
+
* TODO (v1.x): expose `0dai loop status` and `0dai loop pause`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const shared = require("../shared");
|
|
21
|
+
const { log, T, R, D, fs, path } = shared;
|
|
22
|
+
|
|
23
|
+
function nowIso() {
|
|
24
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function detectOpId() {
|
|
28
|
+
// Best-effort identity. We do not exfiltrate this anywhere — it stays in a
|
|
29
|
+
// local jsonl. Order: $ODAI_OP_ID, $USER, $LOGNAME, "unknown".
|
|
30
|
+
return process.env.ODAI_OP_ID
|
|
31
|
+
|| process.env.USER
|
|
32
|
+
|| process.env.LOGNAME
|
|
33
|
+
|| "unknown";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ackPath(target) {
|
|
37
|
+
return path.join(target, "ai", "meta", "telemetry", "operator-ack.jsonl");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cmdLoopAck(target, args) {
|
|
41
|
+
const noteIdx = args.indexOf("--note");
|
|
42
|
+
const note = noteIdx >= 0 && args[noteIdx + 1] ? args[noteIdx + 1] : "";
|
|
43
|
+
|
|
44
|
+
const file = ackPath(target);
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
log(`error: cannot create telemetry dir: ${e.message}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const entry = { ts: nowIso(), op_id: detectOpId() };
|
|
53
|
+
if (note) entry.note = note.slice(0, 240);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
57
|
+
} catch (e) {
|
|
58
|
+
log(`error: cannot write ack: ${e.message}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
log(`${T}loop ack${R} recorded at ${entry.ts} (op_id=${entry.op_id})`);
|
|
63
|
+
console.log(` ${D}wrote ${file}${R}`);
|
|
64
|
+
console.log(` ${D}TODO v1.1: server-side ack + signed token${R}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function cmdLoopStatus(target) {
|
|
68
|
+
const file = ackPath(target);
|
|
69
|
+
if (!fs.existsSync(file)) {
|
|
70
|
+
log("no ack history yet — run `0dai loop ack` to start the dead-man clock");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let lastTs = null;
|
|
74
|
+
try {
|
|
75
|
+
const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
|
|
76
|
+
for (const ln of lines) {
|
|
77
|
+
try {
|
|
78
|
+
const row = JSON.parse(ln);
|
|
79
|
+
if (row && row.ts) lastTs = row.ts;
|
|
80
|
+
} catch { /* ignore malformed */ }
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log(`error: cannot read ack file: ${e.message}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
if (!lastTs) {
|
|
87
|
+
log("ack file present but no parsable rows");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const ageMs = Date.now() - new Date(lastTs).getTime();
|
|
91
|
+
const ageHours = (ageMs / 3_600_000).toFixed(1);
|
|
92
|
+
log(`last ack: ${lastTs} (${ageHours}h ago)`);
|
|
93
|
+
console.log(` ${D}dead-man trips at 24h since last ack${R}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cmdLoop(target, sub, args) {
|
|
97
|
+
const subArgs = args.slice(2);
|
|
98
|
+
if (sub === "ack") return cmdLoopAck(target, subArgs);
|
|
99
|
+
if (sub === "status") return cmdLoopStatus(target);
|
|
100
|
+
console.log("Usage: 0dai loop [ack [--note '...'] | status]");
|
|
101
|
+
console.log("");
|
|
102
|
+
console.log(" ack Refresh dead-man-switch operator ack timestamp");
|
|
103
|
+
console.log(" status Show last ack and runway until dead-man trips");
|
|
104
|
+
console.log("");
|
|
105
|
+
console.log(` ${D}see ai/docs/proactive-loop-v1.md for the full design${R}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { cmdLoop, cmdLoopAck, cmdLoopStatus };
|