@phnx-labs/agents-cli 1.20.61 → 1.20.63

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 (89) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +51 -1
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +100 -28
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/commands.js +29 -0
  32. package/dist/lib/convert.d.ts +11 -0
  33. package/dist/lib/convert.js +22 -0
  34. package/dist/lib/daemon.js +2 -0
  35. package/dist/lib/devices/fleet.d.ts +62 -0
  36. package/dist/lib/devices/fleet.js +128 -0
  37. package/dist/lib/doctor-diff.js +17 -1
  38. package/dist/lib/funnel.d.ts +5 -0
  39. package/dist/lib/funnel.js +23 -0
  40. package/dist/lib/git.d.ts +21 -5
  41. package/dist/lib/git.js +64 -14
  42. package/dist/lib/goose-commands.d.ts +41 -0
  43. package/dist/lib/goose-commands.js +176 -0
  44. package/dist/lib/hooks.js +134 -0
  45. package/dist/lib/hosts/credentials.d.ts +28 -0
  46. package/dist/lib/hosts/credentials.js +48 -0
  47. package/dist/lib/hosts/dispatch.d.ts +25 -0
  48. package/dist/lib/hosts/dispatch.js +68 -2
  49. package/dist/lib/hosts/passthrough.d.ts +13 -10
  50. package/dist/lib/hosts/passthrough.js +119 -29
  51. package/dist/lib/mailbox-gc.js +4 -16
  52. package/dist/lib/migrate.d.ts +12 -0
  53. package/dist/lib/migrate.js +55 -1
  54. package/dist/lib/permissions.d.ts +15 -0
  55. package/dist/lib/permissions.js +99 -0
  56. package/dist/lib/plugins.d.ts +20 -0
  57. package/dist/lib/plugins.js +118 -0
  58. package/dist/lib/resources/permissions.js +2 -0
  59. package/dist/lib/routines.d.ts +29 -10
  60. package/dist/lib/routines.js +47 -15
  61. package/dist/lib/session/active.d.ts +8 -1
  62. package/dist/lib/session/active.js +1 -0
  63. package/dist/lib/session/parse.js +12 -0
  64. package/dist/lib/session/state.d.ts +33 -0
  65. package/dist/lib/session/state.js +40 -0
  66. package/dist/lib/session/sync/agents.d.ts +2 -0
  67. package/dist/lib/session/sync/agents.js +39 -1
  68. package/dist/lib/session/sync/config.d.ts +8 -0
  69. package/dist/lib/session/sync/config.js +6 -1
  70. package/dist/lib/session/sync/provision.d.ts +49 -0
  71. package/dist/lib/session/sync/provision.js +91 -0
  72. package/dist/lib/session/sync/sync.d.ts +3 -0
  73. package/dist/lib/session/sync/sync.js +26 -6
  74. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  75. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  76. package/dist/lib/staleness/detectors/permissions.js +23 -0
  77. package/dist/lib/staleness/detectors/subagents.js +36 -0
  78. package/dist/lib/staleness/writers/commands.js +7 -0
  79. package/dist/lib/staleness/writers/hooks.js +1 -1
  80. package/dist/lib/staleness/writers/subagents.js +22 -3
  81. package/dist/lib/startup/command-registry.d.ts +2 -0
  82. package/dist/lib/startup/command-registry.js +11 -0
  83. package/dist/lib/state.d.ts +10 -2
  84. package/dist/lib/state.js +14 -2
  85. package/dist/lib/subagents.d.ts +32 -0
  86. package/dist/lib/subagents.js +238 -0
  87. package/dist/lib/triggers/webhook.d.ts +70 -27
  88. package/dist/lib/triggers/webhook.js +264 -43
  89. package/package.json +1 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Read-only listing of a browser profile's on-disk captures — screenshots, PDFs,
3
+ * recordings (`<profile>/sessions/<task>/`) and downloads (`<profile>/downloads/`).
4
+ * Reads straight from `.cache/browser/<profile>/`, so it works whether or not the
5
+ * browser daemon is running. Backs both `agents browser sessions` and the
6
+ * `agents sessions --browser` alias.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { spawnSync } from 'child_process';
11
+ import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
12
+ import { formatRelativeTime } from '../session/relative-time.js';
13
+ const EXT_KIND = {
14
+ '.png': 'screenshot',
15
+ '.jpg': 'screenshot',
16
+ '.jpeg': 'screenshot',
17
+ '.webp': 'screenshot',
18
+ '.pdf': 'pdf',
19
+ '.webm': 'recording',
20
+ };
21
+ function statSafe(p) {
22
+ try {
23
+ return fs.statSync(p);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function walkFiles(dir) {
30
+ let out = [];
31
+ let entries;
32
+ try {
33
+ entries = fs.readdirSync(dir, { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return out;
37
+ }
38
+ for (const e of entries) {
39
+ const full = path.join(dir, e.name);
40
+ if (e.isDirectory())
41
+ out = out.concat(walkFiles(full));
42
+ else if (e.isFile())
43
+ out.push(full);
44
+ }
45
+ return out;
46
+ }
47
+ /** Every capture for one profile, newest first. */
48
+ export function listProfileArtifacts(profile) {
49
+ const root = getProfileRuntimeDir(profile);
50
+ const artifacts = [];
51
+ const sessionsRoot = path.join(root, 'sessions');
52
+ let taskDirs = [];
53
+ try {
54
+ taskDirs = fs.readdirSync(sessionsRoot, { withFileTypes: true });
55
+ }
56
+ catch { /* none */ }
57
+ for (const t of taskDirs) {
58
+ if (!t.isDirectory())
59
+ continue;
60
+ for (const file of walkFiles(path.join(sessionsRoot, t.name))) {
61
+ const kind = EXT_KIND[path.extname(file).toLowerCase()];
62
+ if (!kind)
63
+ continue;
64
+ const st = statSafe(file);
65
+ if (!st)
66
+ continue;
67
+ artifacts.push({ kind, task: t.name, name: path.basename(file), path: file, bytes: st.size, mtimeMs: st.mtimeMs });
68
+ }
69
+ }
70
+ for (const file of walkFiles(path.join(root, 'downloads'))) {
71
+ const st = statSafe(file);
72
+ if (!st)
73
+ continue;
74
+ artifacts.push({ kind: 'download', name: path.basename(file), path: file, bytes: st.size, mtimeMs: st.mtimeMs });
75
+ }
76
+ artifacts.sort((a, b) => b.mtimeMs - a.mtimeMs);
77
+ return artifacts;
78
+ }
79
+ /**
80
+ * Captures grouped by profile. With `only` set, returns just that profile (even
81
+ * when empty); otherwise every profile dir on disk that has at least one capture.
82
+ */
83
+ export function listBrowserSessions(only) {
84
+ let profiles;
85
+ if (only) {
86
+ profiles = [only];
87
+ }
88
+ else {
89
+ try {
90
+ profiles = fs.readdirSync(getBrowserRuntimeDir(), { withFileTypes: true })
91
+ .filter((e) => e.isDirectory() && e.name !== 'sessions')
92
+ .map((e) => e.name)
93
+ .sort();
94
+ }
95
+ catch {
96
+ profiles = [];
97
+ }
98
+ }
99
+ return profiles
100
+ .map((p) => ({ profile: p, artifacts: listProfileArtifacts(p) }))
101
+ .filter((r) => !!only || r.artifacts.length > 0);
102
+ }
103
+ function formatBytes(n) {
104
+ if (n < 1024)
105
+ return `${n} B`;
106
+ const units = ['KB', 'MB', 'GB'];
107
+ let val = n / 1024;
108
+ let i = 0;
109
+ while (val >= 1024 && i < units.length - 1) {
110
+ val /= 1024;
111
+ i++;
112
+ }
113
+ return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
114
+ }
115
+ /** Human table for the CLI. Returns lines (no trailing newline). */
116
+ export function renderBrowserSessions(groups) {
117
+ if (groups.length === 0)
118
+ return 'No browser profiles found.';
119
+ const lines = [];
120
+ for (const g of groups) {
121
+ const counts = { screenshot: 0, pdf: 0, recording: 0, download: 0 };
122
+ for (const a of g.artifacts)
123
+ counts[a.kind]++;
124
+ lines.push(`${g.profile} ` +
125
+ `screenshots ${counts.screenshot} pdfs ${counts.pdf} recordings ${counts.recording} downloads ${counts.download}`);
126
+ if (g.artifacts.length === 0) {
127
+ lines.push(' (no captures yet)');
128
+ continue;
129
+ }
130
+ for (const a of g.artifacts) {
131
+ const when = formatRelativeTime(new Date(a.mtimeMs).toISOString());
132
+ const where = a.kind === 'download' ? 'downloads/' : `sessions/${a.task}/`;
133
+ lines.push(` ${when.padEnd(12)} ${a.name.padEnd(28)} ${formatBytes(a.bytes).padStart(8)} ${where}`);
134
+ }
135
+ }
136
+ return lines.join('\n');
137
+ }
138
+ /**
139
+ * Resolve `--open <sel>`: `latest` (newest across the groups) or a filename
140
+ * substring match. Returns the absolute path, or null if nothing matched.
141
+ */
142
+ export function resolveArtifact(groups, selector) {
143
+ const all = groups.flatMap((g) => g.artifacts).sort((a, b) => b.mtimeMs - a.mtimeMs);
144
+ if (all.length === 0)
145
+ return null;
146
+ if (selector === 'latest')
147
+ return all[0].path;
148
+ const hit = all.find((a) => a.name === selector) ?? all.find((a) => a.name.includes(selector));
149
+ return hit ? hit.path : null;
150
+ }
151
+ /** Open a file in the OS default app. Returns true on success. */
152
+ export function openArtifact(filePath) {
153
+ const openers = process.platform === 'darwin'
154
+ ? [['open', [filePath]]]
155
+ : process.platform === 'win32'
156
+ ? [['cmd', ['/c', 'start', '""', filePath]]]
157
+ : [['xdg-open', [filePath]], ['gnome-open', [filePath]]];
158
+ for (const [cmd, args] of openers) {
159
+ if (spawnSync(cmd, args, { stdio: 'ignore' }).status === 0)
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ /**
165
+ * Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
166
+ * `open` is the Commander value for `--open [selector]`: undefined when the flag
167
+ * is absent, `true` when passed bare (defaults to 'latest'), or the selector string.
168
+ */
169
+ export function runBrowserSessions(opts) {
170
+ const groups = listBrowserSessions(opts.profile);
171
+ if (opts.open !== undefined && opts.open !== false) {
172
+ const selector = opts.open === true ? 'latest' : opts.open;
173
+ const target = resolveArtifact(groups, selector);
174
+ if (!target) {
175
+ console.error(`No capture matching "${selector}".`);
176
+ process.exit(1);
177
+ }
178
+ console.log(target);
179
+ if (!openArtifact(target)) {
180
+ console.error(`Could not open ${target}`);
181
+ process.exit(1);
182
+ }
183
+ return;
184
+ }
185
+ if (opts.json) {
186
+ console.log(JSON.stringify(groups, null, 2));
187
+ return;
188
+ }
189
+ console.log(renderBrowserSessions(groups));
190
+ }
@@ -15,6 +15,7 @@ import { markdownToToml } from './convert.js';
15
15
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
16
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
17
17
  import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
18
+ import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
18
19
  function compareVersions(a, b) {
19
20
  const aParts = a.split('.').map((n) => parseInt(n, 10) || 0);
20
21
  const bParts = b.split('.').map((n) => parseInt(n, 10) || 0);
@@ -209,6 +210,19 @@ export function installCommand(sourcePath, agentId, commandName, method = 'symli
209
210
  const agent = AGENTS[agentId];
210
211
  ensureCommandsDir(agentId);
211
212
  const home = getEffectiveHome(agentId);
213
+ // Goose: a slash command is a recipe YAML registered in config.yaml, not a
214
+ // native command file under commandsSubdir.
215
+ if (agentId === 'goose') {
216
+ const result = installGooseCommandToVersion(home, commandName, sourcePath);
217
+ if (!result.success) {
218
+ return { path: '', method: 'copy', error: result.error, warnings: validation.warnings };
219
+ }
220
+ return {
221
+ path: path.join(home, '.config', 'goose', 'commands', `${commandName}.yaml`),
222
+ method: 'copy',
223
+ warnings: validation.warnings,
224
+ };
225
+ }
212
226
  const commandsDir = path.join(home, agentConfigDirName(agentId), agent.commandsSubdir);
213
227
  fs.mkdirSync(commandsDir, { recursive: true });
214
228
  const ext = agent.format === 'toml' ? '.toml' : '.md';
@@ -248,6 +262,9 @@ export function listCommandsInVersionHome(agent, version) {
248
262
  if (shouldInstallCommandAsSkill(agent, version)) {
249
263
  return listCommandSkillsInVersion(agentDir);
250
264
  }
265
+ if (agent === 'goose') {
266
+ return listGooseCommandsInVersion(versionHome);
267
+ }
251
268
  const dir = getVersionCommandsDir(agent, version);
252
269
  if (!fs.existsSync(dir))
253
270
  return [];
@@ -270,6 +287,9 @@ function versionCommandMatches(agent, version, commandName) {
270
287
  if (shouldInstallCommandAsSkill(agent, version)) {
271
288
  return commandSkillMatches(agentDir, commandName, sourcePath);
272
289
  }
290
+ if (agent === 'goose') {
291
+ return gooseCommandMatches(versionHome, commandName, sourcePath);
292
+ }
273
293
  const agentConfig = AGENTS[agent];
274
294
  const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
275
295
  const installedPath = path.join(getVersionCommandsDir(agent, version), `${commandName}${ext}`);
@@ -357,6 +377,11 @@ export function installCommandToVersion(agent, version, commandName, method = 'c
357
377
  ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills')),
358
378
  ]);
359
379
  }
380
+ // Goose: a slash command is a recipe YAML registered in config.yaml, not a
381
+ // native command file. Write the recipe + slash_commands entry.
382
+ if (agent === 'goose') {
383
+ return installGooseCommandToVersion(versionHome, commandName, sourcePath);
384
+ }
360
385
  const agentConfig = AGENTS[agent];
361
386
  const commandsDir = getVersionCommandsDir(agent, version);
362
387
  fs.mkdirSync(commandsDir, { recursive: true });
@@ -392,6 +417,10 @@ export function removeCommandFromVersion(agent, version, commandName) {
392
417
  if (shouldInstallCommandAsSkill(agent, version)) {
393
418
  return removeCommandSkillFromVersion(agentDir, commandName);
394
419
  }
420
+ if (agent === 'goose') {
421
+ const trashDir = path.join(getTrashCommandsDir(), agent, version, commandName);
422
+ return removeGooseCommandFromVersion(versionHome, commandName, trashDir);
423
+ }
395
424
  const ext = AGENTS[agent].format === 'toml' ? '.toml' : '.md';
396
425
  const targetPath = path.join(getVersionCommandsDir(agent, version), `${commandName}${ext}`);
397
426
  if (!fs.existsSync(targetPath) && !fs.lstatSync(targetPath, { throwIfNoEntry: false })) {
@@ -16,5 +16,16 @@ export declare function parseMarkdownFrontmatter(content: string): {
16
16
  };
17
17
  /** Convert a Markdown command file to Gemini's TOML format, translating $ARGUMENTS to {{args}}. */
18
18
  export declare function markdownToToml(skillName: string, markdown: string): string;
19
+ /**
20
+ * Convert a Markdown command file to a Goose recipe YAML object.
21
+ *
22
+ * Goose has no native slash-command file format — a slash command is a recipe
23
+ * (registered in `config.yaml` under `slash_commands`). The recipe schema matches
24
+ * the one agents-cli already emits for Goose workflow/subagent recipes:
25
+ * `version`, `title`, `description`, `instructions`, `prompt`. The Markdown body
26
+ * (with `$ARGUMENTS` preserved) becomes both `instructions` and `prompt`.
27
+ * Returns a plain object so the caller can `yaml.stringify` it.
28
+ */
29
+ export declare function markdownToGooseRecipe(commandName: string, markdown: string): Record<string, unknown>;
19
30
  /** Convert a Gemini TOML command file back to Markdown format, translating {{args}} to $ARGUMENTS. */
20
31
  export declare function tomlToMarkdown(toml: string): string;
@@ -40,6 +40,28 @@ export function markdownToToml(skillName, markdown) {
40
40
  ];
41
41
  return lines.join('\n');
42
42
  }
43
+ /**
44
+ * Convert a Markdown command file to a Goose recipe YAML object.
45
+ *
46
+ * Goose has no native slash-command file format — a slash command is a recipe
47
+ * (registered in `config.yaml` under `slash_commands`). The recipe schema matches
48
+ * the one agents-cli already emits for Goose workflow/subagent recipes:
49
+ * `version`, `title`, `description`, `instructions`, `prompt`. The Markdown body
50
+ * (with `$ARGUMENTS` preserved) becomes both `instructions` and `prompt`.
51
+ * Returns a plain object so the caller can `yaml.stringify` it.
52
+ */
53
+ export function markdownToGooseRecipe(commandName, markdown) {
54
+ const { frontmatter, body } = parseMarkdownFrontmatter(markdown);
55
+ const description = frontmatter.description || `Run ${commandName} command`;
56
+ const prompt = body.trim() || description;
57
+ return {
58
+ version: '1.0.0',
59
+ title: commandName,
60
+ description,
61
+ instructions: prompt,
62
+ prompt,
63
+ };
64
+ }
43
65
  /** Convert a Gemini TOML command file back to Markdown format, translating {{args}} to $ARGUMENTS. */
44
66
  export function tomlToMarkdown(toml) {
45
67
  const nameMatch = toml.match(/name\s*=\s*"([^"]+)"/);
@@ -419,6 +419,8 @@ export async function runDaemon() {
419
419
  log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
420
420
  (r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
421
421
  }
422
+ if (r.warnings.length)
423
+ log('WARN', `sessions sync: ${r.warnings[0]}`);
422
424
  }
423
425
  catch (err) {
424
426
  log('ERROR', `sessions sync failed: ${err.message}`);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Fleet-wide device operations — pick online targets and run a command on each.
3
+ *
4
+ * Used by `agents fleet update` / `agents fleet run` (aliases of the same
5
+ * subcommands under `agents devices`). Offline devices are skipped with a
6
+ * reason so a single dead node never blocks the rest of the rollout. Per-device
7
+ * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
+ * the remaining devices.
9
+ */
10
+ import type { DeviceProfile, DeviceRegistry } from './registry.js';
11
+ export type FleetSkipReason = 'offline' | 'no-address';
12
+ /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
+ export declare const FLEET_VERSION_RE: RegExp;
14
+ export interface FleetTarget {
15
+ device: DeviceProfile;
16
+ /** When set, this device is not reached (skip with reason). */
17
+ skip?: FleetSkipReason;
18
+ }
19
+ export interface FleetRunResult {
20
+ name: string;
21
+ status: 'ok' | 'failed' | 'skipped';
22
+ code: number | null;
23
+ reason?: FleetSkipReason | string;
24
+ /** Truncated combined stderr/stdout for failures. */
25
+ detail?: string;
26
+ }
27
+ /**
28
+ * Classify each registered device for a fleet operation.
29
+ *
30
+ * - Tailscale-offline → skip `offline`
31
+ * - No address → skip `no-address`
32
+ * - Everything else is a target (including this machine, reached over ssh when
33
+ * it has a registry address — same path as any other box).
34
+ */
35
+ export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
36
+ /** Human label for a skip reason. */
37
+ export declare function skipLabel(reason: FleetSkipReason): string;
38
+ /**
39
+ * Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
40
+ * Captures stdout/stderr (not inherited) so the fleet table can summarize.
41
+ * Throws from buildSshInvocation are returned as a non-zero result so a single
42
+ * misconfigured device cannot abort the fleet loop.
43
+ */
44
+ export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?: {
45
+ timeoutMs?: number;
46
+ }): {
47
+ code: number | null;
48
+ stdout: string;
49
+ stderr: string;
50
+ };
51
+ /**
52
+ * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
53
+ * Rejects anything that is not a plain npm version/tag token so a version pin
54
+ * cannot inject shell metacharacters into the remote command line.
55
+ */
56
+ export declare function upgradeCommand(version?: string): string[];
57
+ /**
58
+ * Execute a command across planned targets. Pure orchestration over
59
+ * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
60
+ * the runner are recorded as `failed` so one bad device never aborts the rest.
61
+ */
62
+ export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Fleet-wide device operations — pick online targets and run a command on each.
3
+ *
4
+ * Used by `agents fleet update` / `agents fleet run` (aliases of the same
5
+ * subcommands under `agents devices`). Offline devices are skipped with a
6
+ * reason so a single dead node never blocks the rest of the rollout. Per-device
7
+ * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
+ * the remaining devices.
9
+ */
10
+ import { spawnSync } from 'child_process';
11
+ import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
12
+ /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
+ export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
14
+ /**
15
+ * Classify each registered device for a fleet operation.
16
+ *
17
+ * - Tailscale-offline → skip `offline`
18
+ * - No address → skip `no-address`
19
+ * - Everything else is a target (including this machine, reached over ssh when
20
+ * it has a registry address — same path as any other box).
21
+ */
22
+ export function planFleetTargets(reg) {
23
+ const names = Object.keys(reg).sort();
24
+ return names.map((name) => {
25
+ const device = reg[name];
26
+ if (device.tailscale && !device.tailscale.online) {
27
+ return { device, skip: 'offline' };
28
+ }
29
+ try {
30
+ sshTargetFor(device);
31
+ }
32
+ catch {
33
+ return { device, skip: 'no-address' };
34
+ }
35
+ return { device };
36
+ });
37
+ }
38
+ /** Human label for a skip reason. */
39
+ export function skipLabel(reason) {
40
+ switch (reason) {
41
+ case 'offline':
42
+ return 'offline';
43
+ case 'no-address':
44
+ return 'no address';
45
+ }
46
+ }
47
+ /**
48
+ * Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
49
+ * Captures stdout/stderr (not inherited) so the fleet table can summarize.
50
+ * Throws from buildSshInvocation are returned as a non-zero result so a single
51
+ * misconfigured device cannot abort the fleet loop.
52
+ */
53
+ export function runOnDevice(device, cmd, opts = {}) {
54
+ try {
55
+ const shim = writeAskpassShim();
56
+ const { args, env } = buildSshInvocation(device, cmd, shim);
57
+ const res = spawnSync('ssh', args, {
58
+ encoding: 'utf-8',
59
+ env: { ...process.env, ...env },
60
+ timeout: opts.timeoutMs ?? 600_000,
61
+ });
62
+ return {
63
+ code: res.status,
64
+ stdout: res.stdout?.toString() ?? '',
65
+ stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
66
+ };
67
+ }
68
+ catch (err) {
69
+ return {
70
+ code: 1,
71
+ stdout: '',
72
+ stderr: err instanceof Error ? err.message : String(err),
73
+ };
74
+ }
75
+ }
76
+ /**
77
+ * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
78
+ * Rejects anything that is not a plain npm version/tag token so a version pin
79
+ * cannot inject shell metacharacters into the remote command line.
80
+ */
81
+ export function upgradeCommand(version) {
82
+ if (version !== undefined && version !== '') {
83
+ if (!FLEET_VERSION_RE.test(version)) {
84
+ throw new Error(`Invalid version '${version}'. Use a semver or dist-tag (letters, digits, . _ - only).`);
85
+ }
86
+ return ['agents', 'upgrade', version, '--yes'];
87
+ }
88
+ return ['agents', 'upgrade', '--yes'];
89
+ }
90
+ /**
91
+ * Execute a command across planned targets. Pure orchestration over
92
+ * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
93
+ * the runner are recorded as `failed` so one bad device never aborts the rest.
94
+ */
95
+ export function runFleet(targets, cmd, runner = runOnDevice) {
96
+ const results = [];
97
+ for (const t of targets) {
98
+ if (t.skip) {
99
+ results.push({
100
+ name: t.device.name,
101
+ status: 'skipped',
102
+ code: null,
103
+ reason: t.skip,
104
+ });
105
+ continue;
106
+ }
107
+ try {
108
+ const res = runner(t.device, cmd);
109
+ const ok = res.code === 0;
110
+ const detail = (res.stderr || res.stdout).trim().slice(0, 200);
111
+ results.push({
112
+ name: t.device.name,
113
+ status: ok ? 'ok' : 'failed',
114
+ code: res.code,
115
+ detail: ok ? undefined : detail || undefined,
116
+ });
117
+ }
118
+ catch (err) {
119
+ results.push({
120
+ name: t.device.name,
121
+ status: 'failed',
122
+ code: 1,
123
+ detail: (err instanceof Error ? err.message : String(err)).slice(0, 200),
124
+ });
125
+ }
126
+ }
127
+ return results;
128
+ }
@@ -30,6 +30,7 @@ import { markdownToToml } from './convert.js';
30
30
  import { resolveImports, supportsRulesImports } from './rules/compile.js';
31
31
  import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
32
32
  import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
33
+ import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
33
34
  import { supports } from './capabilities.js';
34
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
35
36
  import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
@@ -141,6 +142,19 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
141
142
  });
142
143
  continue;
143
144
  }
145
+ if (agent === 'goose') {
146
+ // Compare against the installed Goose recipe YAML + slash_commands registration.
147
+ const matches = gooseCommandMatches(getVersionHomePath(agent, version), name, src.path);
148
+ rows.push({
149
+ kind: 'commands',
150
+ name,
151
+ status: matches ? 'ok' : 'diff',
152
+ source: src.layer,
153
+ sourcePath: src.path,
154
+ homePath: path.join(gooseCommandsDir(getVersionHomePath(agent, version)), `${name}.yaml`),
155
+ });
156
+ continue;
157
+ }
144
158
  const homePath = path.join(homeDir, `${name}${ext}`);
145
159
  const installedContent = readSafe(homePath);
146
160
  const sourceContent = readSafe(src.path);
@@ -164,7 +178,9 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
164
178
  continue;
165
179
  const extraHome = asSkill
166
180
  ? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
167
- : path.join(homeDir, `${name}${ext}`);
181
+ : agent === 'goose'
182
+ ? path.join(gooseCommandsDir(getVersionHomePath(agent, version)), `${name}.yaml`)
183
+ : path.join(homeDir, `${name}${ext}`);
168
184
  rows.push({ kind: 'commands', name, status: 'extra', homePath: extraHome });
169
185
  }
170
186
  return rows.sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,5 @@
1
+ export declare const FUNNEL_PORTS: readonly [443, 8443, 10000];
2
+ export type FunnelPort = typeof FUNNEL_PORTS[number];
3
+ export declare function parseFunnelPort(value: string | number): FunnelPort;
4
+ export declare function buildFunnelStatusCommand(): string;
5
+ export declare function buildFunnelUpCommand(publicPort: FunnelPort, localPort: number): string;
@@ -0,0 +1,23 @@
1
+ import { shellQuote } from './ssh-exec.js';
2
+ export const FUNNEL_PORTS = [443, 8443, 10000];
3
+ export function parseFunnelPort(value) {
4
+ const port = typeof value === 'number' ? value : Number.parseInt(value, 10);
5
+ if (FUNNEL_PORTS.includes(port))
6
+ return port;
7
+ throw new Error(`Tailscale Funnel public port must be one of: ${FUNNEL_PORTS.join(', ')}`);
8
+ }
9
+ export function buildFunnelStatusCommand() {
10
+ return 'tailscale funnel status';
11
+ }
12
+ export function buildFunnelUpCommand(publicPort, localPort) {
13
+ if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
14
+ throw new Error('Local port must be between 1 and 65535');
15
+ }
16
+ return [
17
+ 'tailscale',
18
+ 'funnel',
19
+ '--bg',
20
+ `--https=${publicPort}`,
21
+ `http://localhost:${localPort}`,
22
+ ].map(shellQuote).join(' ');
23
+ }
package/dist/lib/git.d.ts CHANGED
@@ -76,13 +76,24 @@ export declare function setRemoteUrl(repoPath: string, url: string): Promise<voi
76
76
  * Check if a GitHub repo exists.
77
77
  */
78
78
  export declare function checkGitHubRepoExists(owner: string, repo: string): Promise<boolean>;
79
- /**
80
- * Commit and push changes in a repo.
81
- */
82
- export declare function commitAndPush(repoPath: string, message: string): Promise<{
79
+ /** Result of {@link commitAndPush}. */
80
+ export type CommitAndPushResult = {
83
81
  success: boolean;
84
82
  error?: string;
85
- }>;
83
+ /** Human detail for success: "already up to date", "pushed abc..def", "committed and pushed …". */
84
+ detail?: string;
85
+ branch?: string;
86
+ committed?: boolean;
87
+ pushed?: boolean;
88
+ };
89
+ /**
90
+ * Commit (if dirty) and push a repo.
91
+ *
92
+ * Clean tree + local ahead of origin still pushes — "nothing to commit" is not
93
+ * "nothing to push". Reports "already up to date" only when `ahead === 0` and
94
+ * there is nothing to commit.
95
+ */
96
+ export declare function commitAndPush(repoPath: string, message: string): Promise<CommitAndPushResult>;
86
97
  /**
87
98
  * Check if repo has uncommitted changes.
88
99
  */
@@ -166,11 +177,16 @@ export declare function displayHomePath(dir: string): string;
166
177
  /**
167
178
  * Pull changes in an existing repo.
168
179
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
180
+ *
181
+ * Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
182
+ * branch reconciles instead of failing with "Need to specify how to reconcile
183
+ * divergent branches".
169
184
  */
170
185
  export declare function pullRepo(dir: string): Promise<{
171
186
  success: boolean;
172
187
  commit: string;
173
188
  error?: string;
189
+ branch?: string;
174
190
  }>;
175
191
  /**
176
192
  * Rebase a repo onto its remote, optionally pushing local commits back up.