@phnx-labs/agents-cli 1.20.27 → 1.20.28

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 (83) 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 +177 -6
  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 +15 -23
  11. package/dist/commands/sessions.d.ts +2 -0
  12. package/dist/commands/sessions.js +7 -24
  13. package/dist/commands/sync.d.ts +2 -0
  14. package/dist/commands/sync.js +22 -5
  15. package/dist/commands/view.js +27 -11
  16. package/dist/index.js +2 -1
  17. package/dist/lib/agents.d.ts +1 -0
  18. package/dist/lib/agents.js +44 -4
  19. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  20. package/dist/lib/browser/drivers/ssh.js +113 -24
  21. package/dist/lib/browser/profiles.js +28 -1
  22. package/dist/lib/browser/runtime-state.js +28 -8
  23. package/dist/lib/browser/types.d.ts +10 -1
  24. package/dist/lib/cli-resources.js +10 -1
  25. package/dist/lib/doctor-diff.d.ts +12 -0
  26. package/dist/lib/doctor-diff.js +89 -2
  27. package/dist/lib/exec.d.ts +27 -0
  28. package/dist/lib/exec.js +62 -19
  29. package/dist/lib/hooks.d.ts +17 -0
  30. package/dist/lib/hooks.js +127 -3
  31. package/dist/lib/hosts/dispatch.d.ts +26 -0
  32. package/dist/lib/hosts/dispatch.js +71 -0
  33. package/dist/lib/hosts/progress.d.ts +21 -0
  34. package/dist/lib/hosts/progress.js +49 -0
  35. package/dist/lib/hosts/providers/local.d.ts +17 -0
  36. package/dist/lib/hosts/providers/local.js +81 -0
  37. package/dist/lib/hosts/ready.d.ts +37 -0
  38. package/dist/lib/hosts/ready.js +88 -0
  39. package/dist/lib/hosts/registry.d.ts +22 -0
  40. package/dist/lib/hosts/registry.js +65 -0
  41. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  42. package/dist/lib/hosts/ssh-config.js +157 -0
  43. package/dist/lib/hosts/tasks.d.ts +32 -0
  44. package/dist/lib/hosts/tasks.js +58 -0
  45. package/dist/lib/hosts/types.d.ts +51 -0
  46. package/dist/lib/hosts/types.js +21 -0
  47. package/dist/lib/loop.d.ts +9 -0
  48. package/dist/lib/loop.js +13 -1
  49. package/dist/lib/mcp.js +12 -3
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  51. package/dist/lib/migrate.js +9 -5
  52. package/dist/lib/platform/exec.d.ts +10 -0
  53. package/dist/lib/platform/exec.js +17 -0
  54. package/dist/lib/platform/index.d.ts +1 -0
  55. package/dist/lib/platform/index.js +1 -0
  56. package/dist/lib/platform/links.d.ts +15 -0
  57. package/dist/lib/platform/links.js +42 -0
  58. package/dist/lib/platform/paths.d.ts +18 -0
  59. package/dist/lib/platform/paths.js +22 -0
  60. package/dist/lib/platform/posixpath.d.ts +28 -0
  61. package/dist/lib/platform/posixpath.js +153 -0
  62. package/dist/lib/plugins.d.ts +10 -0
  63. package/dist/lib/plugins.js +1 -1
  64. package/dist/lib/project-launch.js +6 -3
  65. package/dist/lib/sandbox.js +5 -2
  66. package/dist/lib/self-update.js +7 -2
  67. package/dist/lib/session/db.d.ts +23 -0
  68. package/dist/lib/session/db.js +76 -1
  69. package/dist/lib/session/discover.d.ts +26 -0
  70. package/dist/lib/session/discover.js +75 -4
  71. package/dist/lib/session/relative-time.d.ts +7 -0
  72. package/dist/lib/session/relative-time.js +28 -0
  73. package/dist/lib/session/remote.d.ts +31 -3
  74. package/dist/lib/session/remote.js +121 -14
  75. package/dist/lib/ssh-exec.d.ts +45 -0
  76. package/dist/lib/ssh-exec.js +61 -0
  77. package/dist/lib/startup/command-registry.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.js +2 -0
  79. package/dist/lib/types.d.ts +21 -0
  80. package/dist/lib/versions.d.ts +6 -2
  81. package/dist/lib/versions.js +8 -4
  82. package/package.json +1 -1
  83. package/scripts/postinstall.js +62 -0
@@ -68,26 +68,117 @@ function deriveAlias(source) {
68
68
  // becomes ~/.agents-<alias>/ on disk (e.g. ".agents-work" -> "work").
69
69
  return base.replace(/^\.+/, '').replace(/^agents-/, '') || 'repo';
70
70
  }
71
+ /** Top-level dir -> resource kind. Directory-based resources collapse to one unit. */
72
+ const RESOURCE_DIRS = {
73
+ skills: 'skill', commands: 'command', prompts: 'command', plugins: 'plugin',
74
+ hooks: 'hook', mcp: 'mcp', subagents: 'subagent', rules: 'rule',
75
+ workflows: 'workflow', routines: 'routine', profiles: 'profile',
76
+ permissions: 'permission', cli: 'cli',
77
+ };
78
+ /** [singular, plural] display labels per kind. */
79
+ const RESOURCE_LABELS = {
80
+ skill: ['skill', 'skills'], command: ['command', 'commands'],
81
+ plugin: ['plugin', 'plugins'], hook: ['hook', 'hooks'], mcp: ['MCP', 'MCPs'],
82
+ subagent: ['subagent', 'subagents'], rule: ['rule', 'rules'],
83
+ workflow: ['workflow', 'workflows'], routine: ['routine', 'routines'],
84
+ profile: ['profile', 'profiles'], permission: ['permission', 'permissions'],
85
+ cli: ['CLI', 'CLIs'], config: ['config file', 'config files'],
86
+ other: ['other file', 'other files'],
87
+ };
88
+ /** Display order — the resources a user cares about most come first. */
89
+ const RESOURCE_ORDER = [
90
+ 'skill', 'command', 'plugin', 'hook', 'mcp', 'subagent', 'rule',
91
+ 'workflow', 'routine', 'profile', 'permission', 'cli', 'config', 'other',
92
+ ];
71
93
  /**
72
- * Categorized working-tree summary: `M:2 A:1 D:1 ?:3` letters match the git
73
- * porcelain shorthand (Modified / Added / Deleted / Renamed / Unmerged /
74
- * Untracked). Zero counts are omitted so the column stays compact.
94
+ * Map a repo-relative path to the resource unit it belongs to. Directory-based
95
+ * resources (skills/foo/SKILL.md) collapse to `skills/foo` so all their files
96
+ * count as one unit; flat config files (agents.yaml, hooks.yaml) count alone.
75
97
  */
76
- function formatDirtyCounts(status) {
98
+ export function resourceUnit(file) {
99
+ const parts = file.split('/');
100
+ const top = parts[0];
101
+ if (top === 'agents.yaml' || top === 'hooks.yaml')
102
+ return { kind: 'config', unit: top };
103
+ const kind = RESOURCE_DIRS[top];
104
+ if (kind)
105
+ return { kind, unit: parts.length > 1 ? `${top}/${parts[1]}` : file };
106
+ return { kind: 'other', unit: file };
107
+ }
108
+ /**
109
+ * Render a set of changed files as a resource-level summary, e.g.
110
+ * `2 new skills, 1 changed hook`. Counts distinct resource units (a skill whose
111
+ * three files all changed is "1 changed skill"), grouped by action then kind,
112
+ * and colors each phrase green/yellow/red for new/changed/removed. Caps at
113
+ * `maxParts` phrases, appending `+N more` so a big diff stays scannable.
114
+ */
115
+ export function formatResourceDelta(entries, maxParts = 5) {
116
+ // Gather every action seen across a unit's files, then collapse to one action.
117
+ const units = new Map();
118
+ for (const { action, file } of entries) {
119
+ const { kind, unit } = resourceUnit(file);
120
+ const key = `${kind} ${unit}`;
121
+ const cur = units.get(key) ?? { kind, actions: new Set() };
122
+ cur.actions.add(action);
123
+ units.set(key, cur);
124
+ }
125
+ const counts = new Map(); // `${action} ${kind}` -> count
126
+ for (const { kind, actions } of units.values()) {
127
+ let action;
128
+ if (actions.size === 1)
129
+ action = [...actions][0];
130
+ else if ([...actions].every((a) => a === 'new'))
131
+ action = 'new';
132
+ else if ([...actions].every((a) => a === 'removed'))
133
+ action = 'removed';
134
+ else
135
+ action = 'changed'; // mixed add+modify within one unit reads as a change
136
+ const key = `${action} ${kind}`;
137
+ counts.set(key, (counts.get(key) ?? 0) + 1);
138
+ }
139
+ const COLOR = {
140
+ new: chalk.green, changed: chalk.yellow, removed: chalk.red,
141
+ };
77
142
  const parts = [];
78
- if (status.modified.length)
79
- parts.push(chalk.yellow(`M:${status.modified.length}`));
80
- if (status.created.length)
81
- parts.push(chalk.green(`A:${status.created.length}`));
82
- if (status.deleted.length)
83
- parts.push(chalk.red(`D:${status.deleted.length}`));
84
- if (status.renamed.length)
85
- parts.push(chalk.cyan(`R:${status.renamed.length}`));
86
- if (status.conflicted.length)
87
- parts.push(chalk.magenta(`U:${status.conflicted.length}`));
88
- if (status.not_added.length)
89
- parts.push(chalk.gray(`?:${status.not_added.length}`));
90
- return parts.join(' ');
143
+ for (const action of ['new', 'changed', 'removed']) {
144
+ for (const kind of RESOURCE_ORDER) {
145
+ const n = counts.get(`${action} ${kind}`);
146
+ if (!n)
147
+ continue;
148
+ const [singular, plural] = RESOURCE_LABELS[kind];
149
+ parts.push(COLOR[action](`${n} ${action} ${n === 1 ? singular : plural}`));
150
+ }
151
+ }
152
+ if (parts.length > maxParts) {
153
+ const shown = parts.slice(0, maxParts);
154
+ shown.push(chalk.gray(`+${parts.length - maxParts} more`));
155
+ return shown.join(', ');
156
+ }
157
+ return parts.join(', ');
158
+ }
159
+ /** Parse `git diff --name-status <range>` into resource-delta entries. */
160
+ async function diffResourceEntries(git, range) {
161
+ let raw;
162
+ try {
163
+ raw = await git.diff(['--name-status', range]);
164
+ }
165
+ catch {
166
+ return []; // no upstream resolvable / range invalid — caller falls back to counts
167
+ }
168
+ const out = [];
169
+ for (const line of raw.split('\n')) {
170
+ if (!line.trim())
171
+ continue;
172
+ const cols = line.split('\t');
173
+ const code = cols[0] ?? '';
174
+ const file = cols[cols.length - 1] ?? ''; // renames put the new path last
175
+ if (!file)
176
+ continue;
177
+ const c = code[0];
178
+ const action = c === 'A' ? 'new' : c === 'D' ? 'removed' : 'changed';
179
+ out.push({ action, file });
180
+ }
181
+ return out;
91
182
  }
92
183
  /** Visible character width of a string with embedded ANSI color codes. */
93
184
  function visibleWidth(s) {
@@ -98,46 +189,75 @@ function padVisible(s, width) {
98
189
  return s + ' '.repeat(Math.max(0, width - visibleWidth(s)));
99
190
  }
100
191
  /**
101
- * Render one row of the unified repo table: alias / branch / ahead-behind /
102
- * dirty counts / url+commit. Used by `agents repo list` and the hidden
103
- * `agents repo status` alias.
192
+ * Render one repo's row data: branch, resource-level sync (what a pull/push would
193
+ * move), resource-level local edits, and the remote URL + commit. Used by
194
+ * `agents repo list` and the hidden `agents repo status` alias.
104
195
  */
105
196
  async function renderRepoRow(t) {
106
- const aliasCol = chalk.cyan(t.alias.padEnd(12));
107
197
  if (!fs.existsSync(t.dir)) {
108
- return ` ${aliasCol} ${chalk.red('missing')} ${chalk.gray(t.dir)}`;
198
+ return { alias: t.alias, raw: `${chalk.red('missing')} ${chalk.gray(t.dir)}` };
109
199
  }
110
200
  if (!isGitRepo(t.dir)) {
111
- return ` ${aliasCol} ${chalk.gray('local (no git remote)')} ${chalk.gray(t.dir)}`;
201
+ return { alias: t.alias, raw: `${chalk.gray('local (no git remote)')} ${chalk.gray(t.dir)}` };
112
202
  }
113
203
  try {
114
204
  const git = simpleGit(t.dir);
115
205
  const status = await git.status();
116
- const branch = status.tracking || status.current || '(detached)';
117
- const inSync = (status.ahead ?? 0) === 0 && (status.behind ?? 0) === 0;
118
- const remoteRaw = inSync ? 'in sync' : `+${status.ahead ?? 0} -${status.behind ?? 0}`;
119
- const remoteColored = inSync ? chalk.green(remoteRaw) : chalk.yellow(remoteRaw);
120
- const remotePad = ' '.repeat(Math.max(0, 12 - remoteRaw.length));
121
- const tree = status.isClean() ? chalk.green('clean') : formatDirtyCounts(status);
206
+ // Show the local branch name; the upstream remote is already implied by URL.
207
+ const branch = status.current || (status.tracking ? status.tracking.replace(/^origin\//, '') : '(detached)');
208
+ // SYNC: what a pull brings in / a push sends out, described by resource.
209
+ const ahead = status.ahead ?? 0;
210
+ const behind = status.behind ?? 0;
211
+ let sync;
212
+ if (!status.tracking) {
213
+ sync = chalk.gray('no upstream');
214
+ }
215
+ else if (ahead === 0 && behind === 0) {
216
+ sync = chalk.green('up to date');
217
+ }
218
+ else {
219
+ const pieces = [];
220
+ if (behind > 0) {
221
+ // Three-dot isolates upstream's side via the merge-base, so a diverged
222
+ // branch reports exactly what a pull adds (not the inverse of local commits).
223
+ const incoming = formatResourceDelta(await diffResourceEntries(git, 'HEAD...@{upstream}'));
224
+ pieces.push(`${incoming || chalk.yellow(`${behind} commit${behind > 1 ? 's' : ''}`)} ${chalk.gray('to pull')}`);
225
+ }
226
+ if (ahead > 0) {
227
+ const outgoing = formatResourceDelta(await diffResourceEntries(git, '@{upstream}...HEAD'));
228
+ pieces.push(`${outgoing || chalk.yellow(`${ahead} commit${ahead > 1 ? 's' : ''}`)} ${chalk.gray('to push')}`);
229
+ }
230
+ sync = pieces.join(chalk.gray(' · '));
231
+ }
232
+ // CHANGES: uncommitted working-tree edits, described by resource.
233
+ const localEntries = [
234
+ ...status.created.map((f) => ({ action: 'new', file: f })),
235
+ ...status.not_added.map((f) => ({ action: 'new', file: f })),
236
+ ...status.modified.map((f) => ({ action: 'changed', file: f })),
237
+ ...status.conflicted.map((f) => ({ action: 'changed', file: f })),
238
+ ...status.renamed.map((r) => ({ action: 'changed', file: r.to })),
239
+ ...status.deleted.map((f) => ({ action: 'removed', file: f })),
240
+ ];
241
+ const changes = status.isClean() ? chalk.green('clean') : formatResourceDelta(localEntries);
122
242
  const remotes = await git.getRemotes(true);
123
243
  const origin = remotes.find((r) => r.name === 'origin');
124
244
  const url = origin?.refs?.fetch || '';
125
245
  const commit = (await git.log({ maxCount: 1 })).latest?.hash.slice(0, 8) || '';
126
- const tail = url
246
+ const remote = url
127
247
  ? chalk.gray(`${url}${commit ? ` (${commit})` : ''}`)
128
248
  : commit
129
249
  ? chalk.gray(`(${commit})`)
130
250
  : '';
131
- return ` ${aliasCol} ${branch.padEnd(28)} ${remoteColored}${remotePad} ${padVisible(tree, 14)} ${tail}`;
251
+ return { alias: t.alias, cells: [branch, sync, changes, remote] };
132
252
  }
133
253
  catch (err) {
134
- return ` ${aliasCol} ${chalk.red('error')} ${err.message}`;
254
+ return { alias: t.alias, raw: `${chalk.red('error')} ${err.message}` };
135
255
  }
136
256
  }
137
257
  /**
138
258
  * Shared action body for `agents repo list` and the hidden `agents repo status`
139
- * alias. Prints a unified table: alias, branch, ahead/behind, dirty counts,
140
- * remote URL and short commit hash.
259
+ * alias. Prints an aligned table: repo, branch, resource-level sync (to pull /
260
+ * to push), resource-level local changes, and remote URL + short commit.
141
261
  */
142
262
  async function listRepos(alias) {
143
263
  const targets = collectRepoTargets(alias);
@@ -149,10 +269,26 @@ async function listRepos(alias) {
149
269
  console.log(chalk.gray('No repos to show.'));
150
270
  return;
151
271
  }
272
+ const rows = await Promise.all(targets.map(renderRepoRow));
273
+ const tableRows = rows.filter((r) => r.cells);
274
+ // Column widths grow to fit the widest visible content (resource summaries vary
275
+ // a lot in length), so the table stays aligned without truncating detail.
276
+ const headers = ['REPO', 'BRANCH', 'SYNC', 'CHANGES'];
277
+ const aliasW = Math.max(headers[0].length, ...rows.map((r) => r.alias.length));
278
+ const branchW = Math.max(headers[1].length, 0, ...tableRows.map((r) => visibleWidth(r.cells[0])));
279
+ const syncW = Math.max(headers[2].length, 0, ...tableRows.map((r) => visibleWidth(r.cells[1])));
280
+ const changesW = Math.max(headers[3].length, 0, ...tableRows.map((r) => visibleWidth(r.cells[2])));
152
281
  console.log('');
153
- console.log(` ${chalk.gray('REPO'.padEnd(12))} ${chalk.gray('BRANCH'.padEnd(28))} ${chalk.gray('REMOTE'.padEnd(12))} ${chalk.gray('LOCAL'.padEnd(14))} ${chalk.gray('URL')}`);
154
- for (const t of targets) {
155
- console.log(await renderRepoRow(t));
282
+ console.log(` ${chalk.gray(headers[0].padEnd(aliasW))} ${chalk.gray(headers[1].padEnd(branchW))} ${chalk.gray(headers[2].padEnd(syncW))} ${chalk.gray(headers[3].padEnd(changesW))} ${chalk.gray('REMOTE')}`);
283
+ for (const r of rows) {
284
+ const aliasCol = chalk.cyan(r.alias.padEnd(aliasW));
285
+ if (r.cells) {
286
+ const [branch, sync, changes, remote] = r.cells;
287
+ console.log(` ${aliasCol} ${padVisible(branch, branchW)} ${padVisible(sync, syncW)} ${padVisible(changes, changesW)} ${remote}`);
288
+ }
289
+ else {
290
+ console.log(` ${aliasCol} ${r.raw}`);
291
+ }
156
292
  }
157
293
  const userDir = getUserAgentsDir();
158
294
  if (!isGitRepo(userDir) && fs.existsSync(userDir)) {
@@ -358,7 +494,7 @@ export function registerRepoCommands(program) {
358
494
  repoCmd
359
495
  .command('list [alias]')
360
496
  .alias('ls')
361
- .description('Show all repos: branch, ahead/behind, dirty counts, URL, commit.')
497
+ .description('Show all repos with resource-level sync (skills/commands/plugins to pull or push) and local changes.')
362
498
  .action(async (alias) => {
363
499
  await listRepos(alias);
364
500
  });
@@ -6,13 +6,8 @@
6
6
  * Keychain. Bundles are injected at run time via `agents run --secrets`.
7
7
  */
8
8
  import { type Command } from 'commander';
9
- /**
10
- * SSH target for `export --to-ssh`: a bare ssh-config host alias (e.g. `yosemite-s0`)
11
- * or `user@host`. The strict allowlist blocks shell metacharacters and a leading `-`
12
- * so a target can't be smuggled in as an ssh argv flag.
13
- */
14
- export declare const SSH_TARGET_RE: RegExp;
15
- export declare function assertValidSshTarget(host: string): void;
9
+ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
10
+ export { SSH_TARGET_RE, assertValidSshTarget };
16
11
  /**
17
12
  * Serialize a resolved env map to `.env` lines that round-trip losslessly through
18
13
  * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
@@ -9,6 +9,7 @@ import { Option } from 'commander';
9
9
  import chalk from 'chalk';
10
10
  import * as fs from 'fs';
11
11
  import { spawnSync } from 'child_process';
12
+ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
12
13
  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';
13
14
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
14
15
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
@@ -134,17 +135,9 @@ function readStdinSync() {
134
135
  }
135
136
  return Buffer.concat(chunks).toString('utf-8').trim();
136
137
  }
137
- /**
138
- * SSH target for `export --to-ssh`: a bare ssh-config host alias (e.g. `yosemite-s0`)
139
- * or `user@host`. The strict allowlist blocks shell metacharacters and a leading `-`
140
- * so a target can't be smuggled in as an ssh argv flag.
141
- */
142
- export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
143
- export function assertValidSshTarget(host) {
144
- if (!SSH_TARGET_RE.test(host)) {
145
- throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host (letters, digits, '.', '_', '-').`);
146
- }
147
- }
138
+ // SSH target validation is defined canonically in src/lib/ssh-exec.ts and
139
+ // re-exported here for back-compat with existing importers of these symbols.
140
+ export { SSH_TARGET_RE, assertValidSshTarget };
148
141
  /** POSIX single-quote a string for safe interpolation into a remote shell command. */
149
142
  function shellQuote(s) {
150
143
  return `'${s.replace(/'/g, `'\\''`)}'`;
@@ -414,7 +407,7 @@ export function registerSecretsCommands(program) {
414
407
  eval "$(agents secrets export prod --plaintext)"
415
408
 
416
409
  # Push the bundle to remote machine(s) over SSH (lands as a native bundle there)
417
- agents secrets export prod --to-ssh --host yosemite-s0 --host yosemite-s1 --force
410
+ agents secrets export prod --host yosemite-s0 --host yosemite-s1 --force
418
411
 
419
412
  # Run a one-off command with secrets injected
420
413
  agents secrets exec prod -- ./deploy.sh
@@ -1017,7 +1010,7 @@ Examples:
1017
1010
  const requestedBackend = parseBackendOpt(opts.backend);
1018
1011
  // Read the bundle if it exists (inheriting its backend); otherwise
1019
1012
  // create it with the requested backend so a single `import --backend
1020
- // file` works (this is what `export --to-ssh --remote-backend file`
1013
+ // file` works (this is what `export --host ... --remote-backend file`
1021
1014
  // drives on the remote).
1022
1015
  let bundle;
1023
1016
  if (bundleExists(resolvedBundleName)) {
@@ -1094,23 +1087,22 @@ Examples:
1094
1087
  });
1095
1088
  cmd
1096
1089
  .command('export [bundle]')
1097
- .description('Resolve a bundle and print KEY=VALUE lines, push it to a 1Password vault with --to-1password, or push it to remote machine(s) over SSH with --to-ssh.')
1090
+ .description('Resolve a bundle and print KEY=VALUE lines, push it to a 1Password vault with --to-1password, or push it to remote machine(s) over SSH with --host.')
1098
1091
  .option('--plaintext', 'Acknowledge that the resolved values will be printed in the clear (shell export mode)')
1099
1092
  .option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
1100
1093
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
1101
- .option('--to-ssh', 'Push the bundle to remote machine(s) over SSH via their native agents-cli import')
1102
- .option('--host <target...>', 'SSH target(s) for --to-ssh: host alias or user@host (repeatable)')
1103
- .option('--remote-backend <backend>', 'Backend for the bundle on the remote: keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1104
- .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --to-ssh)')
1094
+ .option('--host <target...>', 'Push the bundle over SSH to this target (host alias or user@host); repeatable for multiple machines')
1095
+ .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
+ .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
1105
1097
  .action(async (bundleName, opts) => {
1106
1098
  try {
1107
1099
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
1108
1100
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
1109
- if (opts.toSsh) {
1110
- const hosts = opts.host ?? [];
1111
- if (hosts.length === 0) {
1112
- throw new Error('--to-ssh requires at least one --host <target>.');
1113
- }
1101
+ // The presence of --host selects SSH push: --host is the destination
1102
+ // and carries the mode (no separate --to-ssh needed — it would be
1103
+ // strictly redundant since SSH always requires at least one host).
1104
+ const hosts = opts.host ?? [];
1105
+ if (hosts.length > 0) {
1114
1106
  for (const h of hosts)
1115
1107
  assertValidSshTarget(h);
1116
1108
  const remoteBackend = parseBackendOpt(opts.remoteBackend);
@@ -1,6 +1,7 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { SessionMeta } from '../lib/session/types.js';
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
+ import { type PickedSession } from './sessions-picker.js';
4
5
  /** Grouped + sorted view of active sessions for the --active renderer. */
5
6
  export interface ActiveSessionsLayout {
6
7
  workspaces: Array<{
@@ -28,6 +29,7 @@ export interface ActiveSessionsLayout {
28
29
  * - sessions within a window/flat bucket: input order preserved
29
30
  */
30
31
  export declare function groupActiveSessions(sessions: ActiveSession[]): ActiveSessionsLayout;
32
+ export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number): Promise<PickedSession | null>;
31
33
  /**
32
34
  * Build the shell command that resumes a picked session.
33
35
  *
@@ -21,6 +21,7 @@ import { discoverSessions, countSessionsInScope, resolveSessionById, searchConte
21
21
  import { filterTeamSessions } from '../lib/session/team-filter.js';
22
22
  import { parseSession } from '../lib/session/parse.js';
23
23
  import { runRemoteSessions } from '../lib/session/remote.js';
24
+ import { formatRelativeTime } from '../lib/session/relative-time.js';
24
25
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
25
26
  import { renderMarkdown } from '../lib/markdown.js';
26
27
  import { colorAgent, resolveAgentName } from '../lib/agents.js';
@@ -656,7 +657,7 @@ function formatPickerLabel(s, query) {
656
657
  renderTopicCell(label, topic, query, 48, 50) +
657
658
  chalk.gray(when));
658
659
  }
659
- async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
660
+ export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
660
661
  if (hiddenCount > 0) {
661
662
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
662
663
  }
@@ -1332,7 +1333,11 @@ function findClaudeResumeTimestamp(filePath, targetTimestampMs) {
1332
1333
  }
1333
1334
  }
1334
1335
  function isWithinProject(sessionCwd, projectRoot) {
1335
- return sessionCwd === projectRoot || sessionCwd.startsWith(projectRoot + path.sep);
1336
+ // Compare separator- and case-normalized (Windows folds `\`→`/` and lowercases)
1337
+ // so a backslash session cwd matches a forward-slash project root and vice versa.
1338
+ const cwd = toComparablePath(sessionCwd);
1339
+ const root = toComparablePath(projectRoot);
1340
+ return cwd === root || cwd.startsWith(root + '/');
1336
1341
  }
1337
1342
  function sessionDistance(session, historyEntry) {
1338
1343
  if (!historyEntry.timestampMs)
@@ -1360,25 +1365,3 @@ function padRight(s, width) {
1360
1365
  function truncate(s, max) {
1361
1366
  return s.length > max ? s.slice(0, max - 1) + '.' : s;
1362
1367
  }
1363
- function formatRelativeTime(isoTimestamp) {
1364
- const now = Date.now();
1365
- const then = new Date(isoTimestamp).getTime();
1366
- if (isNaN(then))
1367
- return isoTimestamp;
1368
- const diffMs = now - then;
1369
- const diffMin = Math.floor(diffMs / 60_000);
1370
- const diffHrs = Math.floor(diffMs / 3_600_000);
1371
- const diffDays = Math.floor(diffMs / 86_400_000);
1372
- if (diffMin < 1)
1373
- return 'just now';
1374
- if (diffMin < 60)
1375
- return `${diffMin} min ago`;
1376
- if (diffHrs < 24)
1377
- return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
1378
- if (diffDays < 7)
1379
- return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
1380
- // Older: show date
1381
- const d = new Date(then);
1382
- const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1383
- return `${months[d.getMonth()]} ${d.getDate()}`;
1384
- }
@@ -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) {