@phnx-labs/agents-cli 1.20.26 → 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 (95) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +182 -30
  4. package/dist/commands/exec.d.ts +1 -1
  5. package/dist/commands/exec.js +177 -6
  6. package/dist/commands/hosts.d.ts +11 -0
  7. package/dist/commands/hosts.js +229 -0
  8. package/dist/commands/repo.d.ts +29 -0
  9. package/dist/commands/repo.js +174 -38
  10. package/dist/commands/secrets.d.ts +2 -7
  11. package/dist/commands/secrets.js +15 -23
  12. package/dist/commands/sessions.d.ts +2 -0
  13. package/dist/commands/sessions.js +27 -25
  14. package/dist/commands/sync.d.ts +2 -0
  15. package/dist/commands/sync.js +22 -5
  16. package/dist/commands/view.js +27 -11
  17. package/dist/index.js +4 -13
  18. package/dist/lib/agent-spec.d.ts +36 -0
  19. package/dist/lib/agent-spec.js +157 -0
  20. package/dist/lib/agents.d.ts +1 -0
  21. package/dist/lib/agents.js +45 -4
  22. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  23. package/dist/lib/browser/drivers/ssh.js +113 -24
  24. package/dist/lib/browser/profiles.js +28 -1
  25. package/dist/lib/browser/runtime-state.js +28 -8
  26. package/dist/lib/browser/types.d.ts +10 -1
  27. package/dist/lib/cli-resources.js +10 -1
  28. package/dist/lib/daemon.js +32 -0
  29. package/dist/lib/doctor-diff.d.ts +19 -0
  30. package/dist/lib/doctor-diff.js +107 -15
  31. package/dist/lib/exec.d.ts +27 -0
  32. package/dist/lib/exec.js +62 -19
  33. package/dist/lib/heal.d.ts +107 -0
  34. package/dist/lib/heal.js +279 -0
  35. package/dist/lib/hooks.d.ts +17 -0
  36. package/dist/lib/hooks.js +127 -3
  37. package/dist/lib/hosts/dispatch.d.ts +26 -0
  38. package/dist/lib/hosts/dispatch.js +71 -0
  39. package/dist/lib/hosts/progress.d.ts +21 -0
  40. package/dist/lib/hosts/progress.js +49 -0
  41. package/dist/lib/hosts/providers/local.d.ts +17 -0
  42. package/dist/lib/hosts/providers/local.js +81 -0
  43. package/dist/lib/hosts/ready.d.ts +37 -0
  44. package/dist/lib/hosts/ready.js +88 -0
  45. package/dist/lib/hosts/registry.d.ts +22 -0
  46. package/dist/lib/hosts/registry.js +65 -0
  47. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  48. package/dist/lib/hosts/ssh-config.js +157 -0
  49. package/dist/lib/hosts/tasks.d.ts +32 -0
  50. package/dist/lib/hosts/tasks.js +58 -0
  51. package/dist/lib/hosts/types.d.ts +51 -0
  52. package/dist/lib/hosts/types.js +21 -0
  53. package/dist/lib/loop.d.ts +9 -0
  54. package/dist/lib/loop.js +13 -1
  55. package/dist/lib/mcp.js +12 -3
  56. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  57. package/dist/lib/migrate.js +9 -5
  58. package/dist/lib/platform/exec.d.ts +10 -0
  59. package/dist/lib/platform/exec.js +17 -0
  60. package/dist/lib/platform/index.d.ts +1 -0
  61. package/dist/lib/platform/index.js +1 -0
  62. package/dist/lib/platform/links.d.ts +15 -0
  63. package/dist/lib/platform/links.js +42 -0
  64. package/dist/lib/platform/paths.d.ts +18 -0
  65. package/dist/lib/platform/paths.js +22 -0
  66. package/dist/lib/platform/posixpath.d.ts +28 -0
  67. package/dist/lib/platform/posixpath.js +153 -0
  68. package/dist/lib/plugin-marketplace.d.ts +18 -0
  69. package/dist/lib/plugin-marketplace.js +67 -1
  70. package/dist/lib/plugins.d.ts +33 -1
  71. package/dist/lib/plugins.js +56 -11
  72. package/dist/lib/project-launch.js +6 -3
  73. package/dist/lib/sandbox.js +5 -2
  74. package/dist/lib/self-update.js +7 -2
  75. package/dist/lib/session/db.d.ts +23 -0
  76. package/dist/lib/session/db.js +76 -1
  77. package/dist/lib/session/discover.d.ts +26 -0
  78. package/dist/lib/session/discover.js +75 -4
  79. package/dist/lib/session/relative-time.d.ts +7 -0
  80. package/dist/lib/session/relative-time.js +28 -0
  81. package/dist/lib/session/remote.d.ts +61 -0
  82. package/dist/lib/session/remote.js +221 -0
  83. package/dist/lib/ssh-exec.d.ts +45 -0
  84. package/dist/lib/ssh-exec.js +61 -0
  85. package/dist/lib/staleness/detectors/commands.js +7 -6
  86. package/dist/lib/staleness/writers/commands.js +7 -12
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/startup/dev-build.d.ts +22 -0
  90. package/dist/lib/startup/dev-build.js +41 -0
  91. package/dist/lib/types.d.ts +28 -0
  92. package/dist/lib/versions.d.ts +9 -3
  93. package/dist/lib/versions.js +43 -7
  94. package/package.json +3 -3
  95. package/scripts/postinstall.js +62 -0
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `agents hosts` — register and inspect agent hosts (machines you offload runs to).
3
+ *
4
+ * The registry is a thin overlay: ssh-config hosts are dispatchable with zero
5
+ * registration (connection details stay in ~/.ssh/config); enrollment only adds
6
+ * capability metadata or bootstraps agents-cli, plus inline (non-ssh-config)
7
+ * hosts. Dispatch itself is `agents run --host <name>` (see commands/exec.ts).
8
+ */
9
+ import type { Command } from 'commander';
10
+ /** Register the `agents hosts` command tree. */
11
+ export declare function registerHostsCommand(program: Command): void;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `agents hosts` — register and inspect agent hosts (machines you offload runs to).
3
+ *
4
+ * The registry is a thin overlay: ssh-config hosts are dispatchable with zero
5
+ * registration (connection details stay in ~/.ssh/config); enrollment only adds
6
+ * capability metadata or bootstraps agents-cli, plus inline (non-ssh-config)
7
+ * hosts. Dispatch itself is `agents run --host <name>` (see commands/exec.ts).
8
+ */
9
+ import chalk from 'chalk';
10
+ import { checkbox, confirm } from '@inquirer/prompts';
11
+ import { assertValidSshTarget } from '../lib/ssh-exec.js';
12
+ import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
13
+ import { sshTargetFor } from '../lib/hosts/types.js';
14
+ import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
15
+ import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
16
+ import { listTasks, loadTask, localLogPath } from '../lib/hosts/tasks.js';
17
+ import { followHostTask } from '../lib/hosts/progress.js';
18
+ import * as fs from 'fs';
19
+ /** Parse `user@host` or `host` into its pieces. */
20
+ function parseTarget(target) {
21
+ const at = target.indexOf('@');
22
+ if (at === -1)
23
+ return { address: target };
24
+ return { user: target.slice(0, at), address: target.slice(at + 1) };
25
+ }
26
+ /** Bootstrap/verify agents-cli on a freshly-enrolled host (best-effort, prompts). */
27
+ async function maybeBootstrap(target, hostName) {
28
+ const probe = probeHost(target);
29
+ if (!probe.reachable) {
30
+ console.log(chalk.yellow(` Not reachable over SSH yet — skipping bootstrap. Fix key auth, then: agents hosts check ${hostName}`));
31
+ return;
32
+ }
33
+ const remoteVer = remoteAgentsVersion(target);
34
+ const localVer = localCliVersion();
35
+ if (!remoteVer) {
36
+ const ok = await confirm({ message: ` agents-cli not found on ${hostName}. Install ${localVer ? `v${localVer}` : 'latest'} now?`, default: true });
37
+ if (ok) {
38
+ console.log(chalk.gray(' Installing agents-cli on the host…'));
39
+ const r = bootstrapAgentsCli(target, localVer);
40
+ console.log(r.ok ? chalk.green(' Installed.') : chalk.red(` Install failed:\n${r.output}`));
41
+ }
42
+ return;
43
+ }
44
+ const remoteClean = remoteVer.replace(/^v/, '');
45
+ if (localVer && remoteClean !== localVer) {
46
+ const ok = await confirm({ message: ` ${hostName} has agents-cli ${remoteClean}, you have ${localVer}. Upgrade to match?`, default: false });
47
+ if (ok) {
48
+ const r = bootstrapAgentsCli(target, localVer);
49
+ console.log(r.ok ? chalk.green(' Upgraded.') : chalk.red(` Upgrade failed:\n${r.output}`));
50
+ }
51
+ }
52
+ }
53
+ async function registerHost(spec) {
54
+ const provider = getProvider('local');
55
+ await provider.register(spec);
56
+ }
57
+ async function doAdd(name, target, opts) {
58
+ // No name + no target → interactive scan of ssh sources.
59
+ if (!name && !target) {
60
+ const existing = new Set((await listAllHosts()).filter((h) => h.enrolled).map((h) => h.name));
61
+ const candidates = [...new Set([...listSshConfigHosts(), ...listKnownHosts()])].filter((c) => !existing.has(c));
62
+ if (candidates.length === 0) {
63
+ console.log(chalk.yellow('No SSH hosts found in ~/.ssh/config or ~/.ssh/known_hosts. Add one explicitly: agents hosts add <name> <user@host>'));
64
+ return;
65
+ }
66
+ const picked = await checkbox({
67
+ message: 'Select hosts to enroll (connection details come from ~/.ssh/config)',
68
+ choices: candidates.map((c) => ({ value: c, name: c })),
69
+ });
70
+ for (const c of picked) {
71
+ // The ssh target is the candidate name itself either way: ssh resolves it
72
+ // for ssh-config hosts, and it's a reachable hostname for known_hosts ones.
73
+ const source = isSshConfigHost(c) ? 'ssh-config' : 'inline';
74
+ const probe = probeHost(c);
75
+ await registerHost({ name: c, provider: 'local', source, ...(source === 'inline' ? { address: c } : {}), os: probe.os, caps: opts.cap });
76
+ console.log(chalk.green(`Enrolled ${c}`) + chalk.gray(` (${source}${probe.os ? `, ${probe.os}` : ''})`));
77
+ if (opts.enroll !== false)
78
+ await maybeBootstrap(c, c);
79
+ }
80
+ return;
81
+ }
82
+ if (!name) {
83
+ console.log(chalk.red('Usage: agents hosts add <name> [user@host]'));
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ let spec;
88
+ let sshTarget;
89
+ if (target) {
90
+ assertValidSshTarget(target);
91
+ const { address, user } = parseTarget(target);
92
+ spec = { name, provider: 'local', source: 'inline', address, user, caps: opts.cap, os: opts.os };
93
+ sshTarget = target;
94
+ }
95
+ else if (isSshConfigHost(name)) {
96
+ spec = { name, provider: 'local', source: 'ssh-config', caps: opts.cap, os: opts.os };
97
+ sshTarget = name;
98
+ }
99
+ else {
100
+ console.log(chalk.red(`"${name}" is not in ~/.ssh/config. Pass a target: agents hosts add ${name} <user@host>`));
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ const probe = probeHost(sshTarget);
105
+ if (!spec.os && probe.os)
106
+ spec.os = probe.os;
107
+ await registerHost(spec);
108
+ console.log(chalk.green(`Enrolled ${name}`) + chalk.gray(` (${spec.source}${spec.os ? `, ${spec.os}` : ''}${spec.caps?.length ? `, caps: ${spec.caps.join(',')}` : ''})`));
109
+ if (opts.enroll !== false)
110
+ await maybeBootstrap(sshTarget, name);
111
+ }
112
+ async function doList(json) {
113
+ const hosts = await listAllHosts();
114
+ if (json) {
115
+ console.log(JSON.stringify(hosts, null, 2));
116
+ return;
117
+ }
118
+ if (hosts.length === 0) {
119
+ console.log(chalk.gray('No hosts. Enroll one: agents hosts add <name> <user@host> (or just: agents hosts add)'));
120
+ return;
121
+ }
122
+ console.log(chalk.bold('NAME').padEnd(20) + chalk.bold('SOURCE').padEnd(13) + chalk.bold('TARGET').padEnd(28) + chalk.bold('CAPS'));
123
+ for (const h of hosts) {
124
+ const tgt = h.source === 'ssh-config' ? chalk.gray('(ssh-config)') : `${h.user ? h.user + '@' : ''}${h.address ?? ''}`;
125
+ const mark = h.enrolled ? '' : chalk.gray(' ·available');
126
+ console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgt.padEnd(28) + (h.caps?.join(',') ?? '') + mark);
127
+ }
128
+ }
129
+ async function doCheck(name) {
130
+ const host = await resolveHost(name);
131
+ if (!host) {
132
+ console.log(chalk.red(`Unknown host "${name}". Known: ${(await listAllHosts()).map((h) => h.name).join(', ') || '(none)'}`));
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ const target = sshTargetFor(host);
137
+ process.stdout.write(`Probing ${chalk.cyan(name)} (${target})… `);
138
+ const probe = probeHost(target);
139
+ if (!probe.reachable) {
140
+ console.log(chalk.red('unreachable'));
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+ console.log(chalk.green('reachable') + chalk.gray(probe.os ? ` · ${probe.os}` : ''));
145
+ const ver = remoteAgentsVersion(target);
146
+ console.log(` agents-cli: ${ver ? chalk.green(ver) : chalk.yellow('not installed')}`);
147
+ }
148
+ async function doRemove(name) {
149
+ const host = await resolveHost(name);
150
+ if (!host || !host.enrolled) {
151
+ console.log(chalk.yellow(`"${name}" is not enrolled (nothing to remove).`));
152
+ return;
153
+ }
154
+ await getProvider('local').remove(name);
155
+ console.log(chalk.green(`Removed ${name}`));
156
+ }
157
+ async function doPs(json) {
158
+ const tasks = listTasks();
159
+ if (json) {
160
+ console.log(JSON.stringify(tasks, null, 2));
161
+ return;
162
+ }
163
+ if (tasks.length === 0) {
164
+ console.log(chalk.gray('No host tasks yet. Dispatch one: agents run <agent> "<task>" --host <name>'));
165
+ return;
166
+ }
167
+ console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
168
+ for (const t of tasks) {
169
+ const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
170
+ console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + t.prompt.slice(0, 50));
171
+ }
172
+ }
173
+ async function doLogs(id, follow) {
174
+ const task = loadTask(id);
175
+ if (!task) {
176
+ console.log(chalk.red(`Unknown task "${id}".`));
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ if (follow && task.status === 'running') {
181
+ const code = await followHostTask(task.target, { remoteLog: task.remoteLog, remoteExit: task.remoteExit, taskId: id, echo: true });
182
+ process.exitCode = code === -1 ? 1 : code;
183
+ return;
184
+ }
185
+ try {
186
+ process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
187
+ }
188
+ catch {
189
+ console.log(chalk.gray('(no local log captured for this task)'));
190
+ }
191
+ }
192
+ /** Register the `agents hosts` command tree. */
193
+ export function registerHostsCommand(program) {
194
+ const hosts = program
195
+ .command('hosts')
196
+ .description('Register and inspect agent hosts (machines you offload runs to with `agents run --host <name>`).');
197
+ hosts
198
+ .command('add [name] [target]')
199
+ .description('Enroll a host. With no args, pick from ~/.ssh/config + known_hosts. `target` is user@host for hosts not in ssh config.')
200
+ .option('--cap <cap...>', 'Capability tag(s) for routing (e.g. --cap gpu)')
201
+ .option('--os <os>', 'Override detected OS label')
202
+ .option('--no-enroll', 'Register only — skip the remote agents-cli bootstrap/version check')
203
+ .action((name, target, opts) => doAdd(name, target, opts));
204
+ hosts
205
+ .command('list')
206
+ .alias('ls')
207
+ .description('List enrolled + ssh-config hosts (metadata only, no probing).')
208
+ .option('--json', 'Output JSON')
209
+ .action((opts) => doList(!!opts.json));
210
+ hosts
211
+ .command('check <name>')
212
+ .description('Probe one host: reachable? agents-cli version?')
213
+ .action((name) => doCheck(name));
214
+ hosts
215
+ .command('remove <name>')
216
+ .alias('rm')
217
+ .description('Remove a host from the registry (does not touch ~/.ssh/config).')
218
+ .action((name) => doRemove(name));
219
+ hosts
220
+ .command('ps')
221
+ .description('List dispatched host tasks.')
222
+ .option('--json', 'Output JSON')
223
+ .action((opts) => doPs(!!opts.json));
224
+ hosts
225
+ .command('logs <id>')
226
+ .description('Show a host task log; -f to follow a running one.')
227
+ .option('-f, --follow', 'Follow live output')
228
+ .action((id, opts) => doLogs(id, !!opts.follow));
229
+ }
@@ -11,5 +11,34 @@
11
11
  * agent version homes after the user repo's (user-wins on name collisions).
12
12
  */
13
13
  import type { Command } from 'commander';
14
+ /**
15
+ * These are DotAgent *resource* repos, so changes are reported at the resource
16
+ * level ("2 skills, 1 hook") rather than the raw-file level. A resource is the
17
+ * unit a user reasons about: one skill, one command, one plugin — even if it
18
+ * spans several files on disk.
19
+ */
20
+ type RepoResourceKind = 'skill' | 'command' | 'plugin' | 'hook' | 'mcp' | 'subagent' | 'rule' | 'workflow' | 'routine' | 'profile' | 'permission' | 'cli' | 'config' | 'other';
21
+ export type ChangeAction = 'new' | 'changed' | 'removed';
22
+ /**
23
+ * Map a repo-relative path to the resource unit it belongs to. Directory-based
24
+ * resources (skills/foo/SKILL.md) collapse to `skills/foo` so all their files
25
+ * count as one unit; flat config files (agents.yaml, hooks.yaml) count alone.
26
+ */
27
+ export declare function resourceUnit(file: string): {
28
+ kind: RepoResourceKind;
29
+ unit: string;
30
+ };
31
+ /**
32
+ * Render a set of changed files as a resource-level summary, e.g.
33
+ * `2 new skills, 1 changed hook`. Counts distinct resource units (a skill whose
34
+ * three files all changed is "1 changed skill"), grouped by action then kind,
35
+ * and colors each phrase green/yellow/red for new/changed/removed. Caps at
36
+ * `maxParts` phrases, appending `+N more` so a big diff stays scannable.
37
+ */
38
+ export declare function formatResourceDelta(entries: {
39
+ action: ChangeAction;
40
+ file: string;
41
+ }[], maxParts?: number): string;
14
42
  /** Register the `agents repo` command tree. */
15
43
  export declare function registerRepoCommands(program: Command): void;
44
+ export {};
@@ -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);