@phnx-labs/agents-cli 1.20.28 → 1.20.30

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 (63) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/exec.js +22 -10
  5. package/dist/commands/inspect.js +1 -1
  6. package/dist/commands/models.js +8 -2
  7. package/dist/commands/secrets.js +93 -6
  8. package/dist/commands/sessions.js +157 -44
  9. package/dist/commands/ssh.d.ts +14 -0
  10. package/dist/commands/ssh.js +263 -0
  11. package/dist/commands/sync.js +70 -14
  12. package/dist/index.js +2 -1
  13. package/dist/lib/agents.d.ts +0 -4
  14. package/dist/lib/agents.js +54 -5
  15. package/dist/lib/browser/drivers/ssh.js +4 -35
  16. package/dist/lib/computer-rpc.d.ts +6 -1
  17. package/dist/lib/computer-rpc.js +86 -3
  18. package/dist/lib/devices/connect.d.ts +34 -0
  19. package/dist/lib/devices/connect.js +101 -0
  20. package/dist/lib/devices/registry.d.ts +78 -0
  21. package/dist/lib/devices/registry.js +168 -0
  22. package/dist/lib/devices/ssh-config.d.ts +21 -0
  23. package/dist/lib/devices/ssh-config.js +33 -0
  24. package/dist/lib/devices/tailscale.d.ts +31 -0
  25. package/dist/lib/devices/tailscale.js +126 -0
  26. package/dist/lib/exec.js +14 -0
  27. package/dist/lib/models.js +138 -5
  28. package/dist/lib/runner.js +7 -7
  29. package/dist/lib/secrets/remote.d.ts +67 -0
  30. package/dist/lib/secrets/remote.js +133 -0
  31. package/dist/lib/session/active.d.ts +13 -0
  32. package/dist/lib/session/active.js +79 -18
  33. package/dist/lib/session/cloud.js +2 -0
  34. package/dist/lib/session/db.d.ts +12 -0
  35. package/dist/lib/session/db.js +66 -9
  36. package/dist/lib/session/discover.d.ts +7 -0
  37. package/dist/lib/session/discover.js +309 -0
  38. package/dist/lib/session/parse.d.ts +22 -0
  39. package/dist/lib/session/parse.js +132 -2
  40. package/dist/lib/session/remote.d.ts +1 -1
  41. package/dist/lib/session/remote.js +8 -3
  42. package/dist/lib/session/state.d.ts +82 -0
  43. package/dist/lib/session/state.js +221 -0
  44. package/dist/lib/session/tail.d.ts +18 -0
  45. package/dist/lib/session/tail.js +57 -0
  46. package/dist/lib/session/types.d.ts +10 -1
  47. package/dist/lib/session/types.js +1 -1
  48. package/dist/lib/session/width.d.ts +29 -0
  49. package/dist/lib/session/width.js +91 -0
  50. package/dist/lib/shims.d.ts +17 -1
  51. package/dist/lib/shims.js +130 -6
  52. package/dist/lib/ssh-tunnel.d.ts +127 -0
  53. package/dist/lib/ssh-tunnel.js +346 -0
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +3 -0
  56. package/dist/lib/state.d.ts +4 -0
  57. package/dist/lib/state.js +19 -1
  58. package/dist/lib/teams/agents.d.ts +11 -1
  59. package/dist/lib/teams/agents.js +16 -2
  60. package/dist/lib/types.d.ts +1 -0
  61. package/dist/lib/versions.d.ts +19 -0
  62. package/dist/lib/versions.js +84 -24
  63. package/package.json +1 -1
@@ -170,11 +170,15 @@ function emit(result, json, human) {
170
170
  console.log(human());
171
171
  }
172
172
  }
173
- // Add the shared --pid/--bundle target options to a verb.
173
+ // Add the shared --pid/--bundle/--host target options to a verb. `--host` routes
174
+ // the verb at a remote Windows device: the `computer` preAction hook hydrates
175
+ // COMPUTER_HELPER_TCP from the tunnel `start --host` recorded, so withClient's
176
+ // openComputerClient() transparently selects the TCP transport.
174
177
  function addTargetOpts(cmd) {
175
178
  return cmd
176
179
  .option('--bundle <id>', 'Bundle id of the target app (default: frontmost allow-listed app)')
177
- .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10));
180
+ .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10))
181
+ .option('--host <device>', 'Drive a remote Windows device (requires `agents computer start --host <device>` first)');
178
182
  }
179
183
  // Add the shared --id/--x/--y element-or-coords options to a verb.
180
184
  function addElementOrCoordOpts(cmd) {
@@ -1,5 +1,17 @@
1
1
  import { Command } from 'commander';
2
2
  import { resolveHelperExec, resolveSocketPath } from '../lib/computer-rpc.js';
3
+ /**
4
+ * Pure platform gate. The computer subsystem is macOS-only for LOCAL driving
5
+ * (Accessibility / launchctl). It is NOT blocked off macOS when a remote daemon
6
+ * is reachable — either a configured TCP endpoint (COMPUTER_HELPER_TCP, e.g. a
7
+ * Windows daemon over a tunnel) or a `--host <device>` remote invocation. Kept
8
+ * pure so the gating rule is unit-testable without a live command tree.
9
+ */
10
+ export declare function shouldBlockOffPlatform(opts: {
11
+ platform: NodeJS.Platform;
12
+ tcpConfigured: boolean;
13
+ host?: string;
14
+ }): boolean;
3
15
  export declare function registerComputerCommand(program: Command): void;
4
16
  export declare function registerComputerSubcommands(program: Command): void;
5
17
  export { resolveHelperExec as resolveHelperPath };
@@ -3,7 +3,8 @@ import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import { registerCommandGroups } from '../lib/help.js';
6
- import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer-rpc.js';
6
+ import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer-rpc.js';
7
+ import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, } from '../lib/ssh-tunnel.js';
7
8
  import { registerActionCommands, withClient, unwrap, pickTarget } from './computer-actions.js';
8
9
  // Help groups — mirror `agents browser` so the mental model carries over.
9
10
  const COMPUTER_HELP_GROUPS = [
@@ -12,15 +13,44 @@ const COMPUTER_HELP_GROUPS = [
12
13
  { title: 'Observe', names: ['apps', 'describe', 'screenshot', 'get-text'] },
13
14
  { title: 'Interact', names: ['launch', 'raise', 'click', 'right-click', 'type', 'type-text', 'key', 'drag', 'scroll', 'ax-action', 'focus', 'wait'] },
14
15
  ];
16
+ // Subcommands that manage the `--host` remote path themselves (provisioning /
17
+ // tunnel lifecycle). Every other `--host`-bearing subcommand is a plain verb
18
+ // that just needs the TCP endpoint hydrated before it runs.
19
+ const REMOTE_LIFECYCLE = new Set(['setup', 'start', 'stop']);
20
+ /**
21
+ * Pure platform gate. The computer subsystem is macOS-only for LOCAL driving
22
+ * (Accessibility / launchctl). It is NOT blocked off macOS when a remote daemon
23
+ * is reachable — either a configured TCP endpoint (COMPUTER_HELPER_TCP, e.g. a
24
+ * Windows daemon over a tunnel) or a `--host <device>` remote invocation. Kept
25
+ * pure so the gating rule is unit-testable without a live command tree.
26
+ */
27
+ export function shouldBlockOffPlatform(opts) {
28
+ if (opts.platform === 'darwin')
29
+ return false;
30
+ if (opts.tcpConfigured)
31
+ return false; // remote (Windows) daemon over a tunnel
32
+ if (opts.host)
33
+ return false; // remote path resolves its own endpoint
34
+ return true;
35
+ }
15
36
  export function registerComputerCommand(program) {
16
37
  const computer = program
17
38
  .command('computer')
18
- .description('Drive macOS apps via Accessibility — list, screenshot, click, type (macOS only)')
19
- // The whole subsystem is macOS Accessibility / TCC. Fail fast with a clear
20
- // message on other platforms instead of a downstream ENOENT / launchctl error.
21
- .hook('preAction', () => {
22
- if (process.platform !== 'darwin') {
23
- console.error('agents computer: macOS only it drives apps via the macOS Accessibility API.');
39
+ .description('Drive macOS apps via Accessibility, or a remote Windows host with --host — list, screenshot, click, type')
40
+ // The whole subsystem is macOS Accessibility / TCC for LOCAL driving. Off
41
+ // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, or
42
+ // a `--host <device>` invocation). Fail fast with a clear message only when
43
+ // neither remote path is available, instead of a downstream launchctl error.
44
+ .hook('preAction', async (_thisCommand, actionCommand) => {
45
+ const host = actionCommand.opts().host;
46
+ // Verbs with --host reconnect to the tunnel `start --host` recorded; this
47
+ // sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
48
+ if (host && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
49
+ hydrateRemoteEnvFromState(host);
50
+ }
51
+ if (shouldBlockOffPlatform({ platform: process.platform, tcpConfigured: resolveTcpEndpoint() != null, host })) {
52
+ console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
53
+ console.error('For a remote Windows host: register it with `agents devices`, then use --host (or set COMPUTER_HELPER_TCP).');
24
54
  process.exit(1);
25
55
  }
26
56
  });
@@ -94,6 +124,7 @@ function registerScreenshotCommand(program) {
94
124
  .description('Capture a window (default: largest), enumerate windows (--list), or the whole display (--display)')
95
125
  .option('--bundle <id>', 'Bundle id to capture (default: frontmost allow-listed app)')
96
126
  .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10))
127
+ .option('--host <device>', 'Drive a remote Windows device (requires `agents computer start --host <device>` first)')
97
128
  .option('--list', 'List the app\'s windows (id/title/layer/bounds) instead of capturing — reveals modals/popups')
98
129
  .option('--window-id <n>', 'Capture a specific window by id (from --list)', (v) => parseInt(v, 10))
99
130
  .option('--display', 'Capture the whole display the app is on (composites stacked modals)')
@@ -181,8 +212,23 @@ function registerSetupCommand(program) {
181
212
  program
182
213
  .command('setup')
183
214
  .alias('install-helper')
184
- .description('Install ComputerHelper.app to /Applications/ (does NOT activate the daemon run `start` to enable)')
185
- .action(async () => {
215
+ .description('Install the helper — locally to /Applications/ (macOS), or to a remote Windows host with --host')
216
+ .option('--host <device>', 'Provision a remote Windows device (push the exe + register a LOGON task) instead of installing locally')
217
+ .action(async (opts) => {
218
+ if (opts.host) {
219
+ try {
220
+ const { target, taskName } = await setupRemoteHelper(opts.host);
221
+ console.log(`pushed computer-helper-win.exe to ${target}`);
222
+ console.log(`registered LOGON scheduled task "${taskName}" (interactive session, started now)`);
223
+ console.log('');
224
+ console.log(`Next: agents computer start --host ${opts.host}`);
225
+ }
226
+ catch (err) {
227
+ console.error(`error: ${err.message}`);
228
+ process.exit(1);
229
+ }
230
+ return;
231
+ }
186
232
  const srcApp = resolveHelperApp();
187
233
  if (!srcApp || !fs.existsSync(srcApp)) {
188
234
  console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
@@ -266,8 +312,24 @@ function registerSetupCommand(program) {
266
312
  function registerStartCommand(program) {
267
313
  program
268
314
  .command('start')
269
- .description('Activate the helper daemon (loads launchd, opens socket)')
270
- .action(async () => {
315
+ .description('Activate the helper daemon local launchd (macOS) or a remote Windows tunnel with --host')
316
+ .option('--host <device>', 'Open a tunnel to the remote Windows daemon and record it for --host verbs')
317
+ .action(async (opts) => {
318
+ if (opts.host) {
319
+ try {
320
+ const state = await startRemoteTunnel(opts.host);
321
+ console.log(`tunnel: 127.0.0.1:${state.localPort} -> ${state.target} (127.0.0.1:${state.remotePort})`);
322
+ console.log(`daemon: answering (ssh pid ${state.tunnelPid})`);
323
+ console.log('');
324
+ console.log(`Drive it: agents computer apps --host ${opts.host}`);
325
+ console.log(`Stop: agents computer stop --host ${opts.host}`);
326
+ }
327
+ catch (err) {
328
+ console.error(`error: ${err.message}`);
329
+ process.exit(1);
330
+ }
331
+ return;
332
+ }
271
333
  const home = os.homedir();
272
334
  const plistPath = path.join(home, 'Library', 'LaunchAgents', `${HELPER_LABEL}.plist`);
273
335
  const socketPath = resolveSocketPath();
@@ -440,8 +502,21 @@ function registerReloadCommand(program) {
440
502
  function registerStopCommand(program) {
441
503
  program
442
504
  .command('stop')
443
- .description('Deactivate the helper daemon (bootout, removes socket)')
444
- .action(async () => {
505
+ .description('Deactivate the helper daemon — local launchd (macOS) or a remote Windows tunnel with --host')
506
+ .option('--host <device>', 'Tear down the remote tunnel and unregister the scheduled task')
507
+ .action(async (opts) => {
508
+ if (opts.host) {
509
+ try {
510
+ const { tunnelKilled, taskRemoved } = await stopRemoteHelper(opts.host);
511
+ console.log(`tunnel: ${tunnelKilled ? 'closed' : 'not running'}`);
512
+ console.log(`task: ${taskRemoved ? 'unregistered' : 'not removed (device offline?)'}`);
513
+ }
514
+ catch (err) {
515
+ console.error(`error: ${err.message}`);
516
+ process.exit(1);
517
+ }
518
+ return;
519
+ }
445
520
  const home = os.homedir();
446
521
  const plistPath = path.join(home, 'Library', 'LaunchAgents', `${HELPER_LABEL}.plist`);
447
522
  const socketPath = resolveSocketPath();
@@ -324,11 +324,12 @@ export function registerRunCommand(program) {
324
324
  process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens\n`));
325
325
  process.exit(loopExitCode(result.stoppedBy));
326
326
  }
327
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
327
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
328
328
  import('../lib/exec.js'),
329
329
  import('../lib/agents.js'),
330
330
  import('../lib/profiles.js'),
331
331
  import('../lib/secrets/bundles.js'),
332
+ import('../lib/secrets/remote.js'),
332
333
  import('../lib/rotate.js'),
333
334
  import('../lib/versions.js'),
334
335
  import('../lib/plugins.js'),
@@ -776,17 +777,28 @@ export function registerRunCommand(program) {
776
777
  // ones. Any resolution failure (missing keychain item, blocked exec ref)
777
778
  // aborts before spawn so the agent never sees a partial env.
778
779
  let secretsEnv = {};
779
- for (const bundleName of options.secrets) {
780
+ for (const bundleRef of options.secrets) {
780
781
  try {
781
- const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, { caller: `agent ${agent}` });
782
- const entries = describeBundle(bundle);
783
- const counts = {};
784
- for (const e of entries) {
785
- counts[e.kind] = (counts[e.kind] || 0) + 1;
782
+ const { bundle: bundleName, host } = splitBundleRef(bundleRef);
783
+ if (host) {
784
+ // Remote bundle (`bundle@host`): resolve over SSH and inject
785
+ // ephemerally values never touch this machine's keychain or disk.
786
+ const target = await resolveSshTarget(host);
787
+ const bundleEnv = await remoteResolveEnv(target, bundleName);
788
+ console.log(chalk.gray(`[secrets] Resolved ${bundleName}@${host}: ${Object.keys(bundleEnv).length} keys (remote, ephemeral)`));
789
+ secretsEnv = { ...secretsEnv, ...bundleEnv };
790
+ }
791
+ else {
792
+ const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, { caller: `agent ${agent}` });
793
+ const entries = describeBundle(bundle);
794
+ const counts = {};
795
+ for (const e of entries) {
796
+ counts[e.kind] = (counts[e.kind] || 0) + 1;
797
+ }
798
+ const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
799
+ console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
800
+ secretsEnv = { ...secretsEnv, ...bundleEnv };
786
801
  }
787
- const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
788
- console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
789
- secretsEnv = { ...secretsEnv, ...bundleEnv };
790
802
  }
791
803
  catch (err) {
792
804
  console.error(chalk.red(err.message));
@@ -1217,7 +1217,7 @@ function safeStat(p) {
1217
1217
  }
1218
1218
  }
1219
1219
  const SESSION_AGENTS = new Set([
1220
- 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi',
1220
+ 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid',
1221
1221
  ]);
1222
1222
  function safeCountSessions(agent) {
1223
1223
  if (!SESSION_AGENTS.has(agent))
@@ -11,7 +11,7 @@ import { homeDir } from '../lib/platform/index.js';
11
11
  import { resolveAgentName, formatAgentError, agentLabel, } from '../lib/agents.js';
12
12
  import { listInstalledVersions, getGlobalDefault, resolveVersion, resolveVersionAlias } from '../lib/versions.js';
13
13
  import { getModelCatalog, locateModelSource } from '../lib/models.js';
14
- const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'cursor', 'openclaw'];
14
+ const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi'];
15
15
  /**
16
16
  * Agents that don't necessarily install under ~/.agents/versions (cursor ships
17
17
  * via a curl script). For these, fall back to the PATH binary and synthesize
@@ -72,8 +72,14 @@ async function resolveTargets(agentSpec) {
72
72
  if (!version && PATH_ONLY_AGENTS.has(agent)) {
73
73
  version = fallbackPathVersion(agent);
74
74
  }
75
- if (version)
75
+ if (version) {
76
76
  targets.push({ agent, version, isDefault: true });
77
+ }
78
+ else {
79
+ // Surface the gap instead of silently dropping the agent -- an
80
+ // uninstalled model-capable agent should tell the user how to add it.
81
+ console.error(chalk.gray(`${agentLabel(agent)}: not installed (run 'agents add ${agent}@latest')`));
82
+ }
77
83
  }
78
84
  if (targets.length === 0) {
79
85
  console.error(chalk.yellow('No installed agent versions found. Run `agents add claude@latest` to install one.'));
@@ -10,6 +10,7 @@ import chalk from 'chalk';
10
10
  import * as fs from 'fs';
11
11
  import { spawnSync } from 'child_process';
12
12
  import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
13
+ import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, resolveSshTarget, } from '../lib/secrets/remote.js';
13
14
  import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
14
15
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
15
16
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
@@ -160,6 +161,47 @@ export function bundleEnvToDotenv(env) {
160
161
  }
161
162
  return lines.join('\n') + '\n';
162
163
  }
164
+ /**
165
+ * Browse `agents secrets <args>` on one or more remote hosts over SSH and print
166
+ * each host's stdout verbatim (lossless — no parsing). With >1 host the output
167
+ * is grouped under a `── <host> ──` header. `tty` forces an interactive ssh
168
+ * session (run sequentially) so a remote Touch-ID / passphrase prompt can
169
+ * surface (e.g. `view --reveal`); otherwise hosts are queried in parallel.
170
+ * Exits non-zero if any host fails.
171
+ */
172
+ async function browseRemote(targets, args, tty) {
173
+ const multi = targets.length > 1;
174
+ let failures = 0;
175
+ const render = (name, res) => {
176
+ if (multi)
177
+ console.log(chalk.bold.cyan(`\n── ${name} ──`));
178
+ if (res.code === 0) {
179
+ if (res.stdout)
180
+ process.stdout.write(res.stdout.endsWith('\n') ? res.stdout : `${res.stdout}\n`);
181
+ if (res.stderr.trim())
182
+ process.stderr.write(chalk.gray(res.stderr));
183
+ }
184
+ else {
185
+ failures++;
186
+ const msg = (res.stderr || res.stdout || '').trim();
187
+ const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
188
+ console.error(chalk.red(`${name}: ${why}${msg ? `: ${msg}` : ''}`));
189
+ }
190
+ };
191
+ if (tty) {
192
+ for (const t of targets) {
193
+ const target = await resolveSshTarget(t);
194
+ render(t, remoteSecretsRaw(target, args, { tty: true }));
195
+ }
196
+ }
197
+ else {
198
+ const resolved = await Promise.all(targets.map((t) => resolveSshTarget(t)));
199
+ const results = resolved.map((target) => remoteSecretsRaw(target, args));
200
+ targets.forEach((t, i) => render(t, results[i]));
201
+ }
202
+ if (failures > 0)
203
+ process.exit(1);
204
+ }
163
205
  /** Strip ANSI escape sequences so padding can be computed on visible width. */
164
206
  function visibleWidth(s) {
165
207
  // eslint-disable-next-line no-control-regex
@@ -450,8 +492,15 @@ export function registerSecretsCommands(program) {
450
492
  cmd
451
493
  .command('list')
452
494
  .alias('ls')
453
- .description('List configured secrets bundles')
454
- .action(async () => {
495
+ .description('List configured secrets bundles (use --host/--hosts to list bundles on other machines over SSH)')
496
+ .option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
497
+ .option('--hosts <list>', 'Comma-separated hosts to list in one shot, e.g. yosemite-s0,yosemite-s1')
498
+ .action(async (opts) => {
499
+ const targets = parseHostsOption(opts);
500
+ if (targets.length > 0) {
501
+ await browseRemote(targets, ['list'], false);
502
+ return;
503
+ }
455
504
  const bundles = listBundles();
456
505
  if (bundles.length === 0) {
457
506
  console.log(chalk.gray('No secrets bundles configured.'));
@@ -481,8 +530,28 @@ export function registerSecretsCommands(program) {
481
530
  .description('Show a bundle. Keychain values are masked by default — pass --reveal to see them.')
482
531
  .option('--reveal', 'Print keychain-backed values in the clear (TTY only unless --plaintext)')
483
532
  .option('--plaintext', 'Allow --reveal in non-interactive shells (use with care)')
533
+ .option('--host <target>', 'Show a bundle on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
534
+ .option('--hosts <list>', 'Comma-separated hosts to show in one shot, e.g. yosemite-s0,yosemite-s1')
484
535
  .action(async (name, opts) => {
485
536
  try {
537
+ const targets = parseHostsOption(opts);
538
+ if (targets.length > 0) {
539
+ if (!name) {
540
+ console.error(chalk.red('A bundle name is required when viewing a remote host (interactive pick needs a local terminal).'));
541
+ process.exit(1);
542
+ }
543
+ const args = ['view', name];
544
+ if (opts.reveal)
545
+ args.push('--reveal');
546
+ if (opts.plaintext)
547
+ args.push('--plaintext');
548
+ // With --reveal, force a TTY so the remote keychain prompt can surface
549
+ // (and the remote's "--reveal in a non-TTY needs --plaintext" gate is
550
+ // satisfied) — only when this side is itself interactive.
551
+ const tty = Boolean(opts.reveal) && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
552
+ await browseRemote(targets, args, tty);
553
+ return;
554
+ }
486
555
  const resolvedName = name ?? (await pickBundleName('view'));
487
556
  const bundle = readBundle(resolvedName);
488
557
  const entries = describeBundle(bundle);
@@ -1094,6 +1163,7 @@ Examples:
1094
1163
  .option('--host <target...>', 'Push the bundle over SSH to this target (host alias or user@host); repeatable for multiple machines')
1095
1164
  .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1096
1165
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
1166
+ .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
1097
1167
  .action(async (bundleName, opts) => {
1098
1168
  try {
1099
1169
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
@@ -1204,11 +1274,21 @@ Examples:
1204
1274
  console.log(chalk.green(`Exported to 1Password vault '${vault}': ${parts.join(', ')}.`));
1205
1275
  return;
1206
1276
  }
1277
+ if (opts.format && opts.format !== 'shell' && opts.format !== 'json') {
1278
+ console.error(chalk.red(`Invalid --format ${JSON.stringify(opts.format)}. Expected 'shell' or 'json'.`));
1279
+ process.exit(1);
1280
+ }
1207
1281
  if (!opts.plaintext) {
1208
1282
  console.error(chalk.red('export prints secrets in the clear and requires --plaintext (works for TTY and pipes alike).'));
1209
1283
  process.exit(1);
1210
1284
  }
1211
1285
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `export to shell` });
1286
+ if (opts.format === 'json') {
1287
+ // Lossless, machine-readable form consumed by `remoteResolveEnv` over
1288
+ // SSH. Single object of KEY -> value; values verbatim (newlines, quotes).
1289
+ process.stdout.write(JSON.stringify(env));
1290
+ return;
1291
+ }
1212
1292
  const prefix = bundleToEnvPrefix(resolvedBundleName);
1213
1293
  for (const [k, v] of Object.entries(env)) {
1214
1294
  const exportKey = isReservedEnvName(k) ? `${prefix}_${k}` : k;
@@ -1226,17 +1306,24 @@ Examples:
1226
1306
  });
1227
1307
  cmd
1228
1308
  .command('exec <bundle> [command...]')
1229
- .description('Run a command with the bundle\'s secrets injected into the environment')
1309
+ .description('Run a command with the bundle\'s secrets injected into the environment (use --host to resolve the bundle from a remote machine, ephemerally)')
1310
+ .option('--host <target>', 'Resolve <bundle> on a remote host over SSH and inject it (ephemeral — never stored on this machine)')
1230
1311
  .allowUnknownOption()
1231
- .action(async (bundleName, commandParts) => {
1312
+ .action(async (bundleName, commandParts, execOpts) => {
1232
1313
  try {
1233
1314
  if (commandParts.length === 0) {
1234
1315
  console.error(chalk.red('Usage: agents secrets exec <bundle> -- <command...>'));
1235
1316
  process.exit(1);
1236
1317
  }
1237
- const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
1238
1318
  const [cmd, ...args] = commandParts;
1239
- const { env: secretEnv } = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` });
1319
+ let secretEnv;
1320
+ if (execOpts.host) {
1321
+ secretEnv = await remoteResolveEnv(await resolveSshTarget(execOpts.host), bundleName);
1322
+ }
1323
+ else {
1324
+ const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
1325
+ secretEnv = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` }).env;
1326
+ }
1240
1327
  const { spawn } = await import('child_process');
1241
1328
  const proc = spawn(cmd, args, {
1242
1329
  stdio: 'inherit',