@phnx-labs/agents-cli 1.20.0 → 1.20.3
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 +73 -0
- package/README.md +4 -4
- package/dist/commands/cli.js +3 -3
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/commands.js +24 -7
- package/dist/commands/exec.js +36 -16
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.js +89 -0
- package/dist/commands/helper.d.ts +12 -0
- package/dist/commands/helper.js +87 -0
- package/dist/commands/hooks.js +86 -7
- package/dist/commands/mcp.js +166 -10
- package/dist/commands/packages.js +196 -27
- package/dist/commands/permissions.js +21 -6
- package/dist/commands/profiles.d.ts +8 -0
- package/dist/commands/profiles.js +117 -4
- package/dist/commands/pull.js +4 -4
- package/dist/commands/routines.js +6 -6
- package/dist/commands/rules.js +8 -4
- package/dist/commands/secrets-migrate.d.ts +24 -0
- package/dist/commands/secrets-migrate.js +198 -0
- package/dist/commands/secrets-sync.d.ts +11 -0
- package/dist/commands/secrets-sync.js +155 -0
- package/dist/commands/secrets.js +74 -39
- package/dist/commands/skills.js +22 -5
- package/dist/commands/subagents.js +69 -49
- package/dist/commands/teams.js +48 -10
- package/dist/commands/utils.d.ts +33 -0
- package/dist/commands/utils.js +139 -0
- package/dist/commands/versions.js +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +164 -8
- package/dist/commands/workflows.js +29 -6
- package/dist/index.js +4 -0
- package/dist/lib/acp/client.js +6 -1
- package/dist/lib/agents.d.ts +4 -0
- package/dist/lib/agents.js +18 -14
- package/dist/lib/auto-pull-worker.js +18 -1
- package/dist/lib/browser/chrome.js +4 -0
- package/dist/lib/browser/drivers/ssh.js +1 -1
- package/dist/lib/browser/profiles.d.ts +3 -3
- package/dist/lib/browser/profiles.js +3 -3
- package/dist/lib/browser/service.js +19 -0
- package/dist/lib/browser/types.d.ts +4 -4
- package/dist/lib/cli-resources.d.ts +36 -8
- package/dist/lib/cli-resources.js +268 -46
- package/dist/lib/cloud/factory.d.ts +1 -1
- package/dist/lib/cloud/factory.js +1 -1
- package/dist/lib/events.d.ts +16 -2
- package/dist/lib/events.js +33 -2
- package/dist/lib/exec.d.ts +39 -11
- package/dist/lib/exec.js +90 -31
- package/dist/lib/help.js +11 -5
- package/dist/lib/hooks/cache.d.ts +38 -0
- package/dist/lib/hooks/cache.js +242 -0
- package/dist/lib/hooks/profile.d.ts +33 -0
- package/dist/lib/hooks/profile.js +129 -0
- package/dist/lib/hooks.d.ts +0 -10
- package/dist/lib/hooks.js +68 -15
- package/dist/lib/mcp.d.ts +15 -0
- package/dist/lib/mcp.js +40 -0
- package/dist/lib/permissions.d.ts +13 -0
- package/dist/lib/permissions.js +51 -1
- package/dist/lib/plugins.js +15 -1
- package/dist/lib/profiles-presets.d.ts +26 -0
- package/dist/lib/profiles-presets.js +187 -8
- package/dist/lib/profiles.d.ts +34 -0
- package/dist/lib/profiles.js +112 -1
- package/dist/lib/routines-format.d.ts +17 -5
- package/dist/lib/routines-format.js +37 -16
- package/dist/lib/routines.d.ts +1 -1
- package/dist/lib/routines.js +2 -2
- package/dist/lib/runner.js +64 -10
- 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/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
- package/dist/lib/secrets/bundles.d.ts +18 -22
- package/dist/lib/secrets/bundles.js +75 -99
- package/dist/lib/secrets/index.d.ts +51 -27
- package/dist/lib/secrets/index.js +147 -156
- package/dist/lib/secrets/install-helper.d.ts +45 -0
- package/dist/lib/secrets/install-helper.js +165 -0
- package/dist/lib/secrets/linux.js +4 -4
- package/dist/lib/secrets/sync.d.ts +56 -0
- package/dist/lib/secrets/sync.js +180 -0
- package/dist/lib/session/render.js +4 -4
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/shims.d.ts +4 -1
- package/dist/lib/shims.js +5 -35
- package/dist/lib/state.d.ts +14 -1
- package/dist/lib/state.js +49 -5
- package/dist/lib/teams/agents.d.ts +5 -4
- package/dist/lib/teams/agents.js +47 -21
- package/dist/lib/teams/api.d.ts +2 -1
- package/dist/lib/teams/api.js +4 -3
- package/dist/lib/types.d.ts +57 -1
- package/dist/lib/types.js +2 -0
- package/dist/lib/usage.d.ts +27 -2
- package/dist/lib/usage.js +100 -17
- package/dist/lib/versions.d.ts +35 -1
- package/dist/lib/versions.js +267 -64
- package/package.json +9 -8
- package/scripts/install-helper.js +97 -0
- package/scripts/postinstall.js +16 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
|
@@ -8,10 +8,30 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import * as fs from 'fs';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileFromPreset, validateProfileName, getPresetForProfile, } from '../lib/profiles.js';
|
|
13
|
+
import { getPreset, listPresets, expandPreset } from '../lib/profiles-presets.js';
|
|
13
14
|
import { hasKeychainToken, keychainItemName, setKeychainToken, deleteKeychainToken, } from '../lib/secrets/profiles.js';
|
|
14
15
|
import { isInteractiveTerminal } from './utils.js';
|
|
16
|
+
/**
|
|
17
|
+
* Pure helper: builds a Profile from collected wizard inputs. Extracted so the
|
|
18
|
+
* shape of preset->profile mapping for the `create` wizard is unit-testable
|
|
19
|
+
* without mocking @inquirer/prompts.
|
|
20
|
+
*/
|
|
21
|
+
export function buildProfileFromCollection(name, preset, collected, version) {
|
|
22
|
+
return {
|
|
23
|
+
name,
|
|
24
|
+
host: { agent: preset.host, version },
|
|
25
|
+
env: { ...preset.env, ...collected },
|
|
26
|
+
auth: {
|
|
27
|
+
envVar: preset.authEnvVar,
|
|
28
|
+
keychainItem: keychainItemName(preset.provider),
|
|
29
|
+
},
|
|
30
|
+
description: preset.description,
|
|
31
|
+
preset: preset.name,
|
|
32
|
+
provider: preset.provider,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
15
35
|
/** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
|
|
16
36
|
async function promptForSecret(message) {
|
|
17
37
|
if (!isInteractiveTerminal()) {
|
|
@@ -86,7 +106,8 @@ Built-in presets (via OpenRouter, one shared key):
|
|
|
86
106
|
Run 'agents profiles presets' for the full list with pricing and context sizes.
|
|
87
107
|
|
|
88
108
|
Typical flow:
|
|
89
|
-
agents profiles
|
|
109
|
+
agents profiles create # interactive wizard for any provider
|
|
110
|
+
agents profiles add kimi # one-line preset (existing)
|
|
90
111
|
agents run kimi "refactor this" # Claude Code UI, Kimi model responses
|
|
91
112
|
agents profiles add deepseek # reuses OpenRouter key, no re-prompt
|
|
92
113
|
|
|
@@ -202,6 +223,96 @@ Examples:
|
|
|
202
223
|
process.exit(1);
|
|
203
224
|
}
|
|
204
225
|
});
|
|
226
|
+
cmd
|
|
227
|
+
.command('create')
|
|
228
|
+
.description('Interactive profile creation wizard (any provider, with prompts for endpoints + keys).')
|
|
229
|
+
.option('--name <name>', 'Profile name (skips the name prompt)')
|
|
230
|
+
.option('--provider <provider>', 'Provider preset name (skips the provider prompt)')
|
|
231
|
+
.option('--no-smoke-test', 'Skip the post-create smoke test prompt')
|
|
232
|
+
.action(async (opts) => {
|
|
233
|
+
if (!isInteractiveTerminal()) {
|
|
234
|
+
console.error(chalk.red('agents profiles create requires an interactive terminal. Use `agents profiles add <preset>` for scriptable creation.'));
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
const { input, select, confirm } = await import('@inquirer/prompts');
|
|
238
|
+
const name = opts.name
|
|
239
|
+
? opts.name
|
|
240
|
+
: await input({
|
|
241
|
+
message: 'Profile name',
|
|
242
|
+
validate: (v) => /^[a-z0-9][a-z0-9-_]{0,48}$/i.test(v) || 'lowercase alphanumeric + -_ only, max 48 chars',
|
|
243
|
+
});
|
|
244
|
+
validateProfileName(name);
|
|
245
|
+
if (profileExists(name)) {
|
|
246
|
+
const overwrite = await confirm({
|
|
247
|
+
message: `Profile '${name}' already exists. Overwrite?`,
|
|
248
|
+
default: false,
|
|
249
|
+
});
|
|
250
|
+
if (!overwrite) {
|
|
251
|
+
console.log(chalk.gray('Cancelled.'));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const presets = listPresets();
|
|
256
|
+
const providerName = opts.provider
|
|
257
|
+
? opts.provider
|
|
258
|
+
: await select({
|
|
259
|
+
message: 'Provider',
|
|
260
|
+
choices: presets.map((p) => ({
|
|
261
|
+
name: `${p.name.padEnd(18)} ${chalk.gray(p.description.slice(0, 70))}`,
|
|
262
|
+
value: p.name,
|
|
263
|
+
})),
|
|
264
|
+
});
|
|
265
|
+
const preset = getPreset(providerName);
|
|
266
|
+
if (!preset) {
|
|
267
|
+
console.error(chalk.red(`Unknown provider '${providerName}'. Run 'agents profiles presets' for the list.`));
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
const expanded = expandPreset(preset);
|
|
271
|
+
const collected = {};
|
|
272
|
+
for (const v of expanded.prompts) {
|
|
273
|
+
if (v.secret) {
|
|
274
|
+
collected[v.envVar] = await promptForSecret(v.prompt);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
const value = await input({
|
|
278
|
+
message: v.hint ? `${v.prompt} ${chalk.gray('(' + v.hint + ')')}` : v.prompt,
|
|
279
|
+
default: v.default,
|
|
280
|
+
validate: v.pattern
|
|
281
|
+
? (val) => new RegExp(v.pattern).test(val) || `must match ${v.pattern}`
|
|
282
|
+
: undefined,
|
|
283
|
+
});
|
|
284
|
+
collected[v.envVar] = value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!preset.authOptional) {
|
|
288
|
+
await ensureProviderToken(preset.provider, preset.signupUrl);
|
|
289
|
+
}
|
|
290
|
+
const profile = buildProfileFromCollection(name, preset, collected);
|
|
291
|
+
writeProfile(profile);
|
|
292
|
+
console.log(chalk.green(`Profile '${name}' created.`));
|
|
293
|
+
if (preset.docPath) {
|
|
294
|
+
console.log(chalk.gray(`See docs/profiles/${preset.docPath}.md for provider-specific caveats.`));
|
|
295
|
+
}
|
|
296
|
+
if (opts.smokeTest !== false) {
|
|
297
|
+
const run = await confirm({ message: 'Run smoke test now?', default: true });
|
|
298
|
+
if (run) {
|
|
299
|
+
console.log(chalk.gray(`Spawning: agents run ${name} "say alive in one word" (60s timeout)`));
|
|
300
|
+
const child = spawn(process.argv[0], [
|
|
301
|
+
process.argv[1],
|
|
302
|
+
'run',
|
|
303
|
+
name,
|
|
304
|
+
'say alive in one word',
|
|
305
|
+
'--headless',
|
|
306
|
+
'--timeout',
|
|
307
|
+
'60s',
|
|
308
|
+
], { stdio: 'inherit' });
|
|
309
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
console.log(chalk.gray(`Try later: agents run ${name} "hello"`));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
205
316
|
cmd
|
|
206
317
|
.command('add <name>')
|
|
207
318
|
.description('Add a profile. If <name> matches a built-in preset, the preset is applied. Prompts for API key (once per provider).')
|
|
@@ -223,7 +334,9 @@ Examples:
|
|
|
223
334
|
console.error(chalk.gray('Or pass --preset <name> to pick explicitly.'));
|
|
224
335
|
process.exit(1);
|
|
225
336
|
}
|
|
226
|
-
|
|
337
|
+
if (!preset.authOptional) {
|
|
338
|
+
await ensureProviderToken(preset.provider, preset.signupUrl, opts.keyStdin);
|
|
339
|
+
}
|
|
227
340
|
const profile = profileFromPreset(name, preset, opts.version);
|
|
228
341
|
writeProfile(profile);
|
|
229
342
|
console.log(chalk.green(`Profile '${name}' added.`));
|
package/dist/commands/pull.js
CHANGED
|
@@ -13,8 +13,8 @@ import { getUserAgentsDir, ensureAgentsDir, getEnabledExtraRepos, } from '../lib
|
|
|
13
13
|
import { isGitRepo, pullRepo, isSystemRepoOrigin, } from '../lib/git.js';
|
|
14
14
|
import * as fs from 'fs';
|
|
15
15
|
import * as path from 'path';
|
|
16
|
-
import { installVersion, listInstalledVersions, getGlobalDefault, setGlobalDefault, getVersionHomePath, syncResourcesToVersion, getAvailableResources, getActuallySyncedResources, getNewResources, hasNewResources, promptNewResourceSelection, promptResourceSelection, resolveConfiguredAgentTargets, } from '../lib/versions.js';
|
|
17
|
-
import { listCliStatus, installCli, describeMethod, selectInstallMethod, } from '../lib/cli-resources.js';
|
|
16
|
+
import { installVersion, listInstalledVersions, getGlobalDefault, setGlobalDefault, getVersionHomePath, syncResourcesToVersion, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, promptResourceSelection, resolveConfiguredAgentTargets, } from '../lib/versions.js';
|
|
17
|
+
import { listCliStatus, installCli, describeMethod, describeCheck, selectInstallMethod, } from '../lib/cli-resources.js';
|
|
18
18
|
import { ensureShimCurrent, isShimsInPath, addShimsToPath, getPathSetupInstructions, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
|
|
19
19
|
import { parseHookManifest, registerHooksToSettings } from '../lib/hooks.js';
|
|
20
20
|
import { setHelpSections } from '../lib/help.js';
|
|
@@ -219,7 +219,7 @@ export function registerPullCommand(program) {
|
|
|
219
219
|
if (!defaultVer)
|
|
220
220
|
continue;
|
|
221
221
|
const actuallySynced = getActuallySyncedResources(agentId, defaultVer);
|
|
222
|
-
const newResources = getNewResources(available, actuallySynced);
|
|
222
|
+
const newResources = getNewResources(available, actuallySynced, getProjectOnlyResources());
|
|
223
223
|
const hasAnySynced = actuallySynced.commands.length > 0 ||
|
|
224
224
|
actuallySynced.skills.length > 0 ||
|
|
225
225
|
actuallySynced.hooks.length > 0 ||
|
|
@@ -401,7 +401,7 @@ export function registerPullCommand(program) {
|
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
403
|
else {
|
|
404
|
-
console.log(chalk.yellow(` install ran but \`${s.manifest.check}\` still fails`));
|
|
404
|
+
console.log(chalk.yellow(` install ran but \`${describeCheck(s.manifest.check)}\` still fails`));
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
}
|
|
@@ -11,7 +11,7 @@ import * as fs from 'fs';
|
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import * as yaml from 'yaml';
|
|
13
13
|
import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonPid, readDaemonLog, } from '../lib/daemon.js';
|
|
14
|
-
import { humanizeCron, humanizeNextRun, formatRepoLink } from '../lib/routines-format.js';
|
|
14
|
+
import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
|
|
15
15
|
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, } from '../lib/routines.js';
|
|
16
16
|
import { getRoutinesDir } from '../lib/state.js';
|
|
17
17
|
import { safeJoin } from '../lib/paths.js';
|
|
@@ -133,13 +133,13 @@ export function registerRoutinesCommands(program) {
|
|
|
133
133
|
}
|
|
134
134
|
console.log(chalk.bold('Scheduled Jobs\n'));
|
|
135
135
|
// OSC 8 hyperlink helper — renders as a clickable link in supporting terminals.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
const link = (label, url) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
|
|
136
|
+
// Guarded on process.stdout.isTTY so that piped/redirected output never
|
|
137
|
+
// contains raw ESC ] 8 ;; ... BEL escape sequences.
|
|
138
|
+
const link = (label, url) => url && process.stdout.isTTY ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
|
|
139
139
|
const now = new Date();
|
|
140
140
|
const NAME_W = 24;
|
|
141
141
|
const AGENT_W = 10;
|
|
142
|
-
const REPO_W =
|
|
142
|
+
const REPO_W = REPO_DISPLAY_MAX;
|
|
143
143
|
const SCHED_W = 22;
|
|
144
144
|
const ENABLED_W = 10;
|
|
145
145
|
const NEXT_W = 22;
|
|
@@ -184,7 +184,7 @@ export function registerRoutinesCommands(program) {
|
|
|
184
184
|
.option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
|
|
185
185
|
.option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
|
|
186
186
|
.option('-p, --prompt <prompt>', 'Task instruction for the agent')
|
|
187
|
-
.option('-m, --mode <mode>',
|
|
187
|
+
.option('-m, --mode <mode>', "Execution mode: plan (read-only), edit (can write files), auto (smart classifier), or skip (bypass all permission prompts). 'full' accepted as alias for skip.", 'plan')
|
|
188
188
|
.option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
|
|
189
189
|
.option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
|
|
190
190
|
.option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
|
package/dist/commands/rules.js
CHANGED
|
@@ -7,11 +7,11 @@ import { select, checkbox } from '@inquirer/prompts';
|
|
|
7
7
|
import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError, agentLabel, } from '../lib/agents.js';
|
|
8
8
|
import { cloneRepo } from '../lib/git.js';
|
|
9
9
|
import { discoverInstructionsFromRepo, discoverRuleFilesFromRepo, installInstructionsCentrally, uninstallInstructions, listInstalledInstructionsWithScope, instructionsExists, getInstructionsContent, listCentralRules, } from '../lib/rules/rules.js';
|
|
10
|
-
import { listInstalledVersions, getGlobalDefault, resolveVersionAlias, syncResourcesToVersion, promptAgentVersionSelection, getVersionHomePath,
|
|
10
|
+
import { listInstalledVersions, getGlobalDefault, resolveVersionAlias, syncResourcesToVersion, promptAgentVersionSelection, getVersionHomePath, } from '../lib/versions.js';
|
|
11
11
|
import { recordVersionResources, getActiveRulesPreset, setActiveRulesPreset } from '../lib/state.js';
|
|
12
12
|
import { discoverRulesLayers } from '../lib/rules/compose.js';
|
|
13
13
|
import * as yaml from 'yaml';
|
|
14
|
-
import { isPromptCancelled, formatPath, isInteractiveTerminal, parseCommaSeparatedList, printWithPager, requireInteractiveSelection, requireDestructiveArg, } from './utils.js';
|
|
14
|
+
import { isPromptCancelled, formatPath, isInteractiveTerminal, parseCommaSeparatedList, printWithPager, requireInteractiveSelection, requireDestructiveArg, resolveAgentTargetsAutoInstalling, } from './utils.js';
|
|
15
15
|
/** Register the `agents rules` command tree (list, add, view, remove). */
|
|
16
16
|
export function registerRulesCommands(program) {
|
|
17
17
|
const rulesCmd = program
|
|
@@ -317,13 +317,17 @@ Examples:
|
|
|
317
317
|
let selectedAgents;
|
|
318
318
|
let versionSelections;
|
|
319
319
|
if (options.agents) {
|
|
320
|
-
const result =
|
|
320
|
+
const result = await resolveAgentTargetsAutoInstalling(options.agents, ALL_AGENT_IDS, { yes: options.yes });
|
|
321
|
+
if (!result) {
|
|
322
|
+
console.log(chalk.gray('Cancelled.'));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
321
325
|
selectedAgents = result.selectedAgents;
|
|
322
326
|
versionSelections = result.versionSelections;
|
|
323
327
|
}
|
|
324
328
|
else {
|
|
325
329
|
const result = await promptAgentVersionSelection(ALL_AGENT_IDS, {
|
|
326
|
-
skipPrompts: options.yes
|
|
330
|
+
skipPrompts: options.yes,
|
|
327
331
|
});
|
|
328
332
|
selectedAgents = result.selectedAgents;
|
|
329
333
|
versionSelections = result.versionSelections;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets migrate-acl` — refresh existing keychain items so they pick
|
|
3
|
+
* up the new SecAccess ACL written by the signed Agents CLI.app helper.
|
|
4
|
+
*
|
|
5
|
+
* Items created before 1.19.2 (or by `security add-generic-password` directly)
|
|
6
|
+
* may carry the legacy "this-app-only" ACL that prompts the user for a
|
|
7
|
+
* password on every read. Re-writing them through the helper bakes in the
|
|
8
|
+
* empty trusted-app ACL that suppresses the prompt and lets the helper read
|
|
9
|
+
* them under LocalAuthentication instead.
|
|
10
|
+
*
|
|
11
|
+
* Sequence per item:
|
|
12
|
+
* 1. Read the current value (no auth prompt path — uses the unauthenticated
|
|
13
|
+
* `security` CLI for non-sync items, helper `get` for sync items).
|
|
14
|
+
* 2. Append (item, value, sync) to an encrypted backup before any writes.
|
|
15
|
+
* 3. Delete + rewrite via the helper so macOS hands us a fresh ACL on the
|
|
16
|
+
* new item.
|
|
17
|
+
* 4. Read back via the helper to verify the value round-trips.
|
|
18
|
+
*
|
|
19
|
+
* `--dry-run` (default) reports the planned actions. `--commit` performs the
|
|
20
|
+
* writes and produces the backup.
|
|
21
|
+
*/
|
|
22
|
+
import type { Command } from 'commander';
|
|
23
|
+
/** Register `agents secrets migrate-acl` on the parent secrets Command. */
|
|
24
|
+
export declare function registerSecretsMigrateAclCommand(secrets: Command): void;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets migrate-acl` — refresh existing keychain items so they pick
|
|
3
|
+
* up the new SecAccess ACL written by the signed Agents CLI.app helper.
|
|
4
|
+
*
|
|
5
|
+
* Items created before 1.19.2 (or by `security add-generic-password` directly)
|
|
6
|
+
* may carry the legacy "this-app-only" ACL that prompts the user for a
|
|
7
|
+
* password on every read. Re-writing them through the helper bakes in the
|
|
8
|
+
* empty trusted-app ACL that suppresses the prompt and lets the helper read
|
|
9
|
+
* them under LocalAuthentication instead.
|
|
10
|
+
*
|
|
11
|
+
* Sequence per item:
|
|
12
|
+
* 1. Read the current value (no auth prompt path — uses the unauthenticated
|
|
13
|
+
* `security` CLI for non-sync items, helper `get` for sync items).
|
|
14
|
+
* 2. Append (item, value, sync) to an encrypted backup before any writes.
|
|
15
|
+
* 3. Delete + rewrite via the helper so macOS hands us a fresh ACL on the
|
|
16
|
+
* new item.
|
|
17
|
+
* 4. Read back via the helper to verify the value round-trips.
|
|
18
|
+
*
|
|
19
|
+
* `--dry-run` (default) reports the planned actions. `--commit` performs the
|
|
20
|
+
* writes and produces the backup.
|
|
21
|
+
*/
|
|
22
|
+
import chalk from 'chalk';
|
|
23
|
+
import * as crypto from 'crypto';
|
|
24
|
+
import * as fs from 'fs';
|
|
25
|
+
import * as path from 'path';
|
|
26
|
+
import { deleteKeychainToken, getKeychainToken, hasKeychainToken, listKeychainItems, setKeychainToken, } from '../lib/secrets/index.js';
|
|
27
|
+
import { getBackupsDir } from '../lib/state.js';
|
|
28
|
+
import { encryptBlob, MIN_PASSPHRASE_LEN } from '../lib/secrets/sync.js';
|
|
29
|
+
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
30
|
+
const ITEM_PREFIX = 'agents-cli.';
|
|
31
|
+
function enumerateItems() {
|
|
32
|
+
const seen = new Map();
|
|
33
|
+
for (const item of listKeychainItems(ITEM_PREFIX)) {
|
|
34
|
+
// hasKeychainToken with sync=false probes the non-synced keychain; the
|
|
35
|
+
// helper's list returns both. We don't try to distinguish — re-write with
|
|
36
|
+
// sync=false by default and only flip to sync=true if the value is only
|
|
37
|
+
// readable via the synced-only probe.
|
|
38
|
+
const localExists = hasKeychainToken(item);
|
|
39
|
+
seen.set(item, { item, sync: !localExists });
|
|
40
|
+
}
|
|
41
|
+
return [...seen.values()];
|
|
42
|
+
}
|
|
43
|
+
function writeEncryptedBackup(records, passphrase) {
|
|
44
|
+
const dir = getBackupsDir();
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
const iso = new Date().toISOString().replace(/[:.]/g, '-');
|
|
47
|
+
const file = path.join(dir, `keychain-pre-migrate-${iso}.json.enc`);
|
|
48
|
+
const envelope = encryptBlob(JSON.stringify({ v: 1, records }), passphrase);
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify(envelope), { mode: 0o600 });
|
|
50
|
+
return file;
|
|
51
|
+
}
|
|
52
|
+
async function promptPassphrase() {
|
|
53
|
+
if (!isInteractiveTerminal()) {
|
|
54
|
+
throw new Error('A backup passphrase is required. Run from a TTY or set AGENTS_BACKUP_PASSPHRASE.');
|
|
55
|
+
}
|
|
56
|
+
const { password } = await import('@inquirer/prompts');
|
|
57
|
+
const first = await password({ message: 'Backup passphrase (used to encrypt the pre-migration snapshot)', mask: true });
|
|
58
|
+
if (first.length < MIN_PASSPHRASE_LEN)
|
|
59
|
+
throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`);
|
|
60
|
+
const second = await password({ message: 'Confirm passphrase', mask: true });
|
|
61
|
+
if (first !== second)
|
|
62
|
+
throw new Error('Passphrases do not match.');
|
|
63
|
+
return first;
|
|
64
|
+
}
|
|
65
|
+
function migrateOne(record) {
|
|
66
|
+
const { item, value } = record;
|
|
67
|
+
// Delete + re-add to force macOS to bind a fresh ACL on the new item.
|
|
68
|
+
// SecItemUpdate preserves the existing ACL, so an in-place rewrite would
|
|
69
|
+
// not fix the legacy "enter password" prompt.
|
|
70
|
+
try {
|
|
71
|
+
deleteKeychainToken(item);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return { item, status: 'write-failed', detail: `delete: ${err.message}` };
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
setKeychainToken(item, value);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Try to restore the old value so we don't lose data on a write failure.
|
|
81
|
+
// The backup is the durable safety net; this is best-effort UX.
|
|
82
|
+
try {
|
|
83
|
+
setKeychainToken(item, value);
|
|
84
|
+
}
|
|
85
|
+
catch { /* swallow */ }
|
|
86
|
+
return { item, status: 'write-failed', detail: `set: ${err.message}` };
|
|
87
|
+
}
|
|
88
|
+
let readBack;
|
|
89
|
+
try {
|
|
90
|
+
readBack = getKeychainToken(item);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
return { item, status: 'verify-failed', detail: `read-back: ${err.message}` };
|
|
94
|
+
}
|
|
95
|
+
if (readBack !== value) {
|
|
96
|
+
return { item, status: 'verify-failed', detail: 'value mismatch after rewrite' };
|
|
97
|
+
}
|
|
98
|
+
return { item, status: 'ok' };
|
|
99
|
+
}
|
|
100
|
+
/** Register `agents secrets migrate-acl` on the parent secrets Command. */
|
|
101
|
+
export function registerSecretsMigrateAclCommand(secrets) {
|
|
102
|
+
secrets
|
|
103
|
+
.command('migrate-acl')
|
|
104
|
+
.description('Refresh existing keychain ACLs to use the signed Agents CLI helper. Dry-run by default.')
|
|
105
|
+
.option('--commit', 'Perform writes (default is dry-run reporting only)')
|
|
106
|
+
.option('--prefix <p>', `Restrict to items beginning with PREFIX (default ${ITEM_PREFIX})`, ITEM_PREFIX)
|
|
107
|
+
.option('--passphrase-env <var>', 'Read the backup passphrase from this env var instead of prompting')
|
|
108
|
+
.action(async (opts) => {
|
|
109
|
+
try {
|
|
110
|
+
if (process.platform !== 'darwin') {
|
|
111
|
+
throw new Error('migrate-acl is macOS-only. Linux items already use the keyring-native ACL model.');
|
|
112
|
+
}
|
|
113
|
+
const prefix = opts.prefix ?? ITEM_PREFIX;
|
|
114
|
+
if (!prefix.startsWith(ITEM_PREFIX)) {
|
|
115
|
+
throw new Error(`--prefix must start with '${ITEM_PREFIX}' to avoid touching unrelated Keychain items (got '${prefix}').`);
|
|
116
|
+
}
|
|
117
|
+
const items = listKeychainItems(prefix).map((item) => {
|
|
118
|
+
const localExists = hasKeychainToken(item);
|
|
119
|
+
return { item, sync: !localExists };
|
|
120
|
+
});
|
|
121
|
+
if (items.length === 0) {
|
|
122
|
+
console.log(chalk.gray(`No keychain items with prefix '${prefix}'.`));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
console.log(chalk.bold(`Found ${items.length} item(s) under '${prefix}'.`));
|
|
126
|
+
if (!opts.commit) {
|
|
127
|
+
for (const { item, sync } of items) {
|
|
128
|
+
console.log(` ${chalk.cyan(item)} ${chalk.gray(sync ? '(synced)' : '(local)')}`);
|
|
129
|
+
}
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(chalk.gray('Dry-run — pass --commit to perform the migration.'));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Commit phase. Snapshot every value first, encrypt, then mutate.
|
|
135
|
+
const records = [];
|
|
136
|
+
for (const { item, sync } of items) {
|
|
137
|
+
try {
|
|
138
|
+
const value = getKeychainToken(item);
|
|
139
|
+
records.push({ item, sync, value });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error(chalk.red(`Skipping '${item}': read failed (${err.message}).`));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (records.length === 0) {
|
|
146
|
+
console.error(chalk.red('No items could be read. Aborting before any writes.'));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
const passphrase = opts.passphraseEnv
|
|
150
|
+
? (() => {
|
|
151
|
+
const v = process.env[opts.passphraseEnv];
|
|
152
|
+
if (!v)
|
|
153
|
+
throw new Error(`Env var '${opts.passphraseEnv}' not set.`);
|
|
154
|
+
if (v.length < MIN_PASSPHRASE_LEN)
|
|
155
|
+
throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`);
|
|
156
|
+
return v;
|
|
157
|
+
})()
|
|
158
|
+
: await promptPassphrase();
|
|
159
|
+
const backupPath = writeEncryptedBackup(records, passphrase);
|
|
160
|
+
// Compute a quick fingerprint so the user can sanity-check recovery without
|
|
161
|
+
// decrypting. Hash of (count, sorted item names).
|
|
162
|
+
const fingerprint = crypto
|
|
163
|
+
.createHash('sha256')
|
|
164
|
+
.update(records.length + '\n' + records.map((r) => r.item).sort().join('\n'))
|
|
165
|
+
.digest('hex')
|
|
166
|
+
.slice(0, 12);
|
|
167
|
+
console.log(chalk.green(`Encrypted backup written to ${backupPath} (sha256-12: ${fingerprint}).`));
|
|
168
|
+
const results = [];
|
|
169
|
+
for (const record of records) {
|
|
170
|
+
const r = migrateOne(record);
|
|
171
|
+
results.push(r);
|
|
172
|
+
if (r.status === 'ok') {
|
|
173
|
+
console.log(` ${chalk.green('ok')} ${record.item}`);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
console.log(` ${chalk.red(r.status)} ${record.item} ${chalk.gray(r.detail ?? '')}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const okCount = results.filter((r) => r.status === 'ok').length;
|
|
180
|
+
const failCount = results.length - okCount;
|
|
181
|
+
console.log();
|
|
182
|
+
if (failCount === 0) {
|
|
183
|
+
console.log(chalk.green(`Migrated ${okCount}/${results.length} item(s).`));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.error(chalk.yellow(`Migrated ${okCount}/${results.length} item(s); ${failCount} failed.`));
|
|
187
|
+
console.error(chalk.gray(`Restore from ${backupPath} using the backup passphrase if needed.`));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (isPromptCancelled(err))
|
|
193
|
+
return;
|
|
194
|
+
console.error(chalk.red(err.message));
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets push|pull` subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Replaces iCloud Keychain as the cross-device sync mechanism with explicit
|
|
5
|
+
* encrypted-at-rest sync against api.prix.dev. Plaintext never leaves the
|
|
6
|
+
* machine — bundle contents are sealed with AES-256-GCM under a user-supplied
|
|
7
|
+
* passphrase before upload.
|
|
8
|
+
*/
|
|
9
|
+
import type { Command } from 'commander';
|
|
10
|
+
/** Register `agents secrets push|pull|remote-list` on the parent secrets Command. */
|
|
11
|
+
export declare function registerSecretsSyncCommands(secrets: Command): void;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets push|pull` subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Replaces iCloud Keychain as the cross-device sync mechanism with explicit
|
|
5
|
+
* encrypted-at-rest sync against api.prix.dev. Plaintext never leaves the
|
|
6
|
+
* machine — bundle contents are sealed with AES-256-GCM under a user-supplied
|
|
7
|
+
* passphrase before upload.
|
|
8
|
+
*/
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { listRemoteBundles, MIN_PASSPHRASE_LEN, pullBundle, pushBundle, } from '../lib/secrets/sync.js';
|
|
11
|
+
import { bundleExists, listBundles } from '../lib/secrets/bundles.js';
|
|
12
|
+
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
13
|
+
async function promptPassphrase(message, confirm = false) {
|
|
14
|
+
if (!isInteractiveTerminal()) {
|
|
15
|
+
throw new Error('A sync passphrase is required. Run from a TTY, or set AGENTS_SECRETS_PASSPHRASE.');
|
|
16
|
+
}
|
|
17
|
+
const { password } = await import('@inquirer/prompts');
|
|
18
|
+
const first = await password({ message, mask: true });
|
|
19
|
+
if (first.length < MIN_PASSPHRASE_LEN)
|
|
20
|
+
throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`);
|
|
21
|
+
if (!confirm)
|
|
22
|
+
return first;
|
|
23
|
+
const second = await password({ message: 'Confirm passphrase', mask: true });
|
|
24
|
+
if (first !== second)
|
|
25
|
+
throw new Error('Passphrases do not match.');
|
|
26
|
+
return first;
|
|
27
|
+
}
|
|
28
|
+
// Print the env-var-source warning at most once per process so a `--all` push
|
|
29
|
+
// over many bundles doesn't flood stderr with the same notice.
|
|
30
|
+
let envPassphraseWarned = false;
|
|
31
|
+
function passphraseFromEnvOrPrompt(confirm) {
|
|
32
|
+
const fromEnv = process.env.AGENTS_SECRETS_PASSPHRASE;
|
|
33
|
+
if (fromEnv) {
|
|
34
|
+
if (fromEnv.length < MIN_PASSPHRASE_LEN) {
|
|
35
|
+
return Promise.reject(new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`));
|
|
36
|
+
}
|
|
37
|
+
if (!envPassphraseWarned) {
|
|
38
|
+
envPassphraseWarned = true;
|
|
39
|
+
process.stderr.write(chalk.yellow('warn: using AGENTS_SECRETS_PASSPHRASE. Env vars are readable by other same-user processes ' +
|
|
40
|
+
'(/proc, ps, crash dumps, CI logs) — rotate the passphrase after CI use.\n'));
|
|
41
|
+
}
|
|
42
|
+
return Promise.resolve(fromEnv);
|
|
43
|
+
}
|
|
44
|
+
return promptPassphrase('Sync passphrase', confirm);
|
|
45
|
+
}
|
|
46
|
+
/** Strip C0/C1 control bytes from server-supplied strings before terminal print. */
|
|
47
|
+
function safePrint(s) {
|
|
48
|
+
return s.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, '');
|
|
49
|
+
}
|
|
50
|
+
/** Register `agents secrets push|pull|remote-list` on the parent secrets Command. */
|
|
51
|
+
export function registerSecretsSyncCommands(secrets) {
|
|
52
|
+
secrets
|
|
53
|
+
.command('push [name]')
|
|
54
|
+
.description('Encrypt a local bundle and upload it to api.prix.dev (replaces iCloud Keychain sync).')
|
|
55
|
+
.option('--all', 'Push every local bundle (each prompts independently if no passphrase env var is set)')
|
|
56
|
+
.action(async (name, opts) => {
|
|
57
|
+
try {
|
|
58
|
+
if (opts.all) {
|
|
59
|
+
const bundles = listBundles();
|
|
60
|
+
if (bundles.length === 0) {
|
|
61
|
+
console.log(chalk.gray('No local bundles to push.'));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const passphrase = await passphraseFromEnvOrPrompt(true);
|
|
65
|
+
for (const b of bundles) {
|
|
66
|
+
try {
|
|
67
|
+
const { updated_at } = await pushBundle(b.name, { passphrase });
|
|
68
|
+
console.log(chalk.green(`Pushed '${b.name}' (updated_at=${updated_at}).`));
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error(chalk.red(`Failed to push '${b.name}': ${err.message}`));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!name) {
|
|
77
|
+
throw new Error('Bundle name required. Try: agents secrets push <name> | agents secrets push --all');
|
|
78
|
+
}
|
|
79
|
+
if (!bundleExists(name)) {
|
|
80
|
+
throw new Error(`Bundle '${name}' not found locally.`);
|
|
81
|
+
}
|
|
82
|
+
const passphrase = await passphraseFromEnvOrPrompt(true);
|
|
83
|
+
const { updated_at } = await pushBundle(name, { passphrase });
|
|
84
|
+
console.log(chalk.green(`Pushed '${name}' to api.prix.dev (updated_at=${updated_at}).`));
|
|
85
|
+
console.log(chalk.gray('Remember the passphrase — it is required to pull this bundle on another machine.'));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (isPromptCancelled(err))
|
|
89
|
+
return;
|
|
90
|
+
console.error(chalk.red(err.message));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
secrets
|
|
95
|
+
.command('pull [name]')
|
|
96
|
+
.description('Decrypt a remote bundle from api.prix.dev and restore it into the local keychain.')
|
|
97
|
+
.option('--all', 'Pull every bundle visible on the remote')
|
|
98
|
+
.option('--force', 'Overwrite a local bundle with the same name')
|
|
99
|
+
.action(async (name, opts) => {
|
|
100
|
+
try {
|
|
101
|
+
if (opts.all) {
|
|
102
|
+
const remote = await listRemoteBundles();
|
|
103
|
+
if (remote.length === 0) {
|
|
104
|
+
console.log(chalk.gray('No remote bundles found.'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const passphrase = await passphraseFromEnvOrPrompt(false);
|
|
108
|
+
for (const r of remote) {
|
|
109
|
+
try {
|
|
110
|
+
await pullBundle(r.name, { passphrase, force: opts.force });
|
|
111
|
+
console.log(chalk.green(`Pulled '${r.name}'.`));
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.error(chalk.red(`Failed to pull '${r.name}': ${err.message}`));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!name) {
|
|
120
|
+
throw new Error('Bundle name required. Try: agents secrets pull <name> | agents secrets pull --all');
|
|
121
|
+
}
|
|
122
|
+
const passphrase = await passphraseFromEnvOrPrompt(false);
|
|
123
|
+
const bundle = await pullBundle(name, { passphrase, force: opts.force });
|
|
124
|
+
const keyCount = Object.keys(bundle.vars).length;
|
|
125
|
+
console.log(chalk.green(`Pulled '${name}' (${keyCount} key${keyCount === 1 ? '' : 's'}) into local keychain.`));
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (isPromptCancelled(err))
|
|
129
|
+
return;
|
|
130
|
+
console.error(chalk.red(err.message));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
secrets
|
|
135
|
+
.command('remote-list')
|
|
136
|
+
.alias('remote-ls')
|
|
137
|
+
.description('List bundles currently stored on api.prix.dev for this account.')
|
|
138
|
+
.action(async () => {
|
|
139
|
+
try {
|
|
140
|
+
const remote = await listRemoteBundles();
|
|
141
|
+
if (remote.length === 0) {
|
|
142
|
+
console.log(chalk.gray('No remote bundles found.'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
console.log(chalk.bold(`${'NAME'.padEnd(24)} UPDATED_AT`));
|
|
146
|
+
for (const r of remote) {
|
|
147
|
+
console.log(`${chalk.cyan(safePrint(r.name).padEnd(24))} ${chalk.gray(safePrint(r.updated_at))}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
console.error(chalk.red(err.message));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|