@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +4 -4
  3. package/dist/commands/cli.js +3 -3
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/commands.js +24 -7
  6. package/dist/commands/exec.js +36 -16
  7. package/dist/commands/feedback.d.ts +7 -0
  8. package/dist/commands/feedback.js +89 -0
  9. package/dist/commands/helper.d.ts +12 -0
  10. package/dist/commands/helper.js +87 -0
  11. package/dist/commands/hooks.js +86 -7
  12. package/dist/commands/mcp.js +166 -10
  13. package/dist/commands/packages.js +196 -27
  14. package/dist/commands/permissions.js +21 -6
  15. package/dist/commands/profiles.d.ts +8 -0
  16. package/dist/commands/profiles.js +117 -4
  17. package/dist/commands/pull.js +4 -4
  18. package/dist/commands/routines.js +6 -6
  19. package/dist/commands/rules.js +8 -4
  20. package/dist/commands/secrets-migrate.d.ts +24 -0
  21. package/dist/commands/secrets-migrate.js +198 -0
  22. package/dist/commands/secrets-sync.d.ts +11 -0
  23. package/dist/commands/secrets-sync.js +155 -0
  24. package/dist/commands/secrets.js +74 -39
  25. package/dist/commands/skills.js +22 -5
  26. package/dist/commands/subagents.js +69 -49
  27. package/dist/commands/teams.js +48 -10
  28. package/dist/commands/utils.d.ts +33 -0
  29. package/dist/commands/utils.js +139 -0
  30. package/dist/commands/versions.js +4 -4
  31. package/dist/commands/view.d.ts +6 -0
  32. package/dist/commands/view.js +164 -8
  33. package/dist/commands/workflows.js +29 -6
  34. package/dist/index.js +4 -0
  35. package/dist/lib/acp/client.js +6 -1
  36. package/dist/lib/agents.d.ts +4 -0
  37. package/dist/lib/agents.js +18 -14
  38. package/dist/lib/auto-pull-worker.js +18 -1
  39. package/dist/lib/browser/chrome.js +4 -0
  40. package/dist/lib/browser/drivers/ssh.js +1 -1
  41. package/dist/lib/browser/profiles.d.ts +3 -3
  42. package/dist/lib/browser/profiles.js +3 -3
  43. package/dist/lib/browser/service.js +19 -0
  44. package/dist/lib/browser/types.d.ts +4 -4
  45. package/dist/lib/cli-resources.d.ts +36 -8
  46. package/dist/lib/cli-resources.js +268 -46
  47. package/dist/lib/cloud/factory.d.ts +1 -1
  48. package/dist/lib/cloud/factory.js +1 -1
  49. package/dist/lib/events.d.ts +16 -2
  50. package/dist/lib/events.js +33 -2
  51. package/dist/lib/exec.d.ts +39 -11
  52. package/dist/lib/exec.js +90 -31
  53. package/dist/lib/help.js +11 -5
  54. package/dist/lib/hooks/cache.d.ts +38 -0
  55. package/dist/lib/hooks/cache.js +242 -0
  56. package/dist/lib/hooks/profile.d.ts +33 -0
  57. package/dist/lib/hooks/profile.js +129 -0
  58. package/dist/lib/hooks.d.ts +0 -10
  59. package/dist/lib/hooks.js +68 -15
  60. package/dist/lib/mcp.d.ts +15 -0
  61. package/dist/lib/mcp.js +40 -0
  62. package/dist/lib/permissions.d.ts +13 -0
  63. package/dist/lib/permissions.js +51 -1
  64. package/dist/lib/plugins.js +15 -1
  65. package/dist/lib/profiles-presets.d.ts +26 -0
  66. package/dist/lib/profiles-presets.js +187 -8
  67. package/dist/lib/profiles.d.ts +34 -0
  68. package/dist/lib/profiles.js +112 -1
  69. package/dist/lib/routines-format.d.ts +17 -5
  70. package/dist/lib/routines-format.js +37 -16
  71. package/dist/lib/routines.d.ts +1 -1
  72. package/dist/lib/routines.js +2 -2
  73. package/dist/lib/runner.js +64 -10
  74. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
  77. package/dist/lib/secrets/bundles.d.ts +18 -22
  78. package/dist/lib/secrets/bundles.js +75 -99
  79. package/dist/lib/secrets/index.d.ts +51 -27
  80. package/dist/lib/secrets/index.js +147 -156
  81. package/dist/lib/secrets/install-helper.d.ts +45 -0
  82. package/dist/lib/secrets/install-helper.js +165 -0
  83. package/dist/lib/secrets/linux.js +4 -4
  84. package/dist/lib/secrets/sync.d.ts +56 -0
  85. package/dist/lib/secrets/sync.js +180 -0
  86. package/dist/lib/session/render.js +4 -4
  87. package/dist/lib/session/types.d.ts +1 -1
  88. package/dist/lib/shims.d.ts +4 -1
  89. package/dist/lib/shims.js +5 -35
  90. package/dist/lib/state.d.ts +14 -1
  91. package/dist/lib/state.js +49 -5
  92. package/dist/lib/teams/agents.d.ts +5 -4
  93. package/dist/lib/teams/agents.js +47 -21
  94. package/dist/lib/teams/api.d.ts +2 -1
  95. package/dist/lib/teams/api.js +4 -3
  96. package/dist/lib/types.d.ts +57 -1
  97. package/dist/lib/types.js +2 -0
  98. package/dist/lib/usage.d.ts +27 -2
  99. package/dist/lib/usage.js +100 -17
  100. package/dist/lib/versions.d.ts +35 -1
  101. package/dist/lib/versions.js +267 -64
  102. package/package.json +9 -8
  103. package/scripts/install-helper.js +97 -0
  104. package/scripts/postinstall.js +16 -0
  105. 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 { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileFromPreset, getPresetForProfile, } from '../lib/profiles.js';
12
- import { getPreset, listPresets } from '../lib/profiles-presets.js';
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 add kimi # prompts for OpenRouter key, stored in Keychain
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
- await ensureProviderToken(preset.provider, preset.signupUrl, opts.keyStdin);
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.`));
@@ -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
- // In terminals that do not support OSC 8 the escape sequences are ignored and
137
- // the label is displayed as plain text.
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 = 24;
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>', 'Execution mode: plan (read-only) or edit (can write files)', 'plan')
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)')
@@ -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, resolveAgentVersionTargets, } from '../lib/versions.js';
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 = resolveAgentVersionTargets(options.agents, ALL_AGENT_IDS);
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 || !isInteractiveTerminal(),
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
+ }