@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.
- package/CHANGELOG.md +29 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +182 -30
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +177 -6
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +15 -23
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +27 -25
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +4 -13
- package/dist/lib/agent-spec.d.ts +36 -0
- package/dist/lib/agent-spec.js +157 -0
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +45 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/doctor-diff.d.ts +19 -0
- package/dist/lib/doctor-diff.js +107 -15
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugin-marketplace.d.ts +18 -0
- package/dist/lib/plugin-marketplace.js +67 -1
- package/dist/lib/plugins.d.ts +33 -1
- package/dist/lib/plugins.js +56 -11
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +23 -0
- package/dist/lib/session/db.js +76 -1
- package/dist/lib/session/discover.d.ts +26 -0
- package/dist/lib/session/discover.js +75 -4
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +61 -0
- package/dist/lib/session/remote.js +221 -0
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/staleness/detectors/commands.js +7 -6
- package/dist/lib/staleness/writers/commands.js +7 -12
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/startup/dev-build.d.ts +22 -0
- package/dist/lib/startup/dev-build.js +41 -0
- package/dist/lib/types.d.ts +28 -0
- package/dist/lib/versions.d.ts +9 -3
- package/dist/lib/versions.js +43 -7
- package/package.json +3 -3
- 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
|
+
}
|
package/dist/commands/repo.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/repo.js
CHANGED
|
@@ -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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
102
|
-
*
|
|
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
|
|
198
|
+
return { alias: t.alias, raw: `${chalk.red('missing')} ${chalk.gray(t.dir)}` };
|
|
109
199
|
}
|
|
110
200
|
if (!isGitRepo(t.dir)) {
|
|
111
|
-
return
|
|
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
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
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
|
|
246
|
+
const remote = url
|
|
127
247
|
? chalk.gray(`${url}${commit ? ` (${commit})` : ''}`)
|
|
128
248
|
: commit
|
|
129
249
|
? chalk.gray(`(${commit})`)
|
|
130
250
|
: '';
|
|
131
|
-
return
|
|
251
|
+
return { alias: t.alias, cells: [branch, sync, changes, remote] };
|
|
132
252
|
}
|
|
133
253
|
catch (err) {
|
|
134
|
-
return
|
|
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
|
|
140
|
-
* remote URL
|
|
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(
|
|
154
|
-
for (const
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
-
|
|
139
|
-
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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('--
|
|
1102
|
-
.option('--
|
|
1103
|
-
.option('--
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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);
|