agentproc 0.2.1 → 0.3.0
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/package.json +1 -1
- package/src/cli.js +216 -88
- package/src/hub.js +310 -0
- package/src/hub.test.js +327 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -41,6 +41,7 @@ const fs = require('node:fs');
|
|
|
41
41
|
const path = require('node:path');
|
|
42
42
|
|
|
43
43
|
const runner = require('./runner.js');
|
|
44
|
+
const hub = require('./hub.js');
|
|
44
45
|
const { PROTOCOL_VERSION } = runner;
|
|
45
46
|
|
|
46
47
|
const PKG_VERSION = require('../package.json').version;
|
|
@@ -57,7 +58,7 @@ function parseArgs(argv) {
|
|
|
57
58
|
sessionName: 'default',
|
|
58
59
|
from: '',
|
|
59
60
|
cwd: null,
|
|
60
|
-
env:
|
|
61
|
+
env: [], // array of "KEY=VALUE" strings; --env can repeat
|
|
61
62
|
timeout: null,
|
|
62
63
|
stream: true,
|
|
63
64
|
verbose: true,
|
|
@@ -83,20 +84,21 @@ function parseArgs(argv) {
|
|
|
83
84
|
case '--session-name': opts.sessionName = next(); break;
|
|
84
85
|
case '--from': opts.from = next(); break;
|
|
85
86
|
case '--cwd': opts.cwd = next(); break;
|
|
86
|
-
case '--env':
|
|
87
|
-
|
|
88
|
-
const eq = kv.indexOf('=');
|
|
89
|
-
if (eq < 0) throw new Error(`--env expects KEY=VALUE, got: ${kv}`);
|
|
90
|
-
opts.env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
87
|
+
case '--env':
|
|
88
|
+
opts.env.push(next());
|
|
91
89
|
break;
|
|
92
|
-
}
|
|
93
90
|
case '--timeout': opts.timeout = parseInt(next(), 10); break;
|
|
94
|
-
case '--no-stream': opts.
|
|
91
|
+
case '--no-stream': opts.noStream = true; break;
|
|
95
92
|
case '--verbose': opts.verbose = true; break;
|
|
96
93
|
case '--quiet': opts.verbose = false; break;
|
|
97
94
|
case '--raw': opts.raw = true; break;
|
|
98
95
|
case '--stdin': opts.stdin = true; break;
|
|
99
96
|
default:
|
|
97
|
+
if (a === 'hub' && extras.length === 0) {
|
|
98
|
+
opts.hub = true;
|
|
99
|
+
opts.hubArgs = argv.slice(i + 1);
|
|
100
|
+
return { opts, extras };
|
|
101
|
+
}
|
|
100
102
|
if (a.startsWith('--')) throw new Error(`unknown option: ${a}`);
|
|
101
103
|
extras.push(a);
|
|
102
104
|
}
|
|
@@ -104,6 +106,204 @@ function parseArgs(argv) {
|
|
|
104
106
|
return { opts, extras };
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Hub subcommand dispatcher
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async function runHubSubcommand(args) {
|
|
114
|
+
const sub = args[0];
|
|
115
|
+
const rest = args.slice(1);
|
|
116
|
+
|
|
117
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
118
|
+
showHubHelp();
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Parse common flags
|
|
123
|
+
const refresh = rest.includes('--refresh');
|
|
124
|
+
const positional = rest.filter(a => !a.startsWith('--'));
|
|
125
|
+
|
|
126
|
+
if (sub === 'list') {
|
|
127
|
+
const profiles = await hub.listProfiles({
|
|
128
|
+
onLog: m => process.stderr.write(m + '\n'),
|
|
129
|
+
});
|
|
130
|
+
process.stdout.write('Available profiles in the official hub:\n\n');
|
|
131
|
+
for (const p of profiles) {
|
|
132
|
+
process.stdout.write(
|
|
133
|
+
` ${p.name.padEnd(15)} ${p.tested.padEnd(12)} ${p.description.slice(0, 60)}\n`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(`\nRun \`agentproc hub run <name> -p "hi"\` to use one.\n`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (sub === 'show') {
|
|
141
|
+
if (!positional[0]) {
|
|
142
|
+
process.stderr.write('error: hub show requires a profile name\n');
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
const readme = await hub.showReadme(positional[0], {
|
|
146
|
+
refresh,
|
|
147
|
+
onLog: m => process.stderr.write(m + '\n'),
|
|
148
|
+
});
|
|
149
|
+
process.stdout.write(readme);
|
|
150
|
+
if (!readme.endsWith('\n')) process.stdout.write('\n');
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (sub === 'install') {
|
|
155
|
+
if (!positional[0]) {
|
|
156
|
+
process.stderr.write('error: hub install requires a profile name\n');
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
const target = process.cwd();
|
|
160
|
+
await hub.installProfile(positional[0], target, {
|
|
161
|
+
refresh,
|
|
162
|
+
onLog: m => process.stderr.write(m + '\n'),
|
|
163
|
+
});
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (sub === 'run') {
|
|
168
|
+
if (!positional[0]) {
|
|
169
|
+
process.stderr.write('error: hub run requires a profile name\n');
|
|
170
|
+
return 2;
|
|
171
|
+
}
|
|
172
|
+
const profileName = positional[0];
|
|
173
|
+
const cacheDir = await hub.fetchProfile(profileName, {
|
|
174
|
+
refresh,
|
|
175
|
+
onLog: m => process.stderr.write(m + '\n'),
|
|
176
|
+
});
|
|
177
|
+
const profilePath = path.join(cacheDir, 'profile.yaml');
|
|
178
|
+
|
|
179
|
+
// Re-parse the remaining args as the runner options (--prompt, --cwd, etc.).
|
|
180
|
+
const { opts: runOpts } = parseArgs(rest);
|
|
181
|
+
if (!runOpts.prompt && !runOpts.stdin) {
|
|
182
|
+
process.stderr.write('error: hub run requires --prompt <text> or --stdin\n');
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return await runAgent(profilePath, runOpts);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
process.stderr.write(`error: unknown hub subcommand: ${sub}\n\n`);
|
|
190
|
+
showHubHelp();
|
|
191
|
+
return 2;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function showHubHelp() {
|
|
195
|
+
process.stdout.write(`agentproc hub — manage profiles from the official Hub
|
|
196
|
+
|
|
197
|
+
Usage:
|
|
198
|
+
agentproc hub list List all profiles in the hub
|
|
199
|
+
agentproc hub show <name> Show a profile's README
|
|
200
|
+
agentproc hub install <name> Copy a profile to the current directory
|
|
201
|
+
agentproc hub run <name> [run-options] Fetch (if needed) and run a profile
|
|
202
|
+
|
|
203
|
+
Hub run options (same as the regular --profile runner):
|
|
204
|
+
-p, --prompt <text> User message (or use --stdin)
|
|
205
|
+
--cwd <path> Override profile.cwd (default: current dir)
|
|
206
|
+
--env KEY=VALUE Extra env var (repeatable)
|
|
207
|
+
--session <id> Previous session id for multi-turn
|
|
208
|
+
--timeout <secs> Override profile.timeout_secs
|
|
209
|
+
--no-stream Disable streaming
|
|
210
|
+
--verbose / --quiet Protocol line visibility (default: verbose)
|
|
211
|
+
--stdin Read prompt from stdin
|
|
212
|
+
|
|
213
|
+
Common options:
|
|
214
|
+
--refresh Force re-fetch from GitHub (ignore cache)
|
|
215
|
+
-h, --help Show this help
|
|
216
|
+
|
|
217
|
+
Examples:
|
|
218
|
+
agentproc hub list
|
|
219
|
+
agentproc hub run echo-agent -p "hello"
|
|
220
|
+
cd ~/projects/my-app && agentproc hub run claude-code -p "explain this" --env ANTHROPIC_API_KEY=$KEY
|
|
221
|
+
agentproc hub show codex
|
|
222
|
+
agentproc hub install agy
|
|
223
|
+
|
|
224
|
+
Profiles are cached at ~/.agentproc/cache/hub/<name>/ (24h TTL).
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Shared runner logic used by both `agentproc --profile` and `agentproc hub run`.
|
|
230
|
+
* Kept here for the hub subcommand to reuse; the legacy main() path also calls it.
|
|
231
|
+
*/
|
|
232
|
+
async function runAgent(profilePath, opts) {
|
|
233
|
+
let profileRaw;
|
|
234
|
+
try {
|
|
235
|
+
const yamlText = fs.readFileSync(path.resolve(profilePath), 'utf8');
|
|
236
|
+
profileRaw = parseYaml(yamlText);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
process.stderr.write(`error: failed to read profile ${profilePath}: ${e.message}\n`);
|
|
239
|
+
return 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Read prompt.
|
|
243
|
+
let prompt = opts.prompt;
|
|
244
|
+
if (opts.stdin) {
|
|
245
|
+
prompt = fs.readFileSync(0, 'utf8').replace(/\n$/, '');
|
|
246
|
+
}
|
|
247
|
+
if (prompt == null) {
|
|
248
|
+
process.stderr.write('error: --prompt (or --stdin) is required\n');
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// opts.env is an array of "KEY=VALUE" strings (from repeated --env flags)
|
|
253
|
+
const extraEnv = {};
|
|
254
|
+
for (const kv of opts.env || []) {
|
|
255
|
+
const eq = kv.indexOf('=');
|
|
256
|
+
if (eq < 0) {
|
|
257
|
+
process.stderr.write(`error: --env expects KEY=VALUE, got: ${kv}\n`);
|
|
258
|
+
return 2;
|
|
259
|
+
}
|
|
260
|
+
extraEnv[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const streaming = opts.noStream ? false : null;
|
|
264
|
+
|
|
265
|
+
if (opts.raw) {
|
|
266
|
+
const r = await runner.run(profileRaw, {
|
|
267
|
+
message: prompt,
|
|
268
|
+
sessionId: opts.session || '',
|
|
269
|
+
sessionName: opts.sessionName || 'default',
|
|
270
|
+
fromUser: opts.from || '',
|
|
271
|
+
streaming,
|
|
272
|
+
cwd: opts.cwd,
|
|
273
|
+
extraEnv,
|
|
274
|
+
timeoutSecs: opts.timeout,
|
|
275
|
+
});
|
|
276
|
+
process.stdout.write(r.reply);
|
|
277
|
+
if (r.reply && !r.reply.endsWith('\n')) process.stdout.write('\n');
|
|
278
|
+
return r.exitCode === 0 ? 0 : 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const verbose = opts.verbose || !opts.quiet || (opts.verbose === undefined && opts.quiet === undefined) || opts.verbose;
|
|
282
|
+
|
|
283
|
+
const r = await runner.run(profileRaw, {
|
|
284
|
+
message: prompt,
|
|
285
|
+
sessionId: opts.session || '',
|
|
286
|
+
sessionName: opts.sessionName || 'default',
|
|
287
|
+
fromUser: opts.from || '',
|
|
288
|
+
streaming,
|
|
289
|
+
cwd: opts.cwd,
|
|
290
|
+
extraEnv,
|
|
291
|
+
timeoutSecs: opts.timeout,
|
|
292
|
+
onPartial: (t) => { if (verbose) process.stderr.write(`AGENT_PARTIAL:${JSON.stringify(t)}\n`); },
|
|
293
|
+
onSession: (id) => { if (verbose) process.stderr.write(`AGENT_SESSION:${id}\n`); },
|
|
294
|
+
onError: (msg) => { if (verbose) process.stderr.write(`AGENT_ERROR:${JSON.stringify(msg)}\n`); },
|
|
295
|
+
onStderr: (line) => { if (verbose) process.stderr.write(`[agent stderr] ${line}\n`); },
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (r.reply) {
|
|
299
|
+
process.stdout.write(r.reply);
|
|
300
|
+
if (!r.reply.endsWith('\n')) process.stdout.write('\n');
|
|
301
|
+
}
|
|
302
|
+
if (r.sessionId) process.stderr.write(`agentproc:session:${r.sessionId}\n`);
|
|
303
|
+
if (r.error) process.stderr.write(`agentproc:error:${r.error}\n`);
|
|
304
|
+
return r.exitCode === 0 ? 0 : 1;
|
|
305
|
+
}
|
|
306
|
+
|
|
107
307
|
function showHelp() {
|
|
108
308
|
process.stdout.write(`agentproc v${PKG_VERSION} (protocol ${PROTOCOL_VERSION})
|
|
109
309
|
|
|
@@ -392,6 +592,11 @@ async function main() {
|
|
|
392
592
|
if (opts.help) { showHelp(); process.exit(0); }
|
|
393
593
|
if (opts.version) { showVersion(); process.exit(0); }
|
|
394
594
|
|
|
595
|
+
// `agentproc hub <subcommand>` — defer to hub dispatcher.
|
|
596
|
+
if (opts.hub) {
|
|
597
|
+
return await runHubSubcommand(opts.hubArgs);
|
|
598
|
+
}
|
|
599
|
+
|
|
395
600
|
if (!opts.profile) {
|
|
396
601
|
process.stderr.write('error: --profile is required\n\n');
|
|
397
602
|
showHelp();
|
|
@@ -408,89 +613,12 @@ async function main() {
|
|
|
408
613
|
process.exit(2);
|
|
409
614
|
}
|
|
410
615
|
|
|
411
|
-
// Read & parse profile YAML.
|
|
412
|
-
let profileRaw;
|
|
413
|
-
try {
|
|
414
|
-
const yamlText = fs.readFileSync(path.resolve(opts.profile), 'utf8');
|
|
415
|
-
profileRaw = parseYaml(yamlText);
|
|
416
|
-
} catch (e) {
|
|
417
|
-
process.stderr.write(`error: failed to read profile ${opts.profile}: ${e.message}\n`);
|
|
418
|
-
process.exit(2);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// ---- Raw mode: spawn agent, pipe stdout through, exit with agent code ----
|
|
422
|
-
if (opts.raw) {
|
|
423
|
-
const { spawn } = require('node:child_process');
|
|
424
|
-
try {
|
|
425
|
-
const r = await runner.run(profileRaw, {
|
|
426
|
-
message: prompt,
|
|
427
|
-
sessionId: opts.session,
|
|
428
|
-
sessionName: opts.sessionName,
|
|
429
|
-
fromUser: opts.from,
|
|
430
|
-
streaming: opts.stream,
|
|
431
|
-
cwd: opts.cwd,
|
|
432
|
-
extraEnv: opts.env,
|
|
433
|
-
timeoutSecs: opts.timeout,
|
|
434
|
-
// No callbacks — we replace stdout forwarding below.
|
|
435
|
-
});
|
|
436
|
-
// The runner buffers reply; for raw mode we want streaming verbatim,
|
|
437
|
-
// so we re-implement with raw pipes. Simpler: just print the reply
|
|
438
|
-
// (which equals the agent's stdout minus protocol lines).
|
|
439
|
-
// For TRUE raw output (including protocol lines), users should use
|
|
440
|
-
// the bridge script directly.
|
|
441
|
-
process.stdout.write(r.reply);
|
|
442
|
-
if (r.reply && !r.reply.endsWith('\n')) process.stdout.write('\n');
|
|
443
|
-
process.exit(r.exitCode === 0 ? 0 : 1);
|
|
444
|
-
} catch (e) {
|
|
445
|
-
process.stderr.write(`error: ${e.message}\n`);
|
|
446
|
-
process.exit(1);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// ---- Default mode: classify and pretty-print ----
|
|
616
|
+
// Read & parse profile YAML, then delegate to the shared runner path.
|
|
451
617
|
try {
|
|
452
|
-
|
|
453
|
-
message: prompt,
|
|
454
|
-
sessionId: opts.session,
|
|
455
|
-
sessionName: opts.sessionName,
|
|
456
|
-
fromUser: opts.from,
|
|
457
|
-
streaming: opts.stream,
|
|
458
|
-
cwd: opts.cwd,
|
|
459
|
-
extraEnv: opts.env,
|
|
460
|
-
timeoutSecs: opts.timeout,
|
|
461
|
-
onPartial: (text) => {
|
|
462
|
-
if (opts.verbose) process.stderr.write(`AGENT_PARTIAL:${JSON.stringify(text)}\n`);
|
|
463
|
-
},
|
|
464
|
-
onSession: (id) => {
|
|
465
|
-
if (opts.verbose) process.stderr.write(`AGENT_SESSION:${id}\n`);
|
|
466
|
-
},
|
|
467
|
-
onError: (msg) => {
|
|
468
|
-
if (opts.verbose) process.stderr.write(`AGENT_ERROR:${JSON.stringify(msg)}\n`);
|
|
469
|
-
},
|
|
470
|
-
onStderr: (line) => {
|
|
471
|
-
if (opts.verbose) process.stderr.write(`[agent stderr] ${line}\n`);
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
// Print final reply body to stdout.
|
|
476
|
-
if (r.reply) {
|
|
477
|
-
process.stdout.write(r.reply);
|
|
478
|
-
if (!r.reply.endsWith('\n')) process.stdout.write('\n');
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Final session id on stderr (for shell capture).
|
|
482
|
-
if (r.sessionId) {
|
|
483
|
-
process.stderr.write(`agentproc:session:${r.sessionId}\n`);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (r.error) {
|
|
487
|
-
process.stderr.write(`agentproc:error:${r.error}\n`);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
process.exit(r.exitCode === 0 ? 0 : 1);
|
|
618
|
+
return await runAgent(opts.profile, opts);
|
|
491
619
|
} catch (e) {
|
|
492
620
|
process.stderr.write(`error: ${e.message}\n`);
|
|
493
|
-
|
|
621
|
+
return 1;
|
|
494
622
|
}
|
|
495
623
|
}
|
|
496
624
|
|
package/src/hub.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Hub client — fetch and manage profile directories from the official Hub.
|
|
4
|
+
*
|
|
5
|
+
* The Hub lives at https://github.com/jeffkit/agentproc/tree/main/hub/
|
|
6
|
+
* Profiles are cached locally at ~/.agentproc/cache/hub/<name>/ with a
|
|
7
|
+
* 24-hour TTL. Pass refresh=true to force re-fetch.
|
|
8
|
+
*
|
|
9
|
+
* Public API:
|
|
10
|
+
* HUB_REPO — 'jeffkit/agentproc'
|
|
11
|
+
* HUB_REF — 'main'
|
|
12
|
+
* HUB_CACHE_TTL_SECS — 24 hours
|
|
13
|
+
* cacheDir(name) — Path to the local cache directory for a profile
|
|
14
|
+
* fetchProfile(name, opts) -> Promise<string>
|
|
15
|
+
* listProfiles(opts) -> Promise<Array<{name, description, cli, tested}>>
|
|
16
|
+
* showReadme(name, opts) -> Promise<string>
|
|
17
|
+
* installProfile(name, targetDir, opts) -> Promise<string>
|
|
18
|
+
*
|
|
19
|
+
* All network access is via global fetch() (Node 18+). Zero dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
|
|
26
|
+
const HUB_REPO = 'jeffkit/agentproc';
|
|
27
|
+
const HUB_REF = 'main';
|
|
28
|
+
const HUB_CACHE_TTL_SECS = 24 * 60 * 60; // 24 hours
|
|
29
|
+
|
|
30
|
+
const GITHUB_API = (subpath) =>
|
|
31
|
+
`https://api.github.com/repos/${HUB_REPO}/contents/${subpath}?ref=${HUB_REF}`;
|
|
32
|
+
const GITHUB_TREES = `https://api.github.com/repos/${HUB_REPO}/git/trees/${HUB_REF}?recursive=1`;
|
|
33
|
+
const GITHUB_RAW = (p) =>
|
|
34
|
+
`https://raw.githubusercontent.com/${HUB_REPO}/${HUB_REF}/${p}`;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Cache helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function cacheRoot() {
|
|
41
|
+
// Prefer process.env.HOME (overridable in tests, set by sudo -E etc.),
|
|
42
|
+
// fall back to os.homedir() (cached at first call on some platforms).
|
|
43
|
+
const home = process.env.HOME || os.homedir();
|
|
44
|
+
return path.join(home, '.agentproc', 'cache', 'hub');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cacheDir(name) {
|
|
48
|
+
return path.join(cacheRoot(), name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cacheAgeSecs(name) {
|
|
52
|
+
const marker = path.join(cacheDir(name), '.cache-meta.json');
|
|
53
|
+
if (!fs.existsSync(marker)) return null;
|
|
54
|
+
try {
|
|
55
|
+
const meta = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
56
|
+
const ts = meta.fetched_at || 0;
|
|
57
|
+
return Math.max(0, Date.now() / 1000 - ts);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeCacheMeta(name) {
|
|
64
|
+
const dir = cacheDir(name);
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path.join(dir, '.cache-meta.json'),
|
|
68
|
+
JSON.stringify({ fetched_at: Date.now() / 1000, ref: HUB_REF }),
|
|
69
|
+
'utf8'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// HTTP helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async function httpGetJson(url) {
|
|
78
|
+
const r = await fetch(url, {
|
|
79
|
+
headers: {
|
|
80
|
+
Accept: 'application/vnd.github+json',
|
|
81
|
+
'User-Agent': 'agentproc-cli',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
if (!r.ok) {
|
|
85
|
+
const text = await r.text().catch(() => '');
|
|
86
|
+
throw new Error(`GitHub API ${r.status}: ${text.slice(0, 200)}`);
|
|
87
|
+
}
|
|
88
|
+
return r.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function httpGetText(url) {
|
|
92
|
+
const r = await fetch(url, { headers: { 'User-Agent': 'agentproc-cli' } });
|
|
93
|
+
if (!r.ok) {
|
|
94
|
+
throw new Error(`fetch ${r.status}: ${url}`);
|
|
95
|
+
}
|
|
96
|
+
return r.text();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fetch the entire repo tree (1 API call, returns all paths under hub/).
|
|
101
|
+
* Cached in memory for the lifetime of the process.
|
|
102
|
+
* @returns {Promise<Array<{path: string, type: 'blob'|'tree'}>>}
|
|
103
|
+
*/
|
|
104
|
+
let _treeCache = null;
|
|
105
|
+
async function getTree() {
|
|
106
|
+
if (_treeCache) return _treeCache;
|
|
107
|
+
const data = await httpGetJson(GITHUB_TREES);
|
|
108
|
+
if (!data || !Array.isArray(data.tree)) {
|
|
109
|
+
throw new Error('unexpected tree API response');
|
|
110
|
+
}
|
|
111
|
+
_treeCache = data.tree
|
|
112
|
+
.filter((e) => e && typeof e === 'object')
|
|
113
|
+
.map((e) => ({
|
|
114
|
+
path: String(e.path || ''),
|
|
115
|
+
type: String(e.type || ''), // 'blob' or 'tree'
|
|
116
|
+
}));
|
|
117
|
+
return _treeCache;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* List top-level entries under a hub subpath (e.g. 'hub/' → all profile dirs).
|
|
122
|
+
* @param {string} subpath e.g. 'hub' or 'hub/claude-code'
|
|
123
|
+
* @returns {Promise<Array<{name: string, type: 'file'|'dir'}>>}
|
|
124
|
+
*/
|
|
125
|
+
async function listRemoteFiles(subpath) {
|
|
126
|
+
if (!subpath.endsWith('/')) subpath = subpath + '/';
|
|
127
|
+
const tree = await getTree();
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const e of tree) {
|
|
131
|
+
if (!e.path.startsWith(subpath)) continue;
|
|
132
|
+
const name = e.path.slice(subpath.length).split('/')[0];
|
|
133
|
+
if (!name || seen.has(name)) continue;
|
|
134
|
+
seen.add(name);
|
|
135
|
+
// Determine type: is there a path equal to subpath+name with type 'tree'?
|
|
136
|
+
const isDir = tree.some((t) => t.path === subpath + name && t.type === 'tree');
|
|
137
|
+
out.push({
|
|
138
|
+
name,
|
|
139
|
+
type: isDir ? 'dir' : 'file',
|
|
140
|
+
path: e.path,
|
|
141
|
+
download_url: '',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* List actual files inside a hub/<name>/ directory.
|
|
149
|
+
* @param {string} name
|
|
150
|
+
* @returns {Promise<Array<{name: string, path: string}>>}
|
|
151
|
+
*/
|
|
152
|
+
async function listRemoteProfileFiles(name) {
|
|
153
|
+
const prefix = `hub/${name}/`;
|
|
154
|
+
const tree = await getTree();
|
|
155
|
+
return tree
|
|
156
|
+
.filter((e) => e.type === 'blob' && e.path.startsWith(prefix))
|
|
157
|
+
.map((e) => ({
|
|
158
|
+
name: e.path.slice(prefix.length).split('/').pop(),
|
|
159
|
+
path: e.path,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function downloadFile(remotePath, localPath) {
|
|
164
|
+
const text = await httpGetText(GITHUB_RAW(remotePath));
|
|
165
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
166
|
+
fs.writeFileSync(localPath, text, 'utf8');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Public API
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fetch a profile directory to local cache. Returns the cache path.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} name
|
|
177
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
178
|
+
* @returns {Promise<string>} absolute cache path
|
|
179
|
+
*/
|
|
180
|
+
async function fetchProfile(name, opts = {}) {
|
|
181
|
+
const { refresh = false, onLog = null } = opts;
|
|
182
|
+
|
|
183
|
+
// On refresh, also clear the in-memory tree cache so we see new files
|
|
184
|
+
// (e.g. profiles added since the process started).
|
|
185
|
+
if (refresh) _treeCache = null;
|
|
186
|
+
|
|
187
|
+
const age = cacheAgeSecs(name);
|
|
188
|
+
const dir = cacheDir(name);
|
|
189
|
+
const profileYaml = path.join(dir, 'profile.yaml');
|
|
190
|
+
|
|
191
|
+
if (!refresh && age !== null && age < HUB_CACHE_TTL_SECS && fs.existsSync(profileYaml)) {
|
|
192
|
+
if (onLog) onLog(`using cached profile: ${dir} (age ${Math.floor(age)}s)`);
|
|
193
|
+
return dir;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (onLog) {
|
|
197
|
+
if (refresh) {
|
|
198
|
+
onLog(`refreshing profile '${name}' from ${HUB_REPO}:${HUB_REF}...`);
|
|
199
|
+
} else {
|
|
200
|
+
onLog(`fetching profile '${name}' from ${HUB_REPO}:${HUB_REF}...`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const entries = await listRemoteProfileFiles(name);
|
|
205
|
+
if (entries.length === 0) {
|
|
206
|
+
throw new Error(`profile '${name}' not found in hub`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Clear cache, then re-download every file in the profile directory.
|
|
210
|
+
if (fs.existsSync(dir)) {
|
|
211
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
214
|
+
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const local = path.join(dir, entry.name);
|
|
217
|
+
await downloadFile(entry.path, local);
|
|
218
|
+
if (onLog) onLog(` - ${entry.name}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
writeCacheMeta(name);
|
|
222
|
+
return dir;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* List profiles in the official hub.
|
|
227
|
+
*
|
|
228
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
229
|
+
* @returns {Promise<Array<{name: string, description: string, cli: string, tested: string}>>}
|
|
230
|
+
*/
|
|
231
|
+
async function listProfiles(opts = {}) {
|
|
232
|
+
const { onLog = null } = opts;
|
|
233
|
+
const entries = await listRemoteFiles('hub');
|
|
234
|
+
const profiles = [];
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (entry.type !== 'dir') continue;
|
|
237
|
+
const name = entry.name;
|
|
238
|
+
try {
|
|
239
|
+
const yamlText = await httpGetText(GITHUB_RAW(`hub/${name}/profile.yaml`));
|
|
240
|
+
const { parseYaml } = require('./cli.js');
|
|
241
|
+
const data = parseYaml(yamlText);
|
|
242
|
+
profiles.push({
|
|
243
|
+
name: String(data.name || name),
|
|
244
|
+
description: String(data.description || ''),
|
|
245
|
+
cli: String(data.cli || ''),
|
|
246
|
+
tested: String(data.tested || 'unverified'),
|
|
247
|
+
});
|
|
248
|
+
} catch (e) {
|
|
249
|
+
if (onLog) onLog(`warning: could not read metadata for ${name}: ${e.message}`);
|
|
250
|
+
profiles.push({
|
|
251
|
+
name,
|
|
252
|
+
description: '(failed to read metadata)',
|
|
253
|
+
cli: '',
|
|
254
|
+
tested: 'unverified',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return profiles;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Return the README.md content for a profile.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} name
|
|
265
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
266
|
+
* @returns {Promise<string>}
|
|
267
|
+
*/
|
|
268
|
+
async function showReadme(name, opts = {}) {
|
|
269
|
+
const dir = await fetchProfile(name, opts);
|
|
270
|
+
const readme = path.join(dir, 'README.md');
|
|
271
|
+
if (!fs.existsSync(readme)) {
|
|
272
|
+
return `(no README.md for profile '${name}')`;
|
|
273
|
+
}
|
|
274
|
+
return fs.readFileSync(readme, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Copy a cached profile into targetDir/<name>/.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} name
|
|
281
|
+
* @param {string} targetDir
|
|
282
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
283
|
+
* @returns {Promise<string>} destination path
|
|
284
|
+
*/
|
|
285
|
+
async function installProfile(name, targetDir, opts = {}) {
|
|
286
|
+
const cached = await fetchProfile(name, opts);
|
|
287
|
+
const dest = path.join(targetDir, name);
|
|
288
|
+
if (fs.existsSync(dest)) {
|
|
289
|
+
throw new Error(`target already exists: ${dest}`);
|
|
290
|
+
}
|
|
291
|
+
fs.cpSync(cached, dest, { recursive: true });
|
|
292
|
+
// Drop our cache meta file from the installed copy.
|
|
293
|
+
const meta = path.join(dest, '.cache-meta.json');
|
|
294
|
+
if (fs.existsSync(meta)) fs.unlinkSync(meta);
|
|
295
|
+
if (opts.onLog) opts.onLog(`installed to: ${dest}`);
|
|
296
|
+
return dest;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
HUB_REPO,
|
|
301
|
+
HUB_REF,
|
|
302
|
+
HUB_CACHE_TTL_SECS,
|
|
303
|
+
cacheRoot,
|
|
304
|
+
cacheDir,
|
|
305
|
+
cacheAgeSecs,
|
|
306
|
+
fetchProfile,
|
|
307
|
+
listProfiles,
|
|
308
|
+
showReadme,
|
|
309
|
+
installProfile,
|
|
310
|
+
};
|
package/src/hub.test.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for hub.js — mock-based, no real network access.
|
|
4
|
+
*
|
|
5
|
+
* Run with: `node --test src/hub.test.js`
|
|
6
|
+
*
|
|
7
|
+
* Strategy: redirect HOME to a tmp dir for cache isolation; intercept
|
|
8
|
+
* global.fetch with fixtures. Concurrency is disabled because HOME is
|
|
9
|
+
* process-global state.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { test, describe, before, after, beforeEach, afterEach } = require('node:test');
|
|
13
|
+
const assert = require('node:assert');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const hub = require('./hub.js');
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Test fixtures
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const FAKE_TREE = [
|
|
25
|
+
{ path: 'hub', type: 'tree' },
|
|
26
|
+
{ path: 'hub/echo-agent', type: 'tree' },
|
|
27
|
+
{ path: 'hub/echo-agent/profile.yaml', type: 'blob' },
|
|
28
|
+
{ path: 'hub/echo-agent/bridge.py', type: 'blob' },
|
|
29
|
+
{ path: 'hub/echo-agent/bridge.js', type: 'blob' },
|
|
30
|
+
{ path: 'hub/echo-agent/bridge.sh', type: 'blob' },
|
|
31
|
+
{ path: 'hub/echo-agent/README.md', type: 'blob' },
|
|
32
|
+
{ path: 'hub/claude-code', type: 'tree' },
|
|
33
|
+
{ path: 'hub/claude-code/profile.yaml', type: 'blob' },
|
|
34
|
+
{ path: 'hub/claude-code/bridge.py', type: 'blob' },
|
|
35
|
+
{ path: 'hub/claude-code/bridge.js', type: 'blob' },
|
|
36
|
+
{ path: 'hub/claude-code/README.md', type: 'blob' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const FAKE_FILE_CONTENTS = {
|
|
40
|
+
'hub/echo-agent/profile.yaml':
|
|
41
|
+
'name: echo-agent\n' +
|
|
42
|
+
'description: Minimal hello-world agent\n' +
|
|
43
|
+
'cli: none\n' +
|
|
44
|
+
'agentproc:\n' +
|
|
45
|
+
' command: python3 ./bridge.py\n' +
|
|
46
|
+
' cwd: .\n' +
|
|
47
|
+
'tested: official\n' +
|
|
48
|
+
'maintainer: jeffkit\n',
|
|
49
|
+
'hub/echo-agent/bridge.py': '#!/usr/bin/env python3\nprint("echo")\n',
|
|
50
|
+
'hub/echo-agent/bridge.js': "'use strict';\nconsole.log('echo');\n",
|
|
51
|
+
'hub/echo-agent/bridge.sh': '#!/usr/bin/env bash\necho echo\n',
|
|
52
|
+
'hub/echo-agent/README.md': '# echo-agent\n\nHello world.\n',
|
|
53
|
+
'hub/claude-code/profile.yaml':
|
|
54
|
+
'name: claude-code\n' +
|
|
55
|
+
'description: Claude Code wrapper\n' +
|
|
56
|
+
'cli: claude\n' +
|
|
57
|
+
'agentproc:\n' +
|
|
58
|
+
' command: python3 ./bridge.py\n' +
|
|
59
|
+
'tested: official\n' +
|
|
60
|
+
'maintainer: jeffkit\n',
|
|
61
|
+
'hub/claude-code/bridge.py': '#!/usr/bin/env python3\nprint("claude")\n',
|
|
62
|
+
'hub/claude-code/bridge.js': "'use strict';\nconsole.log('claude');\n",
|
|
63
|
+
'hub/claude-code/README.md': '# claude-code\n\nReal wrapper.\n',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let _origHome = null;
|
|
67
|
+
let _tmpHome = null;
|
|
68
|
+
|
|
69
|
+
function setupHome() {
|
|
70
|
+
_origHome = process.env.HOME;
|
|
71
|
+
_tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-hub-home-'));
|
|
72
|
+
process.env.HOME = _tmpHome;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function teardownHome() {
|
|
76
|
+
if (_origHome !== null) process.env.HOME = _origHome;
|
|
77
|
+
if (_tmpHome && fs.existsSync(_tmpHome)) {
|
|
78
|
+
fs.rmSync(_tmpHome, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
_tmpHome = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Install a fake global.fetch that serves our fixtures.
|
|
85
|
+
*/
|
|
86
|
+
function installFakeFetch(tree = FAKE_TREE, contents = FAKE_FILE_CONTENTS) {
|
|
87
|
+
const counter = { json: 0, text: 0 };
|
|
88
|
+
const orig = global.fetch;
|
|
89
|
+
global.fetch = async (url, opts) => {
|
|
90
|
+
const acceptJson = opts && opts.headers && opts.headers.Accept && opts.headers.Accept.includes('json');
|
|
91
|
+
if (acceptJson && url.includes('git/trees')) {
|
|
92
|
+
counter.json++;
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
json: async () => ({ tree }),
|
|
96
|
+
text: async () => JSON.stringify({ tree }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (acceptJson) {
|
|
100
|
+
throw new Error(`unexpected JSON URL: ${url}`);
|
|
101
|
+
}
|
|
102
|
+
for (const [p, content] of Object.entries(contents)) {
|
|
103
|
+
if (url.endsWith(p)) {
|
|
104
|
+
counter.text++;
|
|
105
|
+
return { ok: true, text: async () => content };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`unexpected text URL: ${url}`);
|
|
109
|
+
};
|
|
110
|
+
counter.restore = () => { global.fetch = orig; };
|
|
111
|
+
return counter;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// All tests live inside this suite so we can disable concurrency.
|
|
117
|
+
// (Mutating process.env.HOME is global state; parallel tests would clash.)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe('hub', { concurrency: false }, () => {
|
|
121
|
+
beforeEach(setupHome);
|
|
122
|
+
afterEach(teardownHome);
|
|
123
|
+
|
|
124
|
+
// ----- cacheAgeSecs -----
|
|
125
|
+
|
|
126
|
+
describe('cacheAgeSecs', () => {
|
|
127
|
+
test('null when not cached', () => {
|
|
128
|
+
assert.strictEqual(hub.cacheAgeSecs('never'), null);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('small age right after write', () => {
|
|
132
|
+
fs.mkdirSync(hub.cacheDir('fresh'), { recursive: true });
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(hub.cacheDir('fresh'), '.cache-meta.json'),
|
|
135
|
+
JSON.stringify({ fetched_at: Date.now() / 1000, ref: 'main' })
|
|
136
|
+
);
|
|
137
|
+
const age = hub.cacheAgeSecs('fresh');
|
|
138
|
+
assert.ok(age !== null);
|
|
139
|
+
assert.ok(age < 5);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('large age for stale cache', () => {
|
|
143
|
+
fs.mkdirSync(hub.cacheDir('stale'), { recursive: true });
|
|
144
|
+
fs.writeFileSync(
|
|
145
|
+
path.join(hub.cacheDir('stale'), '.cache-meta.json'),
|
|
146
|
+
JSON.stringify({ fetched_at: Date.now() / 1000 - 100000, ref: 'main' })
|
|
147
|
+
);
|
|
148
|
+
const age = hub.cacheAgeSecs('stale');
|
|
149
|
+
assert.ok(age !== null);
|
|
150
|
+
assert.ok(age >= 99999, `age=${age}`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('null for invalid meta', () => {
|
|
154
|
+
fs.mkdirSync(hub.cacheDir('bad'), { recursive: true });
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
path.join(hub.cacheDir('bad'), '.cache-meta.json'),
|
|
157
|
+
'not json at all'
|
|
158
|
+
);
|
|
159
|
+
assert.strictEqual(hub.cacheAgeSecs('bad'), null);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ----- fetchProfile -----
|
|
164
|
+
|
|
165
|
+
describe('fetchProfile', () => {
|
|
166
|
+
test('downloads all files', async () => {
|
|
167
|
+
const counter = installFakeFetch();
|
|
168
|
+
try {
|
|
169
|
+
const dir = await hub.fetchProfile('echo-agent');
|
|
170
|
+
assert.ok(fs.existsSync(dir));
|
|
171
|
+
const names = fs.readdirSync(dir).sort();
|
|
172
|
+
assert.ok(names.includes('profile.yaml'));
|
|
173
|
+
assert.ok(names.includes('bridge.py'));
|
|
174
|
+
assert.ok(names.includes('bridge.js'));
|
|
175
|
+
assert.ok(names.includes('README.md'));
|
|
176
|
+
assert.ok(names.includes('.cache-meta.json'));
|
|
177
|
+
} finally {
|
|
178
|
+
counter.restore();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('unknown profile raises', async () => {
|
|
183
|
+
const counter = installFakeFetch([{ path: 'hub', type: 'tree' }]);
|
|
184
|
+
try {
|
|
185
|
+
await assert.rejects(hub.fetchProfile('nope'), /not found in hub/);
|
|
186
|
+
} finally {
|
|
187
|
+
counter.restore();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('uses cache on second call (no new fetches)', async () => {
|
|
192
|
+
const counter = installFakeFetch();
|
|
193
|
+
try {
|
|
194
|
+
await hub.fetchProfile('echo-agent');
|
|
195
|
+
const afterFirst = { json: counter.json, text: counter.text };
|
|
196
|
+
await hub.fetchProfile('echo-agent');
|
|
197
|
+
assert.strictEqual(counter.json, afterFirst.json);
|
|
198
|
+
assert.strictEqual(counter.text, afterFirst.text);
|
|
199
|
+
} finally {
|
|
200
|
+
counter.restore();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('refresh forces refetch', async () => {
|
|
205
|
+
const counter = installFakeFetch();
|
|
206
|
+
try {
|
|
207
|
+
await hub.fetchProfile('echo-agent');
|
|
208
|
+
const first = counter.json;
|
|
209
|
+
await hub.fetchProfile('echo-agent', { refresh: true });
|
|
210
|
+
assert.ok(counter.json > first);
|
|
211
|
+
} finally {
|
|
212
|
+
counter.restore();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('overwrites tampered cache on refresh', async () => {
|
|
217
|
+
const counter = installFakeFetch();
|
|
218
|
+
try {
|
|
219
|
+
await hub.fetchProfile('echo-agent');
|
|
220
|
+
const f = path.join(hub.cacheDir('echo-agent'), 'bridge.py');
|
|
221
|
+
const original = fs.readFileSync(f, 'utf8');
|
|
222
|
+
fs.writeFileSync(f, '# tampered\n');
|
|
223
|
+
await hub.fetchProfile('echo-agent', { refresh: true });
|
|
224
|
+
assert.strictEqual(fs.readFileSync(f, 'utf8'), original);
|
|
225
|
+
} finally {
|
|
226
|
+
counter.restore();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ----- listProfiles -----
|
|
232
|
+
|
|
233
|
+
describe('listProfiles', () => {
|
|
234
|
+
test('returns all profile dirs with metadata', async () => {
|
|
235
|
+
const counter = installFakeFetch();
|
|
236
|
+
try {
|
|
237
|
+
const profiles = await hub.listProfiles({ refresh: true });
|
|
238
|
+
const names = profiles.map(p => p.name).sort();
|
|
239
|
+
assert.deepStrictEqual(names, ['claude-code', 'echo-agent']);
|
|
240
|
+
const ec = profiles.find(p => p.name === 'echo-agent');
|
|
241
|
+
assert.strictEqual(ec.tested, 'official');
|
|
242
|
+
assert.strictEqual(ec.description, 'Minimal hello-world agent');
|
|
243
|
+
assert.strictEqual(ec.cli, 'none');
|
|
244
|
+
} finally {
|
|
245
|
+
counter.restore();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ----- showReadme -----
|
|
251
|
+
|
|
252
|
+
describe('showReadme', () => {
|
|
253
|
+
test('returns README content', async () => {
|
|
254
|
+
const counter = installFakeFetch();
|
|
255
|
+
try {
|
|
256
|
+
const text = await hub.showReadme('echo-agent', { refresh: true });
|
|
257
|
+
assert.ok(text.includes('echo-agent'));
|
|
258
|
+
assert.ok(text.includes('Hello world'));
|
|
259
|
+
} finally {
|
|
260
|
+
counter.restore();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('missing README returns placeholder', async () => {
|
|
265
|
+
const tree = [
|
|
266
|
+
{ path: 'hub', type: 'tree' },
|
|
267
|
+
{ path: 'hub/noreadme', type: 'tree' },
|
|
268
|
+
{ path: 'hub/noreadme/profile.yaml', type: 'blob' },
|
|
269
|
+
];
|
|
270
|
+
const contents = {
|
|
271
|
+
'hub/noreadme/profile.yaml': 'name: noreadme\ndescription: x\ntested: unverified\n',
|
|
272
|
+
};
|
|
273
|
+
const counter = installFakeFetch(tree, contents);
|
|
274
|
+
try {
|
|
275
|
+
const text = await hub.showReadme('noreadme', { refresh: true });
|
|
276
|
+
assert.ok(text.includes('no README.md'));
|
|
277
|
+
} finally {
|
|
278
|
+
counter.restore();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ----- installProfile -----
|
|
284
|
+
|
|
285
|
+
describe('installProfile', () => {
|
|
286
|
+
test('copies to target dir', async () => {
|
|
287
|
+
const counter = installFakeFetch();
|
|
288
|
+
const tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-install-'));
|
|
289
|
+
try {
|
|
290
|
+
const dest = await hub.installProfile('echo-agent', tmpTarget, { refresh: true });
|
|
291
|
+
assert.ok(fs.existsSync(dest));
|
|
292
|
+
assert.ok(fs.existsSync(path.join(dest, 'profile.yaml')));
|
|
293
|
+
assert.ok(fs.existsSync(path.join(dest, 'bridge.py')));
|
|
294
|
+
assert.ok(!fs.existsSync(path.join(dest, '.cache-meta.json')));
|
|
295
|
+
} finally {
|
|
296
|
+
counter.restore();
|
|
297
|
+
fs.rmSync(tmpTarget, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('refuses existing target', async () => {
|
|
302
|
+
const counter = installFakeFetch();
|
|
303
|
+
const tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-install-'));
|
|
304
|
+
try {
|
|
305
|
+
await hub.installProfile('echo-agent', tmpTarget, { refresh: true });
|
|
306
|
+
await assert.rejects(
|
|
307
|
+
hub.installProfile('echo-agent', tmpTarget, { refresh: true }),
|
|
308
|
+
/target already exists/
|
|
309
|
+
);
|
|
310
|
+
} finally {
|
|
311
|
+
counter.restore();
|
|
312
|
+
fs.rmSync(tmpTarget, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ----- Constants (no HOME mutation needed) -----
|
|
318
|
+
|
|
319
|
+
test('HUB_CACHE_TTL_SECS is 24h', () => {
|
|
320
|
+
assert.strictEqual(hub.HUB_CACHE_TTL_SECS, 24 * 60 * 60);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('hub repo constants', () => {
|
|
324
|
+
assert.strictEqual(hub.HUB_REPO, 'jeffkit/agentproc');
|
|
325
|
+
assert.strictEqual(hub.HUB_REF, 'main');
|
|
326
|
+
});
|
|
327
|
+
});
|