@phnx-labs/agents-cli 1.20.27 → 1.20.29

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 (101) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/commands/doctor.js +57 -4
  3. package/dist/commands/exec.d.ts +1 -1
  4. package/dist/commands/exec.js +198 -15
  5. package/dist/commands/hosts.d.ts +11 -0
  6. package/dist/commands/hosts.js +229 -0
  7. package/dist/commands/repo.d.ts +29 -0
  8. package/dist/commands/repo.js +174 -38
  9. package/dist/commands/secrets.d.ts +2 -7
  10. package/dist/commands/secrets.js +108 -29
  11. package/dist/commands/sessions.d.ts +2 -0
  12. package/dist/commands/sessions.js +8 -24
  13. package/dist/commands/ssh.d.ts +14 -0
  14. package/dist/commands/ssh.js +263 -0
  15. package/dist/commands/sync.d.ts +2 -0
  16. package/dist/commands/sync.js +22 -5
  17. package/dist/commands/view.js +27 -11
  18. package/dist/index.js +3 -1
  19. package/dist/lib/agents.d.ts +1 -0
  20. package/dist/lib/agents.js +44 -4
  21. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  22. package/dist/lib/browser/drivers/ssh.js +113 -24
  23. package/dist/lib/browser/profiles.js +28 -1
  24. package/dist/lib/browser/runtime-state.js +28 -8
  25. package/dist/lib/browser/types.d.ts +10 -1
  26. package/dist/lib/cli-resources.js +10 -1
  27. package/dist/lib/devices/connect.d.ts +34 -0
  28. package/dist/lib/devices/connect.js +101 -0
  29. package/dist/lib/devices/registry.d.ts +78 -0
  30. package/dist/lib/devices/registry.js +168 -0
  31. package/dist/lib/devices/ssh-config.d.ts +21 -0
  32. package/dist/lib/devices/ssh-config.js +33 -0
  33. package/dist/lib/devices/tailscale.d.ts +31 -0
  34. package/dist/lib/devices/tailscale.js +126 -0
  35. package/dist/lib/doctor-diff.d.ts +12 -0
  36. package/dist/lib/doctor-diff.js +89 -2
  37. package/dist/lib/exec.d.ts +27 -0
  38. package/dist/lib/exec.js +62 -19
  39. package/dist/lib/hooks.d.ts +17 -0
  40. package/dist/lib/hooks.js +127 -3
  41. package/dist/lib/hosts/dispatch.d.ts +26 -0
  42. package/dist/lib/hosts/dispatch.js +71 -0
  43. package/dist/lib/hosts/progress.d.ts +21 -0
  44. package/dist/lib/hosts/progress.js +49 -0
  45. package/dist/lib/hosts/providers/local.d.ts +17 -0
  46. package/dist/lib/hosts/providers/local.js +81 -0
  47. package/dist/lib/hosts/ready.d.ts +37 -0
  48. package/dist/lib/hosts/ready.js +88 -0
  49. package/dist/lib/hosts/registry.d.ts +22 -0
  50. package/dist/lib/hosts/registry.js +65 -0
  51. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  52. package/dist/lib/hosts/ssh-config.js +157 -0
  53. package/dist/lib/hosts/tasks.d.ts +32 -0
  54. package/dist/lib/hosts/tasks.js +58 -0
  55. package/dist/lib/hosts/types.d.ts +51 -0
  56. package/dist/lib/hosts/types.js +21 -0
  57. package/dist/lib/loop.d.ts +9 -0
  58. package/dist/lib/loop.js +13 -1
  59. package/dist/lib/mcp.js +12 -3
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  61. package/dist/lib/migrate.js +9 -5
  62. package/dist/lib/platform/exec.d.ts +10 -0
  63. package/dist/lib/platform/exec.js +17 -0
  64. package/dist/lib/platform/index.d.ts +1 -0
  65. package/dist/lib/platform/index.js +1 -0
  66. package/dist/lib/platform/links.d.ts +15 -0
  67. package/dist/lib/platform/links.js +42 -0
  68. package/dist/lib/platform/paths.d.ts +18 -0
  69. package/dist/lib/platform/paths.js +22 -0
  70. package/dist/lib/platform/posixpath.d.ts +28 -0
  71. package/dist/lib/platform/posixpath.js +153 -0
  72. package/dist/lib/plugins.d.ts +10 -0
  73. package/dist/lib/plugins.js +1 -1
  74. package/dist/lib/project-launch.js +6 -3
  75. package/dist/lib/sandbox.js +5 -2
  76. package/dist/lib/secrets/remote.d.ts +67 -0
  77. package/dist/lib/secrets/remote.js +133 -0
  78. package/dist/lib/self-update.js +7 -2
  79. package/dist/lib/session/db.d.ts +24 -0
  80. package/dist/lib/session/db.js +80 -5
  81. package/dist/lib/session/discover.d.ts +28 -0
  82. package/dist/lib/session/discover.js +303 -4
  83. package/dist/lib/session/parse.d.ts +7 -0
  84. package/dist/lib/session/parse.js +110 -0
  85. package/dist/lib/session/relative-time.d.ts +7 -0
  86. package/dist/lib/session/relative-time.js +28 -0
  87. package/dist/lib/session/remote.d.ts +31 -3
  88. package/dist/lib/session/remote.js +121 -14
  89. package/dist/lib/session/types.d.ts +1 -1
  90. package/dist/lib/session/types.js +1 -1
  91. package/dist/lib/ssh-exec.d.ts +45 -0
  92. package/dist/lib/ssh-exec.js +61 -0
  93. package/dist/lib/startup/command-registry.d.ts +2 -0
  94. package/dist/lib/startup/command-registry.js +5 -0
  95. package/dist/lib/state.d.ts +2 -0
  96. package/dist/lib/state.js +2 -0
  97. package/dist/lib/types.d.ts +21 -0
  98. package/dist/lib/versions.d.ts +6 -2
  99. package/dist/lib/versions.js +8 -4
  100. package/package.json +1 -1
  101. package/scripts/postinstall.js +62 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `agents devices` (registry) + `agents ssh` (smart wrapper).
3
+ *
4
+ * `agents devices` keeps a registry of SSH device profiles — platform, login
5
+ * user, address, and auth — self-populated from `tailscale status --json`.
6
+ * `agents ssh <name>` then connects through one hardened path: preflight
7
+ * (offline → fail fast instead of a 2-minute hang), platform-aware exec
8
+ * (PowerShell on Windows), and password-from-bundle auth via an askpass shim.
9
+ * Rendering the registry to an ssh_config include also lets plain ssh / scp /
10
+ * rsync / `agents sessions --host` resolve the same logical names.
11
+ */
12
+ import { spawnSync } from 'child_process';
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import chalk from 'chalk';
17
+ import ora from 'ora';
18
+ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
19
+ import { getDevice, loadDevices, removeDevice, upsertDevice, } from '../lib/devices/registry.js';
20
+ import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
21
+ import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
22
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
23
+ /** Parse `user@host` or `host` into pieces. */
24
+ function parseTarget(target) {
25
+ const at = target.indexOf('@');
26
+ if (at === -1)
27
+ return { host: target };
28
+ return { user: target.slice(0, at), host: target.slice(at + 1) };
29
+ }
30
+ /** One-line summary of a device for `list`. */
31
+ function deviceSummary(d) {
32
+ const addr = hostNameFor(d) ?? chalk.gray('no address');
33
+ const online = d.tailscale
34
+ ? d.tailscale.online
35
+ ? chalk.green('online')
36
+ : chalk.gray('offline')
37
+ : chalk.gray('unknown');
38
+ const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
39
+ return ` ${chalk.bold(d.name.padEnd(16))} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}`;
40
+ }
41
+ /** Resolve a device or exit with a clear error. */
42
+ async function mustGetDevice(name) {
43
+ const d = await getDevice(name);
44
+ if (!d) {
45
+ console.error(chalk.red(`Unknown device '${name}'. See 'agents devices list'.`));
46
+ process.exit(1);
47
+ }
48
+ return d;
49
+ }
50
+ /** Register the `agents devices` command tree. */
51
+ function registerDevicesCommands(program) {
52
+ const devicesCmd = program
53
+ .command('devices')
54
+ .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
55
+ .addHelpText('after', `
56
+ Typical workflow:
57
+ agents devices sync # ingest tailscale nodes (auto-detect platform)
58
+ agents devices list # see what's registered
59
+ agents devices set win-mini --auth password --bundle muqsit
60
+ agents devices render --write # write ~/.ssh/config.d/agents include
61
+ `);
62
+ devicesCmd
63
+ .command('sync')
64
+ .description('Ingest `tailscale status --json` and create/update device profiles (auto-detects platform, address, reachability).')
65
+ .action(async () => {
66
+ const spinner = ora('Reading tailscale status...').start();
67
+ try {
68
+ const nodes = parseTailscaleStatus(tailscaleStatusJson());
69
+ spinner.text = `Updating ${nodes.length} device${nodes.length === 1 ? '' : 's'}...`;
70
+ for (const node of nodes) {
71
+ await upsertDevice(node.name, nodeToDeviceInput(node));
72
+ }
73
+ spinner.succeed(`Synced ${nodes.length} device${nodes.length === 1 ? '' : 's'} from Tailscale`);
74
+ }
75
+ catch (err) {
76
+ spinner.fail(err.message);
77
+ process.exit(1);
78
+ }
79
+ });
80
+ devicesCmd
81
+ .command('list')
82
+ .alias('ls')
83
+ .description('List registered devices with platform, address, and reachability.')
84
+ .action(async () => {
85
+ const reg = await loadDevices();
86
+ const names = Object.keys(reg).sort();
87
+ if (names.length === 0) {
88
+ console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
89
+ return;
90
+ }
91
+ console.log(chalk.bold(`Devices (${names.length})`));
92
+ for (const name of names)
93
+ console.log(deviceSummary(reg[name]));
94
+ });
95
+ devicesCmd
96
+ .command('show <name>')
97
+ .description('Show the full profile for one device.')
98
+ .action(async (name) => {
99
+ const d = await mustGetDevice(name);
100
+ console.log(JSON.stringify(d, null, 2));
101
+ });
102
+ devicesCmd
103
+ .command('add <name> <target>')
104
+ .description('Add a device manually (target is user@host or host).')
105
+ .option('--platform <platform>', 'windows | linux | macos')
106
+ .action(async (name, target, opts) => {
107
+ try {
108
+ const { host, user } = parseTarget(target);
109
+ const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
110
+ const d = await upsertDevice(name, {
111
+ platform: opts.platform ?? undefined,
112
+ user,
113
+ address: { via: 'manual', dnsName: isIp ? undefined : host, ip: isIp ? host : undefined },
114
+ });
115
+ console.log(chalk.green(`Added device '${name}'`) + chalk.gray(` (${d.platform}, ${user ? user + '@' : ''}${host})`));
116
+ }
117
+ catch (err) {
118
+ console.error(chalk.red(err.message));
119
+ process.exit(1);
120
+ }
121
+ });
122
+ devicesCmd
123
+ .command('set <name>')
124
+ .description('Update fields on an existing device (platform, user, auth).')
125
+ .option('--platform <platform>', 'windows | linux | macos')
126
+ .option('--user <user>', 'login user')
127
+ .option('--auth <method>', 'key | password')
128
+ .option('--bundle <bundle>', 'secrets bundle holding the password (for --auth password)')
129
+ .option('--bundle-key <key>', "key within the bundle (default 'password')")
130
+ .action(async (name, opts) => {
131
+ try {
132
+ const existing = await mustGetDevice(name);
133
+ const auth = opts.auth || opts.bundle || opts.bundleKey
134
+ ? {
135
+ method: opts.auth ?? existing.auth.method,
136
+ bundle: opts.bundle ?? existing.auth.bundle,
137
+ bundleKey: opts.bundleKey ?? existing.auth.bundleKey,
138
+ }
139
+ : undefined;
140
+ const d = await upsertDevice(name, {
141
+ platform: opts.platform ?? undefined,
142
+ user: opts.user ?? undefined,
143
+ auth,
144
+ });
145
+ console.log(chalk.green(`Updated device '${name}'`) + chalk.gray(` (auth: ${d.auth.method}${d.auth.bundle ? ` via ${d.auth.bundle}` : ''})`));
146
+ }
147
+ catch (err) {
148
+ console.error(chalk.red(err.message));
149
+ process.exit(1);
150
+ }
151
+ });
152
+ devicesCmd
153
+ .command('rm <name>')
154
+ .alias('remove')
155
+ .description('Remove a device from the registry.')
156
+ .action(async (name) => {
157
+ const ok = await removeDevice(name);
158
+ if (!ok) {
159
+ console.error(chalk.red(`Unknown device '${name}'.`));
160
+ process.exit(1);
161
+ }
162
+ console.log(chalk.green(`Removed device '${name}'`));
163
+ });
164
+ devicesCmd
165
+ .command('render')
166
+ .description('Render the registry to ssh_config. Prints to stdout, or use --write to update ~/.ssh/config.d/agents.')
167
+ .option('--write', 'write to ~/.ssh/config.d/agents instead of printing')
168
+ .action(async (opts) => {
169
+ const reg = await loadDevices();
170
+ const text = renderSshConfig(reg);
171
+ if (!opts.write) {
172
+ process.stdout.write(text);
173
+ return;
174
+ }
175
+ const dir = path.join(os.homedir(), '.ssh', 'config.d');
176
+ const file = path.join(dir, 'agents');
177
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
178
+ fs.writeFileSync(file, text, { mode: 0o600 });
179
+ console.log(chalk.green(`Wrote ${file}`));
180
+ console.log(chalk.gray('Add this to ~/.ssh/config (once): Include config.d/agents'));
181
+ });
182
+ }
183
+ /** Register the `agents ssh` smart wrapper. */
184
+ function registerSshWrapper(program) {
185
+ const sshCmd = program
186
+ .command('ssh <name> [cmd...]')
187
+ .description('Connect to a registered device. Preflights reachability, picks the right shell, and authenticates (key or password-from-bundle).')
188
+ .allowUnknownOption()
189
+ .addHelpText('after', `
190
+ Examples:
191
+ agents ssh win-mini # interactive login
192
+ agents ssh win-mini hostname # run a command (PowerShell on Windows)
193
+ agents ssh yosemite-s0 uptime # run a command (POSIX)
194
+
195
+ Devices come from 'agents devices'. Password auth pulls the secret from a
196
+ secrets bundle via an askpass shim — the password never touches argv.
197
+ `)
198
+ .action(async (name, cmd) => {
199
+ // Hidden askpass bridge: ssh execs the shim, which re-invokes us here.
200
+ if (name === '__askpass') {
201
+ await runAskpass();
202
+ return;
203
+ }
204
+ const device = await mustGetDevice(name);
205
+ // Preflight: a device Tailscale last saw offline would otherwise hang
206
+ // for the full ConnectTimeout. Fail fast with a clear message instead.
207
+ if (device.tailscale && !device.tailscale.online) {
208
+ console.error(chalk.red(`Device '${name}' is offline (Tailscale last saw it ${device.tailscale.lastSeen ?? 'a while ago'}).`));
209
+ console.error(chalk.gray("Run 'agents devices sync' to refresh reachability."));
210
+ process.exit(1);
211
+ }
212
+ if (device.tailscale?.online && !device.tailscale.direct) {
213
+ console.error(chalk.yellow(`Note: connection to '${name}' is relayed (DERP ${device.tailscale.relay ?? '?'}) — expect higher latency.`));
214
+ }
215
+ try {
216
+ const shim = writeAskpassShim();
217
+ const { args, env } = buildSshInvocation(device, cmd, shim);
218
+ const res = spawnSync('ssh', args, {
219
+ stdio: 'inherit',
220
+ env: { ...process.env, ...env },
221
+ });
222
+ process.exit(res.status ?? 1);
223
+ }
224
+ catch (err) {
225
+ console.error(chalk.red(err.message));
226
+ process.exit(1);
227
+ }
228
+ });
229
+ // Keep the hidden askpass invocation out of help.
230
+ void sshCmd;
231
+ }
232
+ /**
233
+ * The askpass side of password auth. Invoked by the shim (which ssh execs with
234
+ * SSH_ASKPASS): read the target bundle/key from the environment the wrapper
235
+ * set, resolve it through the existing Keychain path, and print the password
236
+ * to stdout for ssh to consume.
237
+ */
238
+ async function runAskpass() {
239
+ const bundle = process.env[ASKPASS_BUNDLE_ENV];
240
+ const key = process.env[ASKPASS_KEY_ENV] ?? 'password';
241
+ if (!bundle) {
242
+ console.error(`askpass: ${ASKPASS_BUNDLE_ENV} not set`);
243
+ process.exit(1);
244
+ }
245
+ try {
246
+ const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents ssh' });
247
+ const value = env[key];
248
+ if (value === undefined) {
249
+ console.error(`askpass: key '${key}' not found in bundle '${bundle}'`);
250
+ process.exit(1);
251
+ }
252
+ process.stdout.write(value);
253
+ }
254
+ catch (err) {
255
+ console.error(`askpass: ${err?.message ?? err}`);
256
+ process.exit(1);
257
+ }
258
+ }
259
+ /** Register both `agents ssh` and `agents devices`. */
260
+ export function registerSshCommands(program) {
261
+ registerSshWrapper(program);
262
+ registerDevicesCommands(program);
263
+ }
@@ -9,6 +9,8 @@
9
9
  * agents sync claude # one agent: uses default/sole installed version
10
10
  * agents sync claude@2.1.142 # one agent: explicit version
11
11
  * agents sync claude@latest # one agent: newest installed
12
+ * agents sync claude@oldest # one agent: oldest installed
13
+ * agents sync claude@pinned (= claude@default) # one agent: the pinned default version
12
14
  * agents sync --agent claude --agent-version 2.1.142 # legacy form, still supported
13
15
  *
14
16
  * The umbrella stages live in lib/sync-umbrella.ts; this file dispatches to them
@@ -9,6 +9,8 @@
9
9
  * agents sync claude # one agent: uses default/sole installed version
10
10
  * agents sync claude@2.1.142 # one agent: explicit version
11
11
  * agents sync claude@latest # one agent: newest installed
12
+ * agents sync claude@oldest # one agent: oldest installed
13
+ * agents sync claude@pinned (= claude@default) # one agent: the pinned default version
12
14
  * agents sync --agent claude --agent-version 2.1.142 # legacy form, still supported
13
15
  *
14
16
  * The umbrella stages live in lib/sync-umbrella.ts; this file dispatches to them
@@ -28,7 +30,7 @@
28
30
  import * as path from 'path';
29
31
  import chalk from 'chalk';
30
32
  import { agentLabel, resolveAgentName } from '../lib/agents.js';
31
- import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, } from '../lib/versions.js';
33
+ import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, } from '../lib/versions.js';
32
34
  import { compileRulesForProject } from '../lib/rules/compile.js';
33
35
  import { runLaunchSync } from '../lib/project-launch.js';
34
36
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -38,7 +40,7 @@ export function registerSyncCommand(program) {
38
40
  program
39
41
  .command('sync [agentSpec]')
40
42
  .summary('Make this machine current, or sync resources into one agent')
41
- .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude" or "claude@2.1.142".\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
43
+ .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", or a selector: @latest / @oldest / @pinned (= @default).\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
42
44
  .option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
43
45
  .option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version")')
44
46
  .option('--project-dir <path>', 'Path to project-level .agents/ directory containing project-scoped resources')
@@ -116,17 +118,23 @@ async function runSync(agentSpec, opts) {
116
118
  // ---------- 1. Resolve agent + version ----------
117
119
  let agentId;
118
120
  let version;
121
+ // A positional @selector typed by the user (latest/oldest/pinned/default/
122
+ // explicit). parseAgentSpec defaults a missing version to 'latest', so a bare
123
+ // `agents sync claude` and `agents sync claude@latest` are indistinguishable
124
+ // after parsing — we only treat the version as a selector when an '@' was
125
+ // actually typed, keeping bare `claude` on the default-version path.
126
+ let selector;
119
127
  if (agentSpec) {
120
128
  const parsed = parseAgentSpec(agentSpec);
121
129
  if (!parsed) {
122
130
  errLog(chalk.red(`Invalid agent spec '${agentSpec}'.`));
123
- errLog(chalk.gray('Examples: claude, claude@2.1.142, codex@latest'));
131
+ errLog(chalk.gray('Examples: claude, claude@2.1.142, claude@latest, claude@oldest, claude@pinned'));
124
132
  process.exitCode = 1;
125
133
  return;
126
134
  }
127
135
  agentId = parsed.agent;
128
- if (parsed.version !== 'latest' && parsed.version !== 'oldest')
129
- version = parsed.version;
136
+ if (agentSpec.includes('@'))
137
+ selector = parsed.version;
130
138
  }
131
139
  if (opts.agent) {
132
140
  const resolved = resolveAgentName(opts.agent);
@@ -138,6 +146,8 @@ async function runSync(agentSpec, opts) {
138
146
  agentId = resolved;
139
147
  }
140
148
  if (opts.agentVersion) {
149
+ // Legacy flag and the launch-shim hot path (`--agent-version <concrete>`):
150
+ // pass through verbatim. Selector aliases are a positional-spec feature.
141
151
  version = opts.agentVersion;
142
152
  }
143
153
  if (!agentId) {
@@ -147,6 +157,13 @@ async function runSync(agentSpec, opts) {
147
157
  return;
148
158
  }
149
159
  // ---------- 2. Resolve version (project pin → global default → sole installed) ----------
160
+ // A positional @selector wins over the default-resolution below.
161
+ // @latest / @oldest → newest / oldest installed (process.exit if none)
162
+ // @pinned / @default → undefined → fall through to the default path
163
+ // @x.y.z → that version (process.exit if not installed)
164
+ if (selector !== undefined && !version) {
165
+ version = resolveVersionAlias(agentId, selector);
166
+ }
150
167
  if (!version) {
151
168
  version = resolveVersion(agentId, process.cwd()) || undefined;
152
169
  if (!version) {
@@ -21,6 +21,9 @@ import { listProfiles, profileSummary } from '../lib/profiles.js';
21
21
  import { loadManifest, isStale } from '../lib/staleness/index.js';
22
22
  import { confirm } from '@inquirer/prompts';
23
23
  import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js';
24
+ // Shown in the email column for agents that are signed in but expose no email
25
+ // address locally (Antigravity, Kimi store an opaque OAuth/JWT credential).
26
+ const SIGNED_IN_LABEL = 'signed in';
24
27
  /**
25
28
  * Group profile summaries by their host harness, optionally filtered to a
26
29
  * single agent. Profile YAMLs that fail validation are silently skipped by
@@ -350,6 +353,8 @@ async function showInstalledVersions(filterAgentId) {
350
353
  const info = rawInfo ? mergeCanonical(rawInfo) : undefined;
351
354
  if (info?.email)
352
355
  maxEmail = Math.max(maxEmail, info.email.length);
356
+ else if (info?.signedIn)
357
+ maxEmail = Math.max(maxEmail, SIGNED_IN_LABEL.length);
353
358
  if (info?.plan)
354
359
  maxPlanWidth = Math.max(maxPlanWidth, info.plan.length);
355
360
  }
@@ -406,6 +411,7 @@ async function showInstalledVersions(filterAgentId) {
406
411
  // Build columns, trimming trailing whitespace when columns are empty
407
412
  const parts = [` ${label}`];
408
413
  const hasEmail = !!vInfo?.email;
414
+ const signedIn = !!vInfo?.signedIn;
409
415
  const usageStr = formatUsageSummary(vInfo?.plan || null, usageInfo?.snapshot || null, maxPlanWidth);
410
416
  const hasUsage = usageStr.length > 0;
411
417
  // Only show lastActive for versions with an actual logged-in account.
@@ -418,14 +424,17 @@ async function showInstalledVersions(filterAgentId) {
418
424
  runDefaultBits.push(`mode:${runDefaults.mode}`);
419
425
  if (runDefaults.model)
420
426
  runDefaultBits.push(`model:${runDefaults.model}`);
421
- if (!hasEmail && !hasUsage) {
427
+ if (!hasEmail && !hasUsage && !signedIn) {
422
428
  // Installed but never signed in
423
429
  parts.push(chalk.gray('(not signed in — run ' + agent.cliCommand + ' to log in)'));
424
430
  }
425
431
  else {
426
- if (hasEmail || hasUsage || hasActive) {
427
- const emailCol = (vInfo?.email || '').padEnd(maxEmail);
428
- parts.push(hasEmail ? chalk.cyan(emailCol) : ' '.repeat(maxEmail));
432
+ if (hasEmail || hasUsage || hasActive || signedIn) {
433
+ // Signed-in agents without a local email (Antigravity, Kimi) show a
434
+ // "signed in" placeholder so they read as logged in, not blank.
435
+ const display = vInfo?.email || (signedIn ? SIGNED_IN_LABEL : '');
436
+ const emailCol = display.padEnd(maxEmail);
437
+ parts.push(display ? chalk.cyan(emailCol) : ' '.repeat(maxEmail));
429
438
  }
430
439
  if (hasUsage || hasActive) {
431
440
  const usagePad = ' '.repeat(Math.max(0, maxUsageWidth - visibleWidth(usageStr)));
@@ -503,8 +512,10 @@ async function showInstalledVersions(filterAgentId) {
503
512
  const gUsage = gUsageKey ? usageByKey.get(gUsageKey) : undefined;
504
513
  const gUsageStr = formatUsageSummary(gInfo?.plan || null, gUsage?.snapshot || null);
505
514
  const gActiveStr = gInfo ? formatLastActive(gInfo.lastActive) : '';
506
- if (gInfo?.email || gUsageStr || gActiveStr)
507
- parts.push(gInfo?.email ? chalk.cyan(gInfo.email) : '');
515
+ if (gInfo?.email || gUsageStr || gActiveStr || gInfo?.signedIn) {
516
+ const gDisplay = gInfo?.email || (gInfo?.signedIn ? SIGNED_IN_LABEL : '');
517
+ parts.push(gDisplay ? chalk.cyan(gDisplay) : '');
518
+ }
508
519
  if (gUsageStr || gActiveStr)
509
520
  parts.push(gUsageStr);
510
521
  const gStatusStr = formatUsageStatusBadge(gInfo?.usageStatus);
@@ -814,7 +825,11 @@ async function showAgentResources(agentId, requestedVersion, filter) {
814
825
  cliVersion: version,
815
826
  info: accountInfo,
816
827
  });
817
- const emailStr = accountInfo.email ? chalk.cyan(` ${accountInfo.email}`) : '';
828
+ const emailStr = accountInfo.email
829
+ ? chalk.cyan(` ${accountInfo.email}`)
830
+ : accountInfo.signedIn
831
+ ? chalk.cyan(` ${SIGNED_IN_LABEL}`)
832
+ : '';
818
833
  const status = chalk.green(version);
819
834
  const usageStr = formatUsageSummary(accountInfo.plan, null);
820
835
  const usagePart = usageStr ? ` ${usageStr}` : '';
@@ -1006,7 +1021,7 @@ async function collectAgentsJson(filterAgentId) {
1006
1021
  const entry = {
1007
1022
  version,
1008
1023
  isDefault: version === globalDefault,
1009
- signedIn: !!info.email,
1024
+ signedIn: info.signedIn,
1010
1025
  email: info.email,
1011
1026
  plan: info.plan,
1012
1027
  usageStatus: info.usageStatus,
@@ -1273,9 +1288,10 @@ export async function viewAction(agentArg, options) {
1273
1288
  console.log(chalk.red(formatAgentError(agentName)));
1274
1289
  process.exit(1);
1275
1290
  }
1276
- // Keep 'default' as-is since showAgentResources handles it; resolveVersionAlias
1277
- // returns undefined for 'default' which would skip the detailed view.
1278
- const requestedVersion = parts[1] === 'default'
1291
+ // Keep 'default'/'pinned' as-is since showAgentResources handles 'default';
1292
+ // resolveVersionAlias returns undefined for both (they're synonyms), which
1293
+ // would otherwise skip the detailed view.
1294
+ const requestedVersion = (parts[1] === 'default' || parts[1] === 'pinned')
1279
1295
  ? 'default'
1280
1296
  : (resolveVersionAlias(agentId, parts[1]) ?? null);
1281
1297
  if (prune) {
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ if (IS_DEV_BUILD) {
51
51
  // module on each invocation (which loaded the whole ~50-module tree before the
52
52
  // first byte of output), the registry maps a command name to a thunk that
53
53
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
54
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
54
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
55
55
  import { applyGlobalHelpConventions } from './lib/help.js';
56
56
  import { IS_WINDOWS } from './lib/platform/index.js';
57
57
  // Transparent shim delegate: the generated Windows `.cmd` shims invoke
@@ -783,6 +783,8 @@ async function registerAllEagerCommands() {
783
783
  await reg(loadTmux);
784
784
  await reg(loadBrowser);
785
785
  await reg(loadComputer);
786
+ await reg(loadHosts);
787
+ await reg(loadSsh);
786
788
  registerJobsCronAliasCommand(program, 'jobs');
787
789
  registerJobsCronAliasCommand(program, 'cron');
788
790
  registerUpgradeCommand(program);
@@ -107,6 +107,7 @@ export interface AccountInfo {
107
107
  currency: string;
108
108
  } | null;
109
109
  lastActive: Date | null;
110
+ signedIn: boolean;
110
111
  }
111
112
  /** Return the email address associated with the agent's auth config, or null. */
112
113
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
@@ -15,6 +15,7 @@ import * as path from 'path';
15
15
  import * as os from 'os';
16
16
  import * as TOML from 'smol-toml';
17
17
  import chalk from 'chalk';
18
+ import { needsWindowsShell } from './platform/index.js';
18
19
  import { latestFileMtimeMs } from './fs-walk.js';
19
20
  import { damerauLevenshtein } from './fuzzy.js';
20
21
  import { getCacheDir, getVersionsDir, getShimsDir, getCliVersionCachePath } from './state.js';
@@ -782,6 +783,7 @@ export async function getAccountInfo(agentId, home) {
782
783
  usageStatus: null,
783
784
  overageCredits: null,
784
785
  lastActive: null,
786
+ signedIn: false,
785
787
  };
786
788
  const configFiles = {
787
789
  claude: path.join(base, '.claude.json'),
@@ -853,6 +855,7 @@ export async function getAccountInfo(agentId, home) {
853
855
  usageStatus,
854
856
  overageCredits,
855
857
  lastActive,
858
+ signedIn: !!email,
856
859
  };
857
860
  }
858
861
  case 'codex': {
@@ -894,11 +897,13 @@ export async function getAccountInfo(agentId, home) {
894
897
  usageStatus,
895
898
  overageCredits: null,
896
899
  lastActive,
900
+ signedIn: !!email,
897
901
  };
898
902
  }
899
903
  case 'gemini': {
900
904
  const data = JSON.parse(await fs.promises.readFile(path.join(base, '.gemini', 'google_accounts.json'), 'utf-8'));
901
- return { ...empty, email: data.active || null, lastActive };
905
+ const email = data.active || null;
906
+ return { ...empty, email, signedIn: !!email, lastActive };
902
907
  }
903
908
  case 'grok': {
904
909
  // Grok stores auth in ~/.grok/auth.json
@@ -907,12 +912,44 @@ export async function getAccountInfo(agentId, home) {
907
912
  if (fs.existsSync(authPath)) {
908
913
  const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
909
914
  const email = data.email || data.user?.email || data.account?.email || null;
910
- return { ...empty, email, lastActive };
915
+ return { ...empty, email, signedIn: !!email, lastActive };
911
916
  }
912
917
  }
913
918
  catch { }
914
919
  return { ...empty, lastActive };
915
920
  }
921
+ case 'antigravity': {
922
+ // Antigravity (`agy`) stores a Google OAuth token at
923
+ // ~/.gemini/antigravity-cli/antigravity-oauth-token. It's a consumer
924
+ // OAuth grant (access + refresh token, no id_token), so there's no email
925
+ // claim to read locally — presence of a refresh token is the only
926
+ // signed-in signal we can derive without a network call.
927
+ const tokenPath = path.join(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
928
+ if (!fs.existsSync(tokenPath))
929
+ return { ...empty, lastActive };
930
+ const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
931
+ const hasToken = typeof data?.token?.refresh_token === 'string' && !!data.token.refresh_token;
932
+ if (!hasToken)
933
+ return { ...empty, lastActive };
934
+ return { ...empty, signedIn: true, lastActive };
935
+ }
936
+ case 'kimi': {
937
+ // Kimi Code stores OAuth credentials at
938
+ // ~/.kimi-code/credentials/kimi-code.json. The access token is a JWT
939
+ // whose payload carries an opaque user_id (no email), so we report
940
+ // signed-in state plus a stable account key for usage dedup.
941
+ const credPath = path.join(base, '.kimi-code', 'credentials', 'kimi-code.json');
942
+ if (!fs.existsSync(credPath))
943
+ return { ...empty, lastActive };
944
+ const data = JSON.parse(await fs.promises.readFile(credPath, 'utf-8'));
945
+ const accessToken = data?.access_token;
946
+ if (typeof accessToken !== 'string' || !accessToken)
947
+ return { ...empty, lastActive };
948
+ const decoded = decodeJwtPayload(accessToken);
949
+ const userId = normalizeIdentityPart(decoded?.user_id ?? decoded?.sub);
950
+ const accountKey = buildIdentityKey(agentId, [['user', userId]]);
951
+ return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
952
+ }
916
953
  default:
917
954
  return { ...empty, lastActive };
918
955
  }
@@ -1151,7 +1188,10 @@ export async function registerMcp(agentId, name, command, scope = 'user', transp
1151
1188
  }
1152
1189
  // When home is specified, override HOME so MCP config writes to the version's config dir
1153
1190
  const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
1154
- await execFileAsync(bin, args, env ? { env } : undefined);
1191
+ // On Windows a bare command name / `.cmd` wrapper (the npm-installed agent
1192
+ // CLI) can't be exec'd directly — it needs shell:true for PATHEXT/cmd. Off
1193
+ // Windows this is always false, so the no-shell argv path is unchanged.
1194
+ await execFileAsync(bin, args, { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
1155
1195
  return { success: true };
1156
1196
  }
1157
1197
  catch (err) {
@@ -1170,7 +1210,7 @@ export async function unregisterMcp(agentId, name, options) {
1170
1210
  try {
1171
1211
  const bin = options?.binary || agent.cliCommand;
1172
1212
  const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
1173
- await execFileAsync(bin, ['mcp', 'remove', name], env ? { env } : undefined);
1213
+ await execFileAsync(bin, ['mcp', 'remove', name], { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
1174
1214
  return { success: true };
1175
1215
  }
1176
1216
  catch (err) {
@@ -1,11 +1,56 @@
1
1
  import { CDPClient } from '../cdp.js';
2
2
  import type { BrowserProfile } from '../types.js';
3
+ import { shellQuote } from '../../ssh-exec.js';
4
+ export { shellQuote };
3
5
  export interface SSHConnection {
4
6
  cdp: CDPClient;
5
7
  port: number;
6
8
  pid: number;
7
9
  cleanup: () => void;
8
10
  }
9
- export declare function shellQuote(s: string): string;
11
+ /**
12
+ * Which shell dialect the *remote* host speaks. Selected per-endpoint via the
13
+ * `&os=windows` query param on an `ssh://` target. POSIX is the default — the
14
+ * historical behavior for macOS/Linux remotes. Windows remotes run OpenSSH
15
+ * Server with cmd.exe as the default shell, so the launch/teardown command
16
+ * strings differ (no `&` backgrounding, no `lsof`, `.exe` instead of `.app`).
17
+ */
18
+ export type RemoteOs = 'windows' | 'posix';
10
19
  export declare function connectSSH(endpoint: string, profile: BrowserProfile): Promise<SSHConnection>;
11
- export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, customBinary?: string): Promise<void>;
20
+ /**
21
+ * Wrap a PowerShell script as a `-EncodedCommand` invocation. Base64 of the
22
+ * UTF-16LE bytes is a single quote-free token, so it rides through Node spawn
23
+ * → Windows sshd → cmd.exe with zero escaping hazards (hand-quoted
24
+ * `powershell -Command "…"` is fragile the moment a path or URL is involved).
25
+ */
26
+ export declare function encodePowerShell(script: string): string;
27
+ /**
28
+ * The PowerShell that launches the browser on a Windows remote. Two hard
29
+ * requirements shaped this:
30
+ * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
31
+ * the session's job tree on disconnect, which reaps both `start /B` and
32
+ * `Start-Process` children (verified against a real box). WMI
33
+ * `Win32_Process.Create` spawns under the WMI provider service instead,
34
+ * so the process survives after we drop the ssh connection and reconnect
35
+ * over the CDP tunnel.
36
+ * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
37
+ * port comes up even when the user already has Edge open.
38
+ * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
39
+ * registry at runtime rather than relying on a bare `msedge` name.
40
+ */
41
+ export declare function buildWindowsLaunchScript(browserType: string, port: number, customBinary?: string): string;
42
+ /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
43
+ export declare function buildWindowsKillScript(port: number): string;
44
+ /**
45
+ * Build the remote command that launches the browser detached with a CDP port.
46
+ * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
47
+ * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
48
+ */
49
+ export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string, port: number, customBinary?: string): string;
50
+ /**
51
+ * Build the remote command that kills whatever holds the CDP port.
52
+ * POSIX uses `lsof`+`kill`; Windows uses encoded PowerShell
53
+ * (Get-NetTCPConnection → Stop-Process).
54
+ */
55
+ export declare function buildKillCmd(remoteOs: RemoteOs, port: number): string;
56
+ export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;