@phnx-labs/agents-cli 1.20.51 → 1.20.52
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/CHANGELOG.md +16 -0
- package/dist/commands/browser.js +215 -7
- package/dist/commands/cloud.js +6 -0
- package/dist/commands/events.d.ts +1 -1
- package/dist/commands/events.js +2 -3
- package/dist/commands/exec.js +17 -2
- package/dist/commands/factory.js +8 -0
- package/dist/commands/feed.d.ts +9 -0
- package/dist/commands/feed.js +69 -0
- package/dist/commands/logs.d.ts +5 -1
- package/dist/commands/logs.js +248 -3
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/secrets.d.ts +22 -0
- package/dist/commands/secrets.js +173 -42
- package/dist/commands/teams.js +4 -0
- package/dist/index.js +6 -2
- package/dist/lib/browser/login-detection.d.ts +94 -0
- package/dist/lib/browser/login-detection.js +274 -0
- package/dist/lib/browser/profiles.d.ts +17 -8
- package/dist/lib/browser/profiles.js +27 -8
- package/dist/lib/browser/secret-ref.d.ts +10 -0
- package/dist/lib/browser/secret-ref.js +14 -0
- package/dist/lib/browser/service.js +14 -12
- package/dist/lib/cloud/rush.d.ts +15 -0
- package/dist/lib/cloud/rush.js +7 -1
- package/dist/lib/crabbox/lease.d.ts +6 -0
- package/dist/lib/crabbox/lease.js +11 -9
- package/dist/lib/crabbox/runtimes.d.ts +38 -1
- package/dist/lib/crabbox/runtimes.js +98 -5
- package/dist/lib/daemon.d.ts +12 -9
- package/dist/lib/daemon.js +32 -17
- package/dist/lib/events.d.ts +31 -5
- package/dist/lib/events.js +288 -101
- package/dist/lib/exec.js +1 -0
- package/dist/lib/feed.d.ts +56 -0
- package/dist/lib/feed.js +251 -0
- package/dist/lib/hooks.js +7 -2
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/rotate.js +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +21 -0
- package/dist/lib/secrets/agent.js +63 -1
- package/dist/lib/secrets/bundles.d.ts +33 -1
- package/dist/lib/secrets/bundles.js +38 -8
- package/dist/lib/secrets/icloud-import.d.ts +70 -0
- package/dist/lib/secrets/icloud-import.js +173 -0
- package/dist/lib/secrets/index.d.ts +36 -0
- package/dist/lib/secrets/index.js +99 -9
- package/dist/lib/secrets/remote.js +1 -1
- package/dist/lib/secrets/sync.js +1 -1
- package/dist/lib/session/discover.js +1 -2
- package/dist/lib/session/state.js +13 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +25 -8
- package/dist/lib/teams/agents.js +6 -3
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/whats-new.d.ts +5 -3
- package/dist/lib/whats-new.js +25 -5
- package/package.json +1 -1
package/dist/commands/logs.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agents logs` — unified, discoverable run-log viewer.
|
|
2
|
+
* `agents logs` — unified, discoverable run-log viewer + audit trail.
|
|
3
3
|
*
|
|
4
4
|
* Resolves a run across two substrates and shows (or `-f` follows) its log:
|
|
5
5
|
* - host-dispatch tasks (`agents run --host`) → combined-stdout log, offset-tailed
|
|
6
6
|
* - sessions (the local index) → transcript, tailed via the sessions tailer
|
|
7
7
|
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
* - `agents logs audit` — read the structured audit/event log
|
|
10
|
+
* - `agents logs stats` — show aggregate audit statistics
|
|
11
|
+
*
|
|
8
12
|
* Concise by default: a bare `agents logs <id>` prints the same summary digest as
|
|
9
13
|
* `agents sessions <id>` — cheap for an agent to glance at. The token-heavy full
|
|
10
14
|
* transcript / raw stdout is opt-in behind `--full` (alias `-m/--markdown`).
|
|
@@ -17,12 +21,14 @@
|
|
|
17
21
|
* the same underlying helpers (showHostTaskLog / streamSessionTail).
|
|
18
22
|
*/
|
|
19
23
|
import chalk from 'chalk';
|
|
24
|
+
import * as fs from 'fs';
|
|
20
25
|
import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
|
|
21
26
|
import { parseAgentFilter, renderSessionLog } from './sessions.js';
|
|
22
27
|
import { streamSessionTail, isTailable } from './sessions-tail.js';
|
|
23
28
|
import { showHostTaskLog } from '../lib/hosts/logs.js';
|
|
24
29
|
import { listTasks } from '../lib/hosts/tasks.js';
|
|
25
30
|
import { itemPicker } from '../lib/picker.js';
|
|
31
|
+
import { query, stats, getLogsPath, rotate, levelFor, } from '../lib/events.js';
|
|
26
32
|
/** Compact one-line label used by both the picker and the non-TTY list. */
|
|
27
33
|
function candidateLabel(c) {
|
|
28
34
|
if (c.kind === 'task') {
|
|
@@ -130,11 +136,207 @@ async function runLogs(id, opts) {
|
|
|
130
136
|
return;
|
|
131
137
|
await showCandidate(picked.item, follow, full);
|
|
132
138
|
}
|
|
139
|
+
function parseSince(s) {
|
|
140
|
+
const m = s.match(/^(\d+)([smhdw])$/);
|
|
141
|
+
if (m) {
|
|
142
|
+
const n = parseInt(m[1], 10);
|
|
143
|
+
const unitMs = {
|
|
144
|
+
s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000,
|
|
145
|
+
};
|
|
146
|
+
return new Date(Date.now() - n * unitMs[m[2]]);
|
|
147
|
+
}
|
|
148
|
+
const ms = Date.parse(s);
|
|
149
|
+
if (isNaN(ms))
|
|
150
|
+
throw new Error(`Invalid --since value: ${s} (use e.g. 2h, 7d, or an ISO date)`);
|
|
151
|
+
return new Date(ms);
|
|
152
|
+
}
|
|
153
|
+
function originLabel(r) {
|
|
154
|
+
if (r.transport === 'ssh') {
|
|
155
|
+
return chalk.yellow(`ssh${r.sshClientIp ? ' ' + r.sshClientIp : ''}`);
|
|
156
|
+
}
|
|
157
|
+
return chalk.gray('local');
|
|
158
|
+
}
|
|
159
|
+
function auditDetailFor(r) {
|
|
160
|
+
if (r.command)
|
|
161
|
+
return r.command;
|
|
162
|
+
const bits = [];
|
|
163
|
+
if (typeof r.team === 'string')
|
|
164
|
+
bits.push(`team=${r.team}`);
|
|
165
|
+
if (typeof r.bundle === 'string')
|
|
166
|
+
bits.push(`bundle=${r.bundle}`);
|
|
167
|
+
if (typeof r.skill === 'string')
|
|
168
|
+
bits.push(`skill=${r.skill}`);
|
|
169
|
+
if (typeof r.version === 'string')
|
|
170
|
+
bits.push(`v=${r.version}`);
|
|
171
|
+
if (typeof r.profile === 'string')
|
|
172
|
+
bits.push(`profile=${r.profile}`);
|
|
173
|
+
if (typeof r.server === 'string')
|
|
174
|
+
bits.push(`server=${r.server}`);
|
|
175
|
+
if (typeof r.error === 'string')
|
|
176
|
+
bits.push(chalk.red(r.error));
|
|
177
|
+
return bits.join(' ');
|
|
178
|
+
}
|
|
179
|
+
function levelColor(level) {
|
|
180
|
+
if (level === 'audit')
|
|
181
|
+
return chalk.magenta(level);
|
|
182
|
+
if (level === 'warn')
|
|
183
|
+
return chalk.yellow(level);
|
|
184
|
+
if (level === 'debug')
|
|
185
|
+
return chalk.gray(level);
|
|
186
|
+
return chalk.blue(level);
|
|
187
|
+
}
|
|
188
|
+
function renderAuditRow(r) {
|
|
189
|
+
const time = chalk.gray(r.ts.slice(0, 19).replace('T', ' '));
|
|
190
|
+
const user = `${r.osUser ?? '?'}@${r.hostname}`;
|
|
191
|
+
const ev = r.event.startsWith('error') ? chalk.red(r.event) : chalk.cyan(r.event);
|
|
192
|
+
const lvl = levelColor(r.level ?? levelFor(r.event));
|
|
193
|
+
const agent = r.agent ? chalk.gray(` ${r.agent}`) : '';
|
|
194
|
+
const caller = chalk.gray(`via ${r.caller ?? 'unknown'}${r.session ? ` ${r.session}` : ''}`);
|
|
195
|
+
return `${time} ${lvl.padEnd(14)} ${originLabel(r).padEnd(24)} ${user.padEnd(22)} ${caller.padEnd(28)} ${ev.padEnd(26)}${agent} ${auditDetailFor(r)}`;
|
|
196
|
+
}
|
|
197
|
+
function collect(value, previous) {
|
|
198
|
+
return previous.concat([value]);
|
|
199
|
+
}
|
|
200
|
+
async function runAudit(opts) {
|
|
201
|
+
if (opts.follow) {
|
|
202
|
+
await followAuditLog();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const limit = Math.max(1, parseInt(opts.limit ?? '50', 10) || 50);
|
|
206
|
+
let startDate;
|
|
207
|
+
try {
|
|
208
|
+
startDate = opts.since ? parseSince(opts.since) : undefined;
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error(chalk.red(err.message));
|
|
212
|
+
process.exit(2);
|
|
213
|
+
}
|
|
214
|
+
const records = query({
|
|
215
|
+
startDate,
|
|
216
|
+
eventTypes: opts.event?.length ? opts.event : undefined,
|
|
217
|
+
level: opts.level,
|
|
218
|
+
agent: opts.agent,
|
|
219
|
+
caller: opts.caller,
|
|
220
|
+
command: opts.command,
|
|
221
|
+
module: opts.module,
|
|
222
|
+
limit,
|
|
223
|
+
});
|
|
224
|
+
if (opts.json) {
|
|
225
|
+
console.log(JSON.stringify(records, null, 2));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (records.length === 0) {
|
|
229
|
+
console.log(chalk.gray('No matching events.'));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
for (const r of records.slice().reverse())
|
|
233
|
+
console.log(renderAuditRow(r));
|
|
234
|
+
console.log(chalk.gray(`\n${records.length} event(s). Log: ${getLogsPath()}`));
|
|
235
|
+
}
|
|
236
|
+
async function followAuditLog() {
|
|
237
|
+
const file = getLogsPath();
|
|
238
|
+
let offset = 0;
|
|
239
|
+
try {
|
|
240
|
+
offset = fs.statSync(file).size;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// File may not exist yet — start at 0.
|
|
244
|
+
}
|
|
245
|
+
console.log(chalk.gray(`Tailing ${file} — Ctrl-C to stop`));
|
|
246
|
+
const drain = () => {
|
|
247
|
+
let size = 0;
|
|
248
|
+
try {
|
|
249
|
+
size = fs.statSync(file).size;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (size <= offset) {
|
|
255
|
+
if (size < offset)
|
|
256
|
+
offset = 0;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const fd = fs.openSync(file, 'r');
|
|
260
|
+
try {
|
|
261
|
+
const buf = Buffer.alloc(size - offset);
|
|
262
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
263
|
+
offset = size;
|
|
264
|
+
for (const line of buf.toString('utf-8').split('\n').filter(Boolean)) {
|
|
265
|
+
try {
|
|
266
|
+
console.log(renderAuditRow(JSON.parse(line)));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// Skip malformed lines.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
fs.closeSync(fd);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
await new Promise(() => {
|
|
278
|
+
setInterval(drain, 500);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function humanBytes(bytes) {
|
|
282
|
+
if (bytes < 1024)
|
|
283
|
+
return `${bytes} B`;
|
|
284
|
+
if (bytes < 1024 * 1024)
|
|
285
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
286
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
287
|
+
}
|
|
288
|
+
async function runStats(opts) {
|
|
289
|
+
let days = 7;
|
|
290
|
+
if (opts.since) {
|
|
291
|
+
try {
|
|
292
|
+
const d = parseSince(opts.since);
|
|
293
|
+
days = Math.max(1, Math.ceil((Date.now() - d.getTime()) / 86_400_000));
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
console.error(chalk.red(err.message));
|
|
297
|
+
process.exit(2);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const s = stats({ days });
|
|
301
|
+
if (opts.json) {
|
|
302
|
+
console.log(JSON.stringify(s, null, 2));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
console.log(chalk.bold(`Audit statistics (last ${days} day${days === 1 ? '' : 's'})\n`));
|
|
306
|
+
console.log(` Total events: ${s.totalEvents}`);
|
|
307
|
+
console.log(` Log files: ${s.fileCount} (${humanBytes(s.totalBytes)})`);
|
|
308
|
+
console.log(` Log path: ${chalk.gray(getLogsPath())}`);
|
|
309
|
+
if (Object.keys(s.byLevel).length) {
|
|
310
|
+
console.log(chalk.bold('\n By level:'));
|
|
311
|
+
for (const [k, v] of Object.entries(s.byLevel).sort((a, b) => b[1] - a[1])) {
|
|
312
|
+
console.log(` ${levelColor(k).padEnd(20)} ${v}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (Object.keys(s.byEvent).length) {
|
|
316
|
+
console.log(chalk.bold('\n By event (top 15):'));
|
|
317
|
+
for (const [k, v] of Object.entries(s.byEvent).sort((a, b) => b[1] - a[1]).slice(0, 15)) {
|
|
318
|
+
console.log(` ${chalk.cyan(k).padEnd(30)} ${v}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (Object.keys(s.byModule).length) {
|
|
322
|
+
console.log(chalk.bold('\n By module:'));
|
|
323
|
+
for (const [k, v] of Object.entries(s.byModule).sort((a, b) => b[1] - a[1])) {
|
|
324
|
+
console.log(` ${k.padEnd(20)} ${v}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (Object.keys(s.byUser).length) {
|
|
328
|
+
console.log(chalk.bold('\n By user:'));
|
|
329
|
+
for (const [k, v] of Object.entries(s.byUser).sort((a, b) => b[1] - a[1])) {
|
|
330
|
+
console.log(` ${k.padEnd(30)} ${v}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
console.log();
|
|
334
|
+
}
|
|
133
335
|
/** Register the top-level `agents logs` command. */
|
|
134
336
|
export function registerLogsCommand(program) {
|
|
135
|
-
program
|
|
337
|
+
const logsCmd = program
|
|
136
338
|
.command('logs [id]')
|
|
137
|
-
.description('Show a run
|
|
339
|
+
.description('Show a run log, audit trail, or stats. Subcommands: audit, stats, rotate.')
|
|
138
340
|
.option('--host <name>', 'Scope to runs dispatched to a host')
|
|
139
341
|
.option('-a, --agent <agent>', 'Filter by agent (e.g. claude, codex@0.116.0)')
|
|
140
342
|
.option('--version <version>', 'Filter by agent version')
|
|
@@ -142,4 +344,47 @@ export function registerLogsCommand(program) {
|
|
|
142
344
|
.option('-f, --follow', 'Follow live output')
|
|
143
345
|
.option('-m, --full', 'Show the full raw transcript / stdout instead of the concise summary')
|
|
144
346
|
.action((id, opts) => runLogs(id, opts));
|
|
347
|
+
logsCmd
|
|
348
|
+
.command('audit')
|
|
349
|
+
.description('Read the structured audit/event log (who ran what, from where)')
|
|
350
|
+
.option('--module <name>', 'Only events from this command group (e.g. teams, secrets)')
|
|
351
|
+
.option('--command <path>', 'Only this command path — prefix match (e.g. "teams create")')
|
|
352
|
+
.option('--event <type>', 'Only this typed event (repeatable)', collect, [])
|
|
353
|
+
.option('--agent <name>', 'Only events tagged with this agent')
|
|
354
|
+
.option('--caller <kind>', 'Only this caller kind (claude-code, codex, gemini, cursor, terminal, script)')
|
|
355
|
+
.option('--level <level>', 'Only this level: audit, warn, info, debug')
|
|
356
|
+
.option('--since <time>', 'Only events newer than this (e.g. 2h, 7d, or ISO date)')
|
|
357
|
+
.option('--limit <n>', 'Max records to show (default 50)', '50')
|
|
358
|
+
.option('--json', 'Output raw records as JSON')
|
|
359
|
+
.option('-f, --follow', "Tail today's log live")
|
|
360
|
+
.addHelpText('after', `
|
|
361
|
+
Examples:
|
|
362
|
+
agents logs audit Recent activity across everything
|
|
363
|
+
agents logs audit --module teams Team lifecycle (create / add / disband)
|
|
364
|
+
agents logs audit --module secrets Every secret accessed or revealed
|
|
365
|
+
agents logs audit --level audit Only audit-level events
|
|
366
|
+
agents logs audit --command "teams create" Just team creations
|
|
367
|
+
agents logs audit --event secrets.get --since 7d --json
|
|
368
|
+
agents logs audit -f Live tail`)
|
|
369
|
+
.action(async (options) => runAudit(options));
|
|
370
|
+
logsCmd
|
|
371
|
+
.command('stats')
|
|
372
|
+
.description('Show aggregate audit statistics')
|
|
373
|
+
.option('--since <time>', 'Window size (e.g. 7d, 30d; default 7d)')
|
|
374
|
+
.option('--json', 'Output stats as JSON')
|
|
375
|
+
.action(async (opts) => runStats(opts));
|
|
376
|
+
logsCmd
|
|
377
|
+
.command('rotate')
|
|
378
|
+
.description('Force log rotation — remove files older than the retention period')
|
|
379
|
+
.option('--days <n>', 'Retention period in days (default 7)', '7')
|
|
380
|
+
.action((opts) => {
|
|
381
|
+
const days = Math.max(1, parseInt(opts.days ?? '7', 10) || 7);
|
|
382
|
+
const removed = rotate(days);
|
|
383
|
+
if (removed > 0) {
|
|
384
|
+
console.log(`Removed ${removed} log file${removed === 1 ? '' : 's'} older than ${days} day${days === 1 ? '' : 's'}.`);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
console.log(chalk.gray('No log files to remove.'));
|
|
388
|
+
}
|
|
389
|
+
});
|
|
145
390
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -3,6 +3,7 @@ import { truncate } from '../lib/format.js';
|
|
|
3
3
|
import ora from 'ora';
|
|
4
4
|
import { checkbox } from '@inquirer/prompts';
|
|
5
5
|
import { capableAgents, isCapable } from '../lib/capabilities.js';
|
|
6
|
+
import { emit } from '../lib/events.js';
|
|
6
7
|
import { AGENTS, getAllCliStates, resolveAgentName, formatAgentError, registerMcpToTargets, unregisterMcpFromTargets, listInstalledMcpsWithScope, parseMcpConfig, getMcpConfigPathForHome, agentLabel, } from '../lib/agents.js';
|
|
7
8
|
import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
|
|
8
9
|
import { listMcpServerConfigs, discoverMcpConfigsFromRepo, installMcpConfigCentrally, } from '../lib/mcp.js';
|
|
@@ -293,6 +294,7 @@ Examples:
|
|
|
293
294
|
};
|
|
294
295
|
}
|
|
295
296
|
writeManifest(localPath, manifest);
|
|
297
|
+
emit('mcp.add', { module: 'mcp', server: name });
|
|
296
298
|
console.log(chalk.green(`Added MCP server '${name}' to manifest`));
|
|
297
299
|
console.log(chalk.gray('Run: agents mcp register to apply'));
|
|
298
300
|
});
|
|
@@ -441,6 +443,8 @@ Examples:
|
|
|
441
443
|
console.log(chalk.yellow('No MCP servers removed.'));
|
|
442
444
|
}
|
|
443
445
|
else {
|
|
446
|
+
for (const n of mcpsToRemove)
|
|
447
|
+
emit('mcp.remove', { module: 'mcp', server: n });
|
|
444
448
|
console.log(chalk.green(`\nRemoved ${removed} MCP server(s).`));
|
|
445
449
|
}
|
|
446
450
|
});
|
|
@@ -575,6 +579,7 @@ Examples:
|
|
|
575
579
|
targets = resolveConfiguredAgentTargets(config.agents, config.agentVersions, capableAgents('mcp'));
|
|
576
580
|
}
|
|
577
581
|
const results = await registerMcpToTargets(targets, mcpName, commandOrUrl, config.scope || 'user', transport, { headers: config.headers });
|
|
582
|
+
const applied = results.filter(r => r.success).length;
|
|
578
583
|
for (const result of results) {
|
|
579
584
|
if (result.success) {
|
|
580
585
|
console.log(` ${chalk.green('+')} ${formatTargetLabel(result.agentId, result.version)}`);
|
|
@@ -586,6 +591,8 @@ Examples:
|
|
|
586
591
|
console.log(` ${chalk.red('x')} ${formatTargetLabel(result.agentId, result.version)}: ${result.error}`);
|
|
587
592
|
}
|
|
588
593
|
}
|
|
594
|
+
if (applied > 0)
|
|
595
|
+
emit('mcp.register', { module: 'mcp', server: mcpName, applied });
|
|
589
596
|
}
|
|
590
597
|
});
|
|
591
598
|
}
|
|
@@ -17,6 +17,28 @@ import { type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.j
|
|
|
17
17
|
* a filesystem path.
|
|
18
18
|
*/
|
|
19
19
|
export declare function readImportDotenv(from: string): string;
|
|
20
|
+
/** Where `secrets import` pulls keys from, parsed off the unified `--from`. */
|
|
21
|
+
export type ImportSource = {
|
|
22
|
+
kind: 'dotenv';
|
|
23
|
+
path: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: '1password';
|
|
26
|
+
vault?: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'icloud';
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Parse the unified `--from <source>` value: a .env path (`-` reads stdin),
|
|
32
|
+
* `1password:<vault>` (bare `1password` prompts for the vault), or `icloud`
|
|
33
|
+
* (legacy iCloud Keychain bundles). The deprecated `--from-1password --vault`
|
|
34
|
+
* pair maps onto the 1password source. A file literally named `icloud` or
|
|
35
|
+
* `1password` can still be imported via an explicit path (`./icloud`).
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseImportSource(opts: {
|
|
38
|
+
from?: string;
|
|
39
|
+
from1password?: boolean;
|
|
40
|
+
vault?: string;
|
|
41
|
+
}): ImportSource;
|
|
20
42
|
/**
|
|
21
43
|
* Build the remote `agents secrets unlock` argv for `unlock --host`. `--all`
|
|
22
44
|
* forwards verbatim; otherwise the explicit bundle names. A `--ttl` is passed
|
package/dist/commands/secrets.js
CHANGED
|
@@ -24,6 +24,7 @@ import { parseDuration } from '../lib/hooks/cache.js';
|
|
|
24
24
|
import { emit } from '../lib/events.js';
|
|
25
25
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
26
26
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
27
|
+
import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
|
|
27
28
|
import { registerSecretsSyncCommands } from './secrets-sync.js';
|
|
28
29
|
import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
|
|
29
30
|
import { registerSecretsImportKeyringCommand } from './secrets-import.js';
|
|
@@ -135,6 +136,101 @@ async function resolveVault(vaultOpt) {
|
|
|
135
136
|
export function readImportDotenv(from) {
|
|
136
137
|
return from === '-' ? readStdinSync() : fs.readFileSync(from, 'utf-8');
|
|
137
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Parse the unified `--from <source>` value: a .env path (`-` reads stdin),
|
|
141
|
+
* `1password:<vault>` (bare `1password` prompts for the vault), or `icloud`
|
|
142
|
+
* (legacy iCloud Keychain bundles). The deprecated `--from-1password --vault`
|
|
143
|
+
* pair maps onto the 1password source. A file literally named `icloud` or
|
|
144
|
+
* `1password` can still be imported via an explicit path (`./icloud`).
|
|
145
|
+
*/
|
|
146
|
+
export function parseImportSource(opts) {
|
|
147
|
+
if (opts.from && opts.from1password) {
|
|
148
|
+
throw new Error('--from and --from-1password are mutually exclusive.');
|
|
149
|
+
}
|
|
150
|
+
if (opts.from1password)
|
|
151
|
+
return { kind: '1password', vault: opts.vault };
|
|
152
|
+
if (!opts.from) {
|
|
153
|
+
throw new Error("Pass --from <source>: a .env path (- reads stdin), '1password:<vault>', or 'icloud'.");
|
|
154
|
+
}
|
|
155
|
+
if (opts.from === 'icloud')
|
|
156
|
+
return { kind: 'icloud' };
|
|
157
|
+
if (opts.from === '1password')
|
|
158
|
+
return { kind: '1password', vault: opts.vault };
|
|
159
|
+
if (opts.from.startsWith('1password:')) {
|
|
160
|
+
const vault = opts.from.slice('1password:'.length);
|
|
161
|
+
return { kind: '1password', vault: vault || opts.vault };
|
|
162
|
+
}
|
|
163
|
+
return { kind: 'dotenv', path: opts.from };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `secrets import --from icloud` — recover bundles stranded in the iCloud
|
|
167
|
+
* Keychain by the device-local cutover. With a bundle name, imports exactly
|
|
168
|
+
* that bundle; without one, interactively multi-selects from everything
|
|
169
|
+
* discovered (all pre-checked — the common case is "bring them all back").
|
|
170
|
+
*/
|
|
171
|
+
async function importFromICloud(bundleName, opts) {
|
|
172
|
+
if (process.platform !== 'darwin') {
|
|
173
|
+
throw new Error('--from icloud reads the macOS iCloud Keychain and is only available on macOS.');
|
|
174
|
+
}
|
|
175
|
+
const candidates = discoverSyncedBundles();
|
|
176
|
+
if (candidates.length === 0) {
|
|
177
|
+
console.log('No legacy iCloud Keychain bundles found.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const describe = (c) => `${c.name} (${c.keys.length} key${c.keys.length === 1 ? '' : 's'}${c.hasMeta ? '' : ', no metadata'})`;
|
|
181
|
+
let chosen;
|
|
182
|
+
if (bundleName) {
|
|
183
|
+
const hit = candidates.find((c) => c.name === bundleName);
|
|
184
|
+
if (!hit) {
|
|
185
|
+
throw new Error(`No iCloud Keychain bundle named '${bundleName}'. Found: ${candidates.map((c) => c.name).join(', ')}`);
|
|
186
|
+
}
|
|
187
|
+
chosen = [hit];
|
|
188
|
+
}
|
|
189
|
+
else if (!isInteractiveTerminal()) {
|
|
190
|
+
throw new Error(`Found ${candidates.length} iCloud Keychain bundle(s): ${candidates.map((c) => c.name).join(', ')}. ` +
|
|
191
|
+
'Pass a bundle name to import non-interactively.');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
195
|
+
chosen = await checkbox({
|
|
196
|
+
message: 'Which iCloud Keychain bundles to import?',
|
|
197
|
+
choices: candidates.map((c) => ({ name: describe(c), value: c, checked: true })),
|
|
198
|
+
});
|
|
199
|
+
if (chosen.length === 0) {
|
|
200
|
+
console.log('Nothing selected.');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const candidate of chosen) {
|
|
205
|
+
const result = importSyncedBundle(candidate, opts);
|
|
206
|
+
const parts = [`imported ${result.added} key(s)`];
|
|
207
|
+
if (result.skipped)
|
|
208
|
+
parts.push(`skipped ${result.skipped} (already set, pass --force)`);
|
|
209
|
+
if (result.missing.length)
|
|
210
|
+
parts.push(`unreadable (left in iCloud): ${result.missing.join(', ')}`);
|
|
211
|
+
if (opts.purge)
|
|
212
|
+
parts.push(`purged ${result.purged} iCloud item(s)`);
|
|
213
|
+
const line = `${candidate.name}: ${parts.join(', ')}`;
|
|
214
|
+
console.log(result.missing.length ? chalk.yellow(line) : chalk.green(line));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Printed under a "bundle not found" failure: if the name matches a bundle
|
|
219
|
+
* stranded in the iCloud Keychain (pre-device-local-cutover era), point at the
|
|
220
|
+
* recovery command instead of leaving a dead end.
|
|
221
|
+
*/
|
|
222
|
+
function maybePrintSyncedHint(name) {
|
|
223
|
+
if (process.platform !== 'darwin')
|
|
224
|
+
return;
|
|
225
|
+
try {
|
|
226
|
+
if (discoverSyncedBundles().some((c) => c.name === name)) {
|
|
227
|
+
console.error(chalk.yellow(`A legacy iCloud Keychain copy of '${name}' exists. Recover it with: agents secrets import ${name} --from icloud`));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Hint only — never mask the original error.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
138
234
|
/**
|
|
139
235
|
* Build the remote `agents secrets unlock` argv for `unlock --host`. `--all`
|
|
140
236
|
* forwards verbatim; otherwise the explicit bundle names. A `--ttl` is passed
|
|
@@ -525,7 +621,8 @@ export function registerSecretsCommands(program) {
|
|
|
525
621
|
agents secrets status show held bundles + when they lock
|
|
526
622
|
agents secrets rotate <bundle> <key> rotate value, preserve metadata
|
|
527
623
|
agents secrets import <bundle> --from .env bulk import from .env
|
|
528
|
-
agents secrets import <bundle> --from
|
|
624
|
+
agents secrets import <bundle> --from 1password:<vault>
|
|
625
|
+
agents secrets import --from icloud recover legacy iCloud Keychain bundles
|
|
529
626
|
agents secrets generate [length] generate a random password / PIN / hex
|
|
530
627
|
agents secrets migrate-acl upgrade legacy items to the biometry ACL
|
|
531
628
|
`,
|
|
@@ -610,7 +707,15 @@ export function registerSecretsCommands(program) {
|
|
|
610
707
|
return;
|
|
611
708
|
}
|
|
612
709
|
const resolvedName = name ?? (await pickBundleName('view'));
|
|
613
|
-
|
|
710
|
+
let bundle;
|
|
711
|
+
try {
|
|
712
|
+
bundle = readBundle(resolvedName);
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
console.error(chalk.red(err.message));
|
|
716
|
+
maybePrintSyncedHint(resolvedName);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
614
719
|
const entries = describeBundle(bundle);
|
|
615
720
|
console.log(chalk.bold(bundle.name));
|
|
616
721
|
if (bundle.description)
|
|
@@ -672,7 +777,7 @@ export function registerSecretsCommands(program) {
|
|
|
672
777
|
emit('secrets.get', {
|
|
673
778
|
module: 'secrets',
|
|
674
779
|
bundle: bundle.name,
|
|
675
|
-
|
|
780
|
+
operation: 'view --reveal',
|
|
676
781
|
source: 'reveal',
|
|
677
782
|
status: 'success',
|
|
678
783
|
keyCount: exposedCount,
|
|
@@ -713,24 +818,48 @@ export function registerSecretsCommands(program) {
|
|
|
713
818
|
}
|
|
714
819
|
});
|
|
715
820
|
cmd
|
|
716
|
-
.command('get <item>')
|
|
717
|
-
.description('Print a raw keychain item by name (
|
|
718
|
-
.action((item) => {
|
|
821
|
+
.command('get <item> [key]')
|
|
822
|
+
.description('Print one secret value for shell hooks/automation. One arg = a raw keychain item by name; two args = one KEY out of a bundle (`get <bundle> <KEY>`). Cross-platform.')
|
|
823
|
+
.action((item, key) => {
|
|
824
|
+
if (key === undefined) {
|
|
825
|
+
// Raw keychain item path — unchanged.
|
|
826
|
+
try {
|
|
827
|
+
// Routes through the platform keychain layer: macOS reads bare items
|
|
828
|
+
// via /usr/bin/security (no Touch ID), Linux via secret-tool with the
|
|
829
|
+
// encrypted-file fallback. The value goes to stdout (newline-terminated
|
|
830
|
+
// so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
|
|
831
|
+
// to stderr so they never pollute the captured value.
|
|
832
|
+
const value = getKeychainToken(item);
|
|
833
|
+
// Raw item reads bypass readAndResolveBundleEnv, so audit here too.
|
|
834
|
+
// `item` is the keychain service name, never the value.
|
|
835
|
+
emit('secrets.get', { module: 'secrets', item, source: 'raw-item', status: 'success' });
|
|
836
|
+
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
// Missing item is a normal, quiet outcome for a hook probe: exit 1,
|
|
840
|
+
// print nothing to stdout. Callers test the exit code / empty capture.
|
|
841
|
+
process.exit(1);
|
|
842
|
+
}
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
// Bundle-key path: `get <bundle> <KEY>` prints exactly one resolved value.
|
|
846
|
+
// Ungated like the raw path (it IS the automation primitive); the
|
|
847
|
+
// `secrets.get` audit event is emitted inside readAndResolveBundleEnv.
|
|
719
848
|
try {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
849
|
+
if (!bundleExists(item)) {
|
|
850
|
+
console.error(chalk.red(`Secrets bundle '${item}' not found.`));
|
|
851
|
+
process.exit(1);
|
|
852
|
+
}
|
|
853
|
+
const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key] });
|
|
854
|
+
if (!(key in env)) {
|
|
855
|
+
console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
|
|
856
|
+
process.exit(1);
|
|
857
|
+
}
|
|
858
|
+
const value = env[key];
|
|
729
859
|
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
730
860
|
}
|
|
731
|
-
catch {
|
|
732
|
-
|
|
733
|
-
// print nothing to stdout. Callers test the exit code / empty capture.
|
|
861
|
+
catch (err) {
|
|
862
|
+
console.error(chalk.red(err.message));
|
|
734
863
|
process.exit(1);
|
|
735
864
|
}
|
|
736
865
|
});
|
|
@@ -1160,23 +1289,34 @@ Examples:
|
|
|
1160
1289
|
});
|
|
1161
1290
|
cmd
|
|
1162
1291
|
.command('import [bundle]')
|
|
1163
|
-
.description('Import keys from a .env file
|
|
1164
|
-
.option('--from <
|
|
1165
|
-
.
|
|
1166
|
-
.
|
|
1292
|
+
.description('Import keys into a bundle from a .env file, a 1Password vault, or legacy iCloud Keychain bundles. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
|
|
1293
|
+
.option('--from <source>', "Source: a .env path (- reads stdin), '1password:<vault>', or 'icloud' (legacy iCloud Keychain bundles)")
|
|
1294
|
+
.addOption(new Option('--from-1password', 'deprecated alias for --from 1password:<vault>').hideHelp())
|
|
1295
|
+
.addOption(new Option('--vault <name>', 'deprecated: name the vault in --from 1password:<vault>').hideHelp())
|
|
1167
1296
|
.option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
|
|
1168
1297
|
.option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
|
|
1169
1298
|
.option('--force', 'Overwrite an existing key in the bundle')
|
|
1299
|
+
.option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
|
|
1170
1300
|
.action(async (bundleName, opts) => {
|
|
1171
1301
|
try {
|
|
1172
|
-
|
|
1173
|
-
|
|
1302
|
+
const source = parseImportSource(opts);
|
|
1303
|
+
if (opts.purge && source.kind !== 'icloud') {
|
|
1304
|
+
throw new Error('--purge only applies to --from icloud.');
|
|
1174
1305
|
}
|
|
1175
|
-
if (opts.
|
|
1176
|
-
|
|
1306
|
+
if (opts.from1password) {
|
|
1307
|
+
console.log(chalk.yellow('--from-1password is deprecated; use --from 1password:<vault>.'));
|
|
1177
1308
|
}
|
|
1178
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
1179
1309
|
const requestedBackend = parseBackendOpt(opts.backend);
|
|
1310
|
+
if (source.kind === 'icloud') {
|
|
1311
|
+
await importFromICloud(bundleName, {
|
|
1312
|
+
force: opts.force,
|
|
1313
|
+
allPlaintext: opts.allPlaintext,
|
|
1314
|
+
backend: requestedBackend === 'file' ? 'file' : undefined,
|
|
1315
|
+
purge: opts.purge,
|
|
1316
|
+
});
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
1180
1320
|
// Read the bundle if it exists (inheriting its backend); otherwise
|
|
1181
1321
|
// create it with the requested backend so a single `import --backend
|
|
1182
1322
|
// file` works (this is what `export --host ... --remote-backend file`
|
|
@@ -1199,9 +1339,9 @@ Examples:
|
|
|
1199
1339
|
const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
|
|
1200
1340
|
let added = 0;
|
|
1201
1341
|
let skipped = 0;
|
|
1202
|
-
if (
|
|
1342
|
+
if (source.kind === '1password') {
|
|
1203
1343
|
assertOpAvailable();
|
|
1204
|
-
const vault = await resolveVault(
|
|
1344
|
+
const vault = await resolveVault(source.vault);
|
|
1205
1345
|
const items = listItems(vault);
|
|
1206
1346
|
const { secrets, skipped: opSkipped } = extractSecrets(items, vault);
|
|
1207
1347
|
for (const { envKey, value } of secrets) {
|
|
@@ -1226,7 +1366,7 @@ Examples:
|
|
|
1226
1366
|
console.log(chalk.green(`Imported ${added} key(s) from 1Password vault '${vault}'${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
1227
1367
|
}
|
|
1228
1368
|
else {
|
|
1229
|
-
const raw = readImportDotenv(
|
|
1369
|
+
const raw = readImportDotenv(source.path);
|
|
1230
1370
|
const pairs = parseDotenv(raw);
|
|
1231
1371
|
for (const [key, value] of Object.entries(pairs)) {
|
|
1232
1372
|
if (!opts.force && key in bundle.vars) {
|
|
@@ -1720,20 +1860,11 @@ Examples:
|
|
|
1720
1860
|
console.error(chalk.yellow('Aborted.'));
|
|
1721
1861
|
return;
|
|
1722
1862
|
}
|
|
1723
|
-
const wasDaily = bundlePolicy(bundle) === 'daily';
|
|
1724
1863
|
bundle.policy = next;
|
|
1864
|
+
// writeBundle evicts any broker-held copy, so tightening daily ->
|
|
1865
|
+
// always/never takes effect NOW: the next read re-prompts (`always`)
|
|
1866
|
+
// or reads its no-ACL item directly (`never`).
|
|
1725
1867
|
writeBundle(bundle);
|
|
1726
|
-
// Tightening daily -> always/never must take effect NOW, not up to the
|
|
1727
|
-
// ~7d hold later. If the broker is already serving this bundle silently
|
|
1728
|
-
// (auto-cached under the old `daily` policy), evict it so the next read
|
|
1729
|
-
// re-prompts (`always`) or reads its no-ACL item directly (`never`).
|
|
1730
|
-
// macOS-only + best-effort; agentLock no-ops off darwin / with no broker.
|
|
1731
|
-
if (wasDaily && next !== 'daily') {
|
|
1732
|
-
try {
|
|
1733
|
-
await agentLock(bundle.name);
|
|
1734
|
-
}
|
|
1735
|
-
catch { /* broker down — nothing held */ }
|
|
1736
|
-
}
|
|
1737
1868
|
console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
|
|
1738
1869
|
if (next === 'daily') {
|
|
1739
1870
|
console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
|