@0dai-dev/cli 4.1.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 +308 -60
- package/lib/commands/audit.js +13 -0
- package/lib/commands/auth.js +404 -122
- 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 +79 -14
- 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 +553 -53
- package/lib/commands/loop.js +108 -0
- package/lib/commands/mcp.js +410 -0
- package/lib/commands/models.js +42 -12
- package/lib/commands/paste.js +114 -0
- package/lib/commands/persona-simulate.js +19 -0
- package/lib/commands/play.js +173 -0
- package/lib/commands/provider.js +87 -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/ssh.js +416 -0
- package/lib/commands/standup.js +40 -0
- package/lib/commands/status.js +131 -36
- package/lib/commands/swarm.js +97 -4
- package/lib/commands/tui.js +117 -0
- package/lib/commands/usage.js +87 -0
- package/lib/commands/vault.js +246 -0
- package/lib/commands/workspace.js +1 -0
- package/lib/onboarding.js +30 -10
- package/lib/shared.js +153 -96
- package/lib/tui/index.mjs +34994 -0
- 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/model_ratings.js +77 -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 +13 -5
- package/scripts/build-tui.js +77 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const shared = require("../shared");
|
|
4
|
+
const { https, http, fs, path, os, API_URL, AUTH_FILE, log } = shared;
|
|
5
|
+
const { cmdAuthLogin } = require("./auth");
|
|
6
|
+
|
|
7
|
+
const BEGIN_MARKER = "# BEGIN 0dai managed";
|
|
8
|
+
const END_MARKER = "# END 0dai managed";
|
|
9
|
+
|
|
10
|
+
function _apiRequest(method, endpoint, payload, extraHeaders = {}) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const url = new URL(endpoint, API_URL);
|
|
13
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
14
|
+
const body = payload === undefined ? null : JSON.stringify(payload);
|
|
15
|
+
const headers = {
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
"User-Agent": "0dai-cli/ssh",
|
|
18
|
+
...extraHeaders,
|
|
19
|
+
};
|
|
20
|
+
if (body !== null) {
|
|
21
|
+
headers["Content-Type"] = "application/json";
|
|
22
|
+
headers["Content-Length"] = Buffer.byteLength(body);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
|
|
26
|
+
const token = auth.api_key || auth.access_token || auth.token;
|
|
27
|
+
if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
const req = mod.request(
|
|
31
|
+
{
|
|
32
|
+
hostname: url.hostname,
|
|
33
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
34
|
+
path: url.pathname,
|
|
35
|
+
method,
|
|
36
|
+
headers,
|
|
37
|
+
timeout: 60000,
|
|
38
|
+
},
|
|
39
|
+
(res) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
42
|
+
res.on("end", () => {
|
|
43
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
44
|
+
let parsed = {};
|
|
45
|
+
try {
|
|
46
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
47
|
+
} catch {
|
|
48
|
+
parsed = { error: raw || `HTTP ${res.statusCode}` };
|
|
49
|
+
}
|
|
50
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
51
|
+
resolve(parsed);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
reject(new Error(parsed.error || parsed.message || `HTTP ${res.statusCode}`));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
req.on("error", (err) => reject(err));
|
|
59
|
+
req.on("timeout", () => {
|
|
60
|
+
req.destroy();
|
|
61
|
+
reject(new Error("request timed out after 60s"));
|
|
62
|
+
});
|
|
63
|
+
if (body !== null) req.write(body);
|
|
64
|
+
req.end();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function _ensureAuthenticated() {
|
|
69
|
+
let authed = false;
|
|
70
|
+
try {
|
|
71
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
|
|
72
|
+
authed = Boolean(auth?.api_key || auth?.access_token || auth?.token);
|
|
73
|
+
} catch {}
|
|
74
|
+
if (!authed) {
|
|
75
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
76
|
+
await cmdAuthLogin();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
throw new Error("authentication required — run '0dai auth login' first");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _parseArgs(args) {
|
|
84
|
+
const options = {
|
|
85
|
+
name: "",
|
|
86
|
+
file: "",
|
|
87
|
+
publicKey: "",
|
|
88
|
+
keyId: "",
|
|
89
|
+
grantId: "",
|
|
90
|
+
hostId: "",
|
|
91
|
+
environment: "",
|
|
92
|
+
userEmail: "",
|
|
93
|
+
unixUser: "",
|
|
94
|
+
tokenEnv: "ODAI_SSH_SYNC_TOKEN",
|
|
95
|
+
homeRoot: "",
|
|
96
|
+
json: false,
|
|
97
|
+
apply: false,
|
|
98
|
+
};
|
|
99
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
100
|
+
const arg = args[i];
|
|
101
|
+
if (arg === "--name" && args[i + 1]) {
|
|
102
|
+
options.name = args[++i];
|
|
103
|
+
} else if (arg === "--file" && args[i + 1]) {
|
|
104
|
+
options.file = args[++i];
|
|
105
|
+
} else if (arg === "--public-key" && args[i + 1]) {
|
|
106
|
+
options.publicKey = args[++i];
|
|
107
|
+
} else if (arg === "--key-id" && args[i + 1]) {
|
|
108
|
+
options.keyId = args[++i];
|
|
109
|
+
} else if (arg === "--grant-id" && args[i + 1]) {
|
|
110
|
+
options.grantId = args[++i];
|
|
111
|
+
} else if (arg === "--host-id" && args[i + 1]) {
|
|
112
|
+
options.hostId = args[++i];
|
|
113
|
+
} else if (arg === "--env" && args[i + 1]) {
|
|
114
|
+
options.environment = args[++i];
|
|
115
|
+
} else if (arg === "--user-email" && args[i + 1]) {
|
|
116
|
+
options.userEmail = args[++i];
|
|
117
|
+
} else if (arg === "--unix-user" && args[i + 1]) {
|
|
118
|
+
options.unixUser = args[++i];
|
|
119
|
+
} else if (arg === "--token-env" && args[i + 1]) {
|
|
120
|
+
options.tokenEnv = args[++i];
|
|
121
|
+
} else if (arg === "--home-root" && args[i + 1]) {
|
|
122
|
+
options.homeRoot = args[++i];
|
|
123
|
+
} else if (arg === "--json") {
|
|
124
|
+
options.json = true;
|
|
125
|
+
} else if (arg === "--apply") {
|
|
126
|
+
options.apply = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return options;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _require(value, label) {
|
|
133
|
+
if (!value) throw new Error(`${label} required`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _readPublicKey(options) {
|
|
137
|
+
if (options.publicKey) return options.publicKey.trim();
|
|
138
|
+
if (options.file) return fs.readFileSync(options.file, "utf8").trim();
|
|
139
|
+
throw new Error("--file or --public-key required");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _printJson(payload) {
|
|
143
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _printKeys(keys) {
|
|
147
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
148
|
+
console.log("No SSH keys yet. Add one with: 0dai ssh key add --name laptop --file ~/.ssh/id_ed25519.pub");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
console.log(`SSH Keys (${keys.length})`);
|
|
152
|
+
console.log("ID Name Algorithm Status Fingerprint");
|
|
153
|
+
console.log("-".repeat(96));
|
|
154
|
+
for (const key of keys) {
|
|
155
|
+
console.log(
|
|
156
|
+
`${String(key.key_id || "").slice(0, 16).padEnd(16)} ` +
|
|
157
|
+
`${String(key.name || "").slice(0, 14).padEnd(14)} ` +
|
|
158
|
+
`${String(key.algorithm || "").slice(0, 25).padEnd(25)} ` +
|
|
159
|
+
`${String(key.status || "").padEnd(8)} ` +
|
|
160
|
+
`${key.fingerprint_sha256 || ""}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _printHosts(ownedHosts, accessibleHosts) {
|
|
166
|
+
console.log(`Owned Hosts (${ownedHosts.length})`);
|
|
167
|
+
if (!ownedHosts.length) {
|
|
168
|
+
console.log(" none");
|
|
169
|
+
} else {
|
|
170
|
+
for (const host of ownedHosts) {
|
|
171
|
+
console.log(` ${host.host_id} ${host.name} [${host.environment}] token:${host.token_hint || "—"} last-sync:${host.last_sync_at || "never"}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log(`Accessible Hosts (${accessibleHosts.length})`);
|
|
176
|
+
if (!accessibleHosts.length) {
|
|
177
|
+
console.log(" none");
|
|
178
|
+
} else {
|
|
179
|
+
for (const host of accessibleHosts) {
|
|
180
|
+
console.log(` ${host.name} unix:${host.unix_user} owner:${host.owner_email || "—"}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _printGrants(grants) {
|
|
186
|
+
if (!Array.isArray(grants) || grants.length === 0) {
|
|
187
|
+
console.log("No grants yet.");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log(`SSH Grants (${grants.length})`);
|
|
191
|
+
for (const grant of grants) {
|
|
192
|
+
console.log(` ${grant.grant_id} host:${grant.host_name || grant.host_id} user:${grant.user_email} unix:${grant.unix_user} ${grant.state}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _printAudit(audit) {
|
|
197
|
+
if (!Array.isArray(audit) || audit.length === 0) {
|
|
198
|
+
console.log("No SSH audit events yet.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
console.log(`SSH Audit (${audit.length})`);
|
|
202
|
+
for (const entry of audit.slice(-20).reverse()) {
|
|
203
|
+
console.log(` ${entry.created_at} ${entry.kind} ${entry.subject_id}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _resolveAuthorizedKeysPath(unixUser, homeRoot) {
|
|
208
|
+
const currentUser = process.env.USER || process.env.LOGNAME || "";
|
|
209
|
+
if (unixUser === currentUser) {
|
|
210
|
+
return path.join(os.homedir(), ".ssh", "authorized_keys");
|
|
211
|
+
}
|
|
212
|
+
const base = homeRoot || "/home";
|
|
213
|
+
return path.join(base, unixUser, ".ssh", "authorized_keys");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _mergeManagedBlock(existingText, managedText) {
|
|
217
|
+
const lines = String(existingText || "").split(/\r?\n/);
|
|
218
|
+
const kept = [];
|
|
219
|
+
let insideManaged = false;
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (line.trim() === BEGIN_MARKER) {
|
|
222
|
+
insideManaged = true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (line.trim() === END_MARKER) {
|
|
226
|
+
insideManaged = false;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!insideManaged) kept.push(line);
|
|
230
|
+
}
|
|
231
|
+
const unmanaged = kept.join("\n").trim();
|
|
232
|
+
return unmanaged ? `${unmanaged}\n\n${managedText}\n` : `${managedText}\n`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _buildManagedBlock(hostId, unixUser, authorizedKeys) {
|
|
236
|
+
const lines = [
|
|
237
|
+
BEGIN_MARKER,
|
|
238
|
+
`# host_id=${hostId} unix_user=${unixUser}`,
|
|
239
|
+
...authorizedKeys,
|
|
240
|
+
END_MARKER,
|
|
241
|
+
];
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _applySyncState(state, options) {
|
|
246
|
+
const results = [];
|
|
247
|
+
for (const entry of state.unix_users || []) {
|
|
248
|
+
const unixUser = entry.unix_user;
|
|
249
|
+
const filePath = _resolveAuthorizedKeysPath(unixUser, options.homeRoot);
|
|
250
|
+
const dirPath = path.dirname(filePath);
|
|
251
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
|
252
|
+
let existing = "";
|
|
253
|
+
if (fs.existsSync(filePath)) {
|
|
254
|
+
existing = fs.readFileSync(filePath, "utf8");
|
|
255
|
+
const backupPath = `${filePath}.bak.${Date.now()}`;
|
|
256
|
+
fs.copyFileSync(filePath, backupPath);
|
|
257
|
+
}
|
|
258
|
+
const nextContent = _mergeManagedBlock(
|
|
259
|
+
existing,
|
|
260
|
+
_buildManagedBlock(state.host.host_id, unixUser, entry.authorized_keys || [])
|
|
261
|
+
);
|
|
262
|
+
const tmpPath = `${filePath}.tmp`;
|
|
263
|
+
fs.writeFileSync(tmpPath, nextContent, { mode: 0o600 });
|
|
264
|
+
fs.renameSync(tmpPath, filePath);
|
|
265
|
+
results.push({ unix_user: unixUser, path: filePath, key_count: (entry.authorized_keys || []).length });
|
|
266
|
+
}
|
|
267
|
+
return results;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function cmdSsh(_target, sub, args) {
|
|
271
|
+
const entity = sub || "help";
|
|
272
|
+
const action = args[2] || "";
|
|
273
|
+
const options = _parseArgs(args);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
if (entity === "sync") {
|
|
277
|
+
_require(options.hostId, "--host-id");
|
|
278
|
+
const syncToken = String(process.env[options.tokenEnv] || "").trim();
|
|
279
|
+
if (!syncToken) throw new Error(`${options.tokenEnv} is empty`);
|
|
280
|
+
const state = await _apiRequest("POST", "/v1/ssh/sync", { host_id: options.hostId }, { "X-SSH-Sync-Token": syncToken });
|
|
281
|
+
if (options.json) {
|
|
282
|
+
_printJson(state);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
console.log(`SSH sync state ${state.state_version} for ${state.host.name}`);
|
|
286
|
+
for (const entry of state.unix_users || []) {
|
|
287
|
+
console.log(` ${entry.unix_user}: ${(entry.authorized_keys || []).length} keys`);
|
|
288
|
+
}
|
|
289
|
+
if (!options.apply) {
|
|
290
|
+
console.log("");
|
|
291
|
+
console.log("Dry run only. Re-run with --apply to update authorized_keys.");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const results = _applySyncState(state, options);
|
|
295
|
+
console.log("");
|
|
296
|
+
console.log("Applied managed authorized_keys blocks:");
|
|
297
|
+
for (const item of results) {
|
|
298
|
+
console.log(` ${item.unix_user} → ${item.path} (${item.key_count} keys)`);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await _ensureAuthenticated();
|
|
304
|
+
|
|
305
|
+
if (entity === "key") {
|
|
306
|
+
if (action === "add") {
|
|
307
|
+
const payload = { name: options.name, public_key: _readPublicKey(options) };
|
|
308
|
+
const result = await _apiRequest("POST", "/v1/ssh/keys/add", payload);
|
|
309
|
+
if (options.json) return _printJson(result);
|
|
310
|
+
console.log(`Added SSH key ${result.key.key_id}`);
|
|
311
|
+
console.log(` ${result.key.fingerprint_sha256}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (action === "revoke") {
|
|
315
|
+
_require(options.keyId, "--key-id");
|
|
316
|
+
const result = await _apiRequest("POST", "/v1/ssh/keys/revoke", { key_id: options.keyId });
|
|
317
|
+
if (options.json) return _printJson(result);
|
|
318
|
+
console.log(`Revoked SSH key ${result.key.key_id}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const result = await _apiRequest("POST", "/v1/ssh/keys/list", {});
|
|
322
|
+
if (options.json) return _printJson(result);
|
|
323
|
+
_printKeys(result.keys || []);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (entity === "host") {
|
|
328
|
+
if (action === "register") {
|
|
329
|
+
_require(options.name, "--name");
|
|
330
|
+
const result = await _apiRequest("POST", "/v1/ssh/hosts/register", { name: options.name, environment: options.environment });
|
|
331
|
+
if (options.json) return _printJson(result);
|
|
332
|
+
console.log(`Registered host ${result.host.host_id} (${result.host.name})`);
|
|
333
|
+
console.log(` export ODAI_SSH_SYNC_TOKEN=${result.sync_token}`);
|
|
334
|
+
console.log(` 0dai ssh sync --host-id ${result.host.host_id} --apply`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (action === "rotate-token") {
|
|
338
|
+
_require(options.hostId, "--host-id");
|
|
339
|
+
const result = await _apiRequest("POST", "/v1/ssh/hosts/rotate-token", { host_id: options.hostId });
|
|
340
|
+
if (options.json) return _printJson(result);
|
|
341
|
+
console.log(`Rotated sync token for ${result.host.name}`);
|
|
342
|
+
console.log(` export ODAI_SSH_SYNC_TOKEN=${result.sync_token}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const result = await _apiRequest("POST", "/v1/ssh/hosts/list", {});
|
|
346
|
+
if (options.json) return _printJson(result);
|
|
347
|
+
_printHosts(result.owned_hosts || [], result.accessible_hosts || []);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (entity === "grant") {
|
|
352
|
+
if (action === "add") {
|
|
353
|
+
_require(options.hostId, "--host-id");
|
|
354
|
+
_require(options.userEmail, "--user-email");
|
|
355
|
+
_require(options.unixUser, "--unix-user");
|
|
356
|
+
const result = await _apiRequest("POST", "/v1/ssh/grants/add", {
|
|
357
|
+
host_id: options.hostId,
|
|
358
|
+
user_email: options.userEmail,
|
|
359
|
+
unix_user: options.unixUser,
|
|
360
|
+
});
|
|
361
|
+
if (options.json) return _printJson(result);
|
|
362
|
+
console.log(`Added SSH grant ${result.grant.grant_id}`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (action === "revoke") {
|
|
366
|
+
const grantId = options.grantId || args[3];
|
|
367
|
+
_require(grantId, "--grant-id");
|
|
368
|
+
const result = await _apiRequest("POST", "/v1/ssh/grants/revoke", { grant_id: grantId });
|
|
369
|
+
if (options.json) return _printJson(result);
|
|
370
|
+
console.log(`Revoked SSH grant ${result.grant.grant_id}`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const result = await _apiRequest("POST", "/v1/ssh/overview", {});
|
|
374
|
+
if (options.json) return _printJson(result);
|
|
375
|
+
_printGrants(result.grants || []);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (entity === "audit") {
|
|
380
|
+
const result = await _apiRequest("POST", "/v1/ssh/overview", {});
|
|
381
|
+
if (options.json) return _printJson(result);
|
|
382
|
+
_printAudit(result.audit || []);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (entity === "status" || entity === "overview") {
|
|
387
|
+
const result = await _apiRequest("POST", "/v1/ssh/overview", {});
|
|
388
|
+
if (options.json) return _printJson(result);
|
|
389
|
+
_printKeys(result.keys || []);
|
|
390
|
+
console.log("");
|
|
391
|
+
_printHosts(result.owned_hosts || [], result.accessible_hosts || []);
|
|
392
|
+
console.log("");
|
|
393
|
+
_printGrants(result.grants || []);
|
|
394
|
+
console.log("");
|
|
395
|
+
_printAudit(result.audit || []);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log("Usage:");
|
|
400
|
+
console.log(" 0dai ssh key add --name laptop --file ~/.ssh/id_ed25519.pub");
|
|
401
|
+
console.log(" 0dai ssh key list");
|
|
402
|
+
console.log(" 0dai ssh key revoke --key-id <sshk_...>");
|
|
403
|
+
console.log(" 0dai ssh host register --name prod-api --env production");
|
|
404
|
+
console.log(" 0dai ssh host list");
|
|
405
|
+
console.log(" 0dai ssh host rotate-token --host-id <sshh_...>");
|
|
406
|
+
console.log(" 0dai ssh grant add --host-id <sshh_...> --user-email dev@example.com --unix-user deploy");
|
|
407
|
+
console.log(" 0dai ssh grant revoke --grant-id <sshg_...>");
|
|
408
|
+
console.log(" 0dai ssh audit");
|
|
409
|
+
console.log(" 0dai ssh sync --host-id <sshh_...> --token-env ODAI_SSH_SYNC_TOKEN [--apply] [--home-root /home]");
|
|
410
|
+
} catch (err) {
|
|
411
|
+
log(`error: ${err.message}`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
module.exports = { cmdSsh };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const shared = require("../shared");
|
|
3
|
+
const { log, D, R, spawnSync, findRepoScript } = shared;
|
|
4
|
+
|
|
5
|
+
async function cmdStandup(target, args = []) {
|
|
6
|
+
const standupScript = findRepoScript(target, "standup.py");
|
|
7
|
+
if (!standupScript) {
|
|
8
|
+
log("standup briefing unavailable in this environment");
|
|
9
|
+
console.log(` ${D}Expected scripts/standup.py in repo checkout${R}`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const forwarded = [standupScript, "--target", target];
|
|
14
|
+
const flagPairs = [
|
|
15
|
+
"--limit",
|
|
16
|
+
"--window-hours",
|
|
17
|
+
"--threshold-hours",
|
|
18
|
+
"--voice-id",
|
|
19
|
+
"--model-id",
|
|
20
|
+
"--output",
|
|
21
|
+
"--output-format",
|
|
22
|
+
];
|
|
23
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
24
|
+
const arg = args[i];
|
|
25
|
+
if (arg === "--json" || arg === "--if-stale" || arg === "--no-play" || arg === "--launch-hook") {
|
|
26
|
+
forwarded.push(arg);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (flagPairs.includes(arg) && args[i + 1]) {
|
|
30
|
+
forwarded.push(arg, args[i + 1]);
|
|
31
|
+
i += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = spawnSync("python3", forwarded, { stdio: "inherit" });
|
|
36
|
+
if (typeof result.status === "number") process.exit(result.status);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { cmdStandup };
|
package/lib/commands/status.js
CHANGED
|
@@ -1,43 +1,51 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const shared = require("../shared");
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
log, T, R, D, fs, path, spawnSync, findRepoScript,
|
|
5
|
+
getSwarmQuotaLocal, _detectPlanLocal, PLAN_LEVELS, loadAuthState,
|
|
6
|
+
} = shared;
|
|
4
7
|
|
|
5
|
-
function
|
|
8
|
+
function countJson(dir) {
|
|
9
|
+
try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadJson(file) {
|
|
13
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function loadProjectBinding(target) {
|
|
17
|
+
return loadJson(path.join(target, ".0dai", "project-binding.json"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectStatusPayload(target) {
|
|
6
21
|
const ai = path.join(target, "ai");
|
|
22
|
+
const identity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
|
|
23
|
+
const binding = loadProjectBinding(target);
|
|
24
|
+
const bindingStatus = (binding && binding.binding_status) || "local-only";
|
|
25
|
+
const bindingReason = binding
|
|
26
|
+
? (binding.binding_status ? "" : "project-binding.json has no binding_status")
|
|
27
|
+
: "no .0dai/project-binding.json";
|
|
28
|
+
const bindingNextAction = bindingStatus === "bound" ? "" : "run 0dai project bind";
|
|
7
29
|
let v = "?", stack = "?";
|
|
8
30
|
try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
|
|
9
|
-
|
|
10
|
-
log(`v${v} | stack: ${stack}`);
|
|
31
|
+
stack = identity.stack || stack;
|
|
11
32
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const d = count(path.join(ai, "swarm", "done"));
|
|
16
|
-
if (q || a || d) console.log(` swarm: ${q} queued, ${a} active, ${d} done`);
|
|
33
|
+
const q = countJson(path.join(ai, "swarm", "queue"));
|
|
34
|
+
const a = countJson(path.join(ai, "swarm", "active"));
|
|
35
|
+
const d = countJson(path.join(ai, "swarm", "done"));
|
|
17
36
|
|
|
18
|
-
// Swarm quota
|
|
19
37
|
const quota = getSwarmQuotaLocal(target);
|
|
20
|
-
if (quota.plan === "free") {
|
|
21
|
-
console.log(` swarm quota: ${D}locked (Free) — upgrade for ${quota.daily_limit} tasks/day${R}`);
|
|
22
|
-
} else {
|
|
23
|
-
console.log(` swarm quota: ${quota.used_today}/${quota.daily_limit} tasks today (${quota.plan})`);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Session roaming status
|
|
27
38
|
const sessPlan = _detectPlanLocal(target);
|
|
28
39
|
const sessLocked = PLAN_LEVELS[sessPlan] < PLAN_LEVELS["pro"];
|
|
29
|
-
if (sessLocked) {
|
|
30
|
-
console.log(` session roaming: ${D}locked (Free) — upgrade to save/resume sessions${R}`);
|
|
31
|
-
} else {
|
|
32
|
-
console.log(` session roaming: ${T}available (${sessPlan})${R}`);
|
|
33
|
-
}
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const session = loadJson(path.join(ai, "sessions", "active.json"));
|
|
42
|
+
const auth = loadAuthState() || {};
|
|
43
|
+
const budget = loadJson(path.join(ai, "swarm", "budget.json")) || {};
|
|
44
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
45
|
+
const budgetDaily = budget.daily && typeof budget.daily === "object" ? budget.daily : {};
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
let driftDetected = false;
|
|
48
|
+
let warningCount = 0;
|
|
41
49
|
try {
|
|
42
50
|
const ds = findRepoScript(target, "anti_pattern_detector.py");
|
|
43
51
|
if (ds) {
|
|
@@ -45,25 +53,112 @@ function cmdStatus(target) {
|
|
|
45
53
|
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
|
|
46
54
|
if (wr.status === 0 && wr.stdout) {
|
|
47
55
|
const wc = JSON.parse(wr.stdout.trim());
|
|
48
|
-
|
|
56
|
+
warningCount = Number(wc.count || 0);
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
} catch {}
|
|
52
60
|
|
|
53
|
-
// First-status tip (shows once after init)
|
|
54
|
-
try { require("../onboarding").showFirstStatusTip(target); } catch {}
|
|
55
|
-
|
|
56
|
-
// Drift warning (lightweight)
|
|
57
61
|
try {
|
|
58
62
|
const ds = findRepoScript(target, "drift_detector.py");
|
|
59
63
|
if (ds) {
|
|
60
64
|
const dr = spawnSync("python3", [ds, "report", "--target", target],
|
|
61
65
|
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
|
|
62
|
-
|
|
63
|
-
console.log(` drift: config changes detected — run: 0dai doctor --drift`);
|
|
64
|
-
}
|
|
66
|
+
driftDetected = !!(dr.stdout && (dr.stdout.includes("MODIFIED") || dr.stdout.includes("CONTRADICTS")));
|
|
65
67
|
}
|
|
66
68
|
} catch {}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
version: v,
|
|
72
|
+
stack,
|
|
73
|
+
project_id: identity.project_id,
|
|
74
|
+
binding_status: bindingStatus,
|
|
75
|
+
binding_reason: bindingReason,
|
|
76
|
+
binding_next_action: bindingNextAction,
|
|
77
|
+
project: {
|
|
78
|
+
project_id: identity.project_id,
|
|
79
|
+
name: identity.project_name,
|
|
80
|
+
stack,
|
|
81
|
+
origin: identity.origin,
|
|
82
|
+
remote_origin: identity.remote_origin,
|
|
83
|
+
binding_status: bindingStatus,
|
|
84
|
+
binding_reason: bindingReason,
|
|
85
|
+
binding_next_action: bindingNextAction,
|
|
86
|
+
},
|
|
87
|
+
swarm: {
|
|
88
|
+
queued: q,
|
|
89
|
+
active: a,
|
|
90
|
+
done: d,
|
|
91
|
+
quota_state: quota.plan === "free" ? "locked" : "available",
|
|
92
|
+
quota_plan: quota.plan,
|
|
93
|
+
used_today: Number(quota.used_today || 0),
|
|
94
|
+
daily_limit: Number(quota.daily_limit || 0),
|
|
95
|
+
},
|
|
96
|
+
session: {
|
|
97
|
+
id: session ? (session.id || null) : null,
|
|
98
|
+
name: session ? ((session.task || {}).goal || null) : null,
|
|
99
|
+
agent: session ? (session.current_agent || null) : null,
|
|
100
|
+
archive_count: countJson(path.join(ai, "sessions", "archive")),
|
|
101
|
+
roaming_state: sessLocked ? "locked" : "available",
|
|
102
|
+
roaming_plan: sessPlan,
|
|
103
|
+
},
|
|
104
|
+
auth: {
|
|
105
|
+
logged_in: !!(auth.api_key || auth.access_token || auth.token),
|
|
106
|
+
email: auth.email || auth.user || null,
|
|
107
|
+
tier: auth.plan || sessPlan || "free",
|
|
108
|
+
activation: ((auth.license || {}).status) || "inactive",
|
|
109
|
+
activation_id: ((auth.license || {}).activation_id) || null,
|
|
110
|
+
},
|
|
111
|
+
budget: {
|
|
112
|
+
today: Number(budgetDaily[today] || 0),
|
|
113
|
+
total_spent: Number(budget.total_spent || 0),
|
|
114
|
+
},
|
|
115
|
+
drift: {
|
|
116
|
+
detected: driftDetected,
|
|
117
|
+
},
|
|
118
|
+
warnings: warningCount,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cmdStatus(target, options = {}) {
|
|
123
|
+
const payload = collectStatusPayload(target);
|
|
124
|
+
if (options.json) {
|
|
125
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
126
|
+
return payload;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log(`v${payload.version} | stack: ${payload.stack}`);
|
|
130
|
+
|
|
131
|
+
if (payload.swarm.queued || payload.swarm.active || payload.swarm.done) {
|
|
132
|
+
console.log(` swarm: ${payload.swarm.queued} queued, ${payload.swarm.active} active, ${payload.swarm.done} done`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (payload.swarm.quota_state === "locked") {
|
|
136
|
+
console.log(` swarm quota: ${D}locked (Free) — upgrade for ${payload.swarm.daily_limit} tasks/day${R}`);
|
|
137
|
+
} else {
|
|
138
|
+
console.log(` swarm quota: ${payload.swarm.used_today}/${payload.swarm.daily_limit} tasks today (${payload.swarm.quota_plan})`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (payload.session.roaming_state === "locked") {
|
|
142
|
+
console.log(` session roaming: ${D}locked (Free) — upgrade to save/resume sessions${R}`);
|
|
143
|
+
} else {
|
|
144
|
+
console.log(` session roaming: ${T}available (${payload.session.roaming_plan})${R}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (payload.session.name) {
|
|
148
|
+
console.log(` session: ${payload.session.name} (agent: ${payload.session.agent || "?"})`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (payload.warnings > 0) {
|
|
152
|
+
console.log(` warnings: ${payload.warnings} active — run: 0dai experience warnings`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try { require("../onboarding").showFirstStatusTip(target); } catch {}
|
|
156
|
+
|
|
157
|
+
if (payload.drift.detected) {
|
|
158
|
+
console.log(` drift: config changes detected — run: 0dai doctor --drift`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return payload;
|
|
67
162
|
}
|
|
68
163
|
|
|
69
|
-
module.exports = { cmdStatus };
|
|
164
|
+
module.exports = { cmdStatus, collectStatusPayload };
|