@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.
- package/CHANGELOG.md +57 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +51 -1
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +100 -28
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- 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
|
+
}
|
package/dist/lib/commands.js
CHANGED
|
@@ -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 })) {
|
package/dist/lib/convert.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/convert.js
CHANGED
|
@@ -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*"([^"]+)"/);
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
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.
|