agentic-relay 3.8.5 → 4.1.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/CLAUDE.md +1 -1
- package/README.md +11 -1
- package/bin/cli.mjs +5 -2
- package/bin/install.mjs +52 -90
- package/bin/lib/cache.mjs +8 -14
- package/bin/lib/commands.mjs +41 -126
- package/bin/lib/doctor.mjs +222 -0
- package/bin/lib/fetch.mjs +14 -79
- package/bin/lib/install-args.mjs +67 -0
- package/bin/lib/parse.mjs +0 -65
- package/bin/lib/sysenv.mjs +152 -0
- package/bin/lib/update-check.mjs +121 -0
- package/bin/lib/zip.mjs +139 -0
- package/bin/relay-core.mjs +50 -85
- package/bin/test/cache.test.mjs +30 -2
- package/bin/test/doctor.test.mjs +76 -0
- package/bin/test/fetch.test.mjs +0 -15
- package/bin/test/install-args.test.mjs +68 -0
- package/bin/test/parse.test.mjs +1 -27
- package/bin/test/session.test.mjs +5 -1
- package/bin/test/update-check.test.mjs +56 -0
- package/bin/test/zip.test.mjs +212 -0
- package/package.json +3 -3
- package/skill/SKILL.md +3 -2
- package/bin/lib/deliverable.mjs +0 -70
- package/bin/lib/driver.mjs +0 -165
- package/bin/lib/pool.mjs +0 -26
- package/bin/test/driver.test.mjs +0 -49
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async update notifier (the Vercel/update-notifier pattern, zero-dep).
|
|
3
|
+
*
|
|
4
|
+
* The CLI never blocks on the network: each run reads a tiny local state file
|
|
5
|
+
* and prints a one-line stderr nag if a newer version was seen on a PREVIOUS
|
|
6
|
+
* run; when the state is older than 24h it spawns a detached, unref'd child
|
|
7
|
+
* (this module with --refresh) that fetches the registry and rewrites the
|
|
8
|
+
* state for next time.
|
|
9
|
+
*
|
|
10
|
+
* Suppressed when: AGENT_RELAY_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER / CI env
|
|
11
|
+
* vars are set, stderr isn't a TTY (don't pollute piped/scripted runs), or the
|
|
12
|
+
* CLI is running out of an ephemeral npx cache (npx users get @latest anyway).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync, realpathSync } from "fs";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { join, dirname, sep } from "path";
|
|
18
|
+
import { spawn } from "child_process";
|
|
19
|
+
import { fileURLToPath } from "url";
|
|
20
|
+
|
|
21
|
+
export const STATE_PATH = join(homedir(), ".config", "agent-relay", "update-check.json");
|
|
22
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
const SELF_PATH = fileURLToPath(import.meta.url);
|
|
24
|
+
|
|
25
|
+
/** Numeric-dotted semver compare: 1 if a > b, -1 if a < b, 0 if equal. */
|
|
26
|
+
export function compareSemver(a, b) {
|
|
27
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
28
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
29
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
30
|
+
const da = pa[i] || 0;
|
|
31
|
+
const db = pb[i] || 0;
|
|
32
|
+
if (da !== db) return da > db ? 1 : -1;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure suppression predicate (exported for tests). env/tty/binPath injected.
|
|
39
|
+
*/
|
|
40
|
+
export function shouldSuppress({ env, stderrIsTTY, binPath }) {
|
|
41
|
+
if (env.AGENT_RELAY_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI) return true;
|
|
42
|
+
if (!stderrIsTTY) return true;
|
|
43
|
+
if (binPath) {
|
|
44
|
+
let real = binPath;
|
|
45
|
+
try {
|
|
46
|
+
real = realpathSync(binPath);
|
|
47
|
+
} catch {
|
|
48
|
+
/* keep as-is */
|
|
49
|
+
}
|
|
50
|
+
if (real.includes(`${sep}_npx${sep}`) || binPath.includes(`${sep}_npx${sep}`)) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readState(statePath = STATE_PATH) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(statePath, "utf-8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeState(state, statePath = STATE_PATH) {
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
66
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Called at the top of every CLI run. Never throws, never blocks: prints the
|
|
75
|
+
* nag from cached state and (at most once per 24h) spawns the background
|
|
76
|
+
* refresh.
|
|
77
|
+
*/
|
|
78
|
+
export function maybeNotifyUpdate({ currentVersion, binPath = process.argv[1] }) {
|
|
79
|
+
try {
|
|
80
|
+
if (shouldSuppress({ env: process.env, stderrIsTTY: Boolean(process.stderr.isTTY), binPath })) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const state = readState();
|
|
84
|
+
if (state?.latest_version && compareSemver(state.latest_version, currentVersion) > 0) {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`agentrelay ${state.latest_version} is available (you have ${currentVersion}) — npm i -g agentic-relay\n`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const last = state?.last_check_ts ? Date.parse(state.last_check_ts) : 0;
|
|
90
|
+
if (!Number.isFinite(last) || Date.now() - last > CHECK_INTERVAL_MS) {
|
|
91
|
+
spawn(process.execPath, [SELF_PATH, "--refresh"], { detached: true, stdio: "ignore" }).unref();
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// The nag must never break a command.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Background refresh: fetch the registry, persist state, exit. */
|
|
99
|
+
async function refresh() {
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch("https://registry.npmjs.org/agentic-relay/latest", {
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) return;
|
|
107
|
+
const data = await res.json();
|
|
108
|
+
if (data?.version) {
|
|
109
|
+
writeState({ last_check_ts: new Date().toISOString(), latest_version: data.version });
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Offline/registry down — try again next interval.
|
|
113
|
+
} finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Self-run refresh mode (spawned detached by maybeNotifyUpdate).
|
|
119
|
+
if (process.argv[1] === SELF_PATH && process.argv[2] === "--refresh") {
|
|
120
|
+
refresh();
|
|
121
|
+
}
|
package/bin/lib/zip.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-JS zip extraction — no system `unzip` dependency (Windows ships none).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately minimal: reads the END-OF-CENTRAL-DIRECTORY record and walks the
|
|
5
|
+
* central directory (never trusting local headers for sizes — that's what makes
|
|
6
|
+
* archives written with data descriptors / flag bit 3 work), then inflates each
|
|
7
|
+
* entry with node:zlib. Supported: store (method 0) and deflate (method 8).
|
|
8
|
+
* Rejected with explicit errors: zip64, encrypted entries, other methods.
|
|
9
|
+
*
|
|
10
|
+
* Security posture (matches the old `unzip` path, or stricter):
|
|
11
|
+
* - zip-slip guard runs over ALL entry names before anything is written
|
|
12
|
+
* - symlink entries are materialized as regular files (no link-then-write-through)
|
|
13
|
+
* - nothing from the archive is ever executed
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
17
|
+
import { dirname, join, resolve, sep } from "path";
|
|
18
|
+
import { inflateRawSync } from "zlib";
|
|
19
|
+
|
|
20
|
+
const EOCD_SIG = 0x06054b50; // end of central directory
|
|
21
|
+
const CEN_SIG = 0x02014b50; // central directory file header
|
|
22
|
+
const LOC_SIG = 0x04034b50; // local file header
|
|
23
|
+
const ZIP64_LOCATOR_SIG = 0x07064b50; // zip64 EOCD locator
|
|
24
|
+
|
|
25
|
+
/** Reject any archive entry that escapes the destination (absolute or ../). */
|
|
26
|
+
export function assertNoZipSlip(entryNames, destDir) {
|
|
27
|
+
const destRoot = resolve(destDir) + sep;
|
|
28
|
+
for (const name of entryNames) {
|
|
29
|
+
if (name.startsWith("/") || name.includes("\0")) {
|
|
30
|
+
throw new Error(`unsafe archive entry (absolute path): ${name}`);
|
|
31
|
+
}
|
|
32
|
+
const target = resolve(destDir, name);
|
|
33
|
+
if (target !== resolve(destDir) && !target.startsWith(destRoot)) {
|
|
34
|
+
throw new Error(`unsafe archive entry (path traversal / zip-slip): ${name}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Locate the EOCD record (scan backwards over the max 22-byte record +
|
|
41
|
+
* 65535-byte comment tail). Throws on zip64 archives and missing records.
|
|
42
|
+
*/
|
|
43
|
+
export function findEndOfCentralDirectory(buf) {
|
|
44
|
+
const min = Math.max(0, buf.length - 22 - 65535);
|
|
45
|
+
for (let i = buf.length - 22; i >= min; i--) {
|
|
46
|
+
if (buf.readUInt32LE(i) !== EOCD_SIG) continue;
|
|
47
|
+
const totalEntries = buf.readUInt16LE(i + 10);
|
|
48
|
+
const cdSize = buf.readUInt32LE(i + 12);
|
|
49
|
+
const cdOffset = buf.readUInt32LE(i + 16);
|
|
50
|
+
if (
|
|
51
|
+
totalEntries === 0xffff ||
|
|
52
|
+
cdSize === 0xffffffff ||
|
|
53
|
+
cdOffset === 0xffffffff ||
|
|
54
|
+
(i >= 20 && buf.readUInt32LE(i - 20) === ZIP64_LOCATOR_SIG)
|
|
55
|
+
) {
|
|
56
|
+
throw new Error("zip64 archives are not supported");
|
|
57
|
+
}
|
|
58
|
+
return { cdOffset, cdSize, totalEntries };
|
|
59
|
+
}
|
|
60
|
+
throw new Error("invalid zip: end-of-central-directory record not found");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk the central directory. Sizes/offsets come from here, NEVER from local
|
|
65
|
+
* headers, so data-descriptor zips (local sizes = 0) extract correctly.
|
|
66
|
+
*/
|
|
67
|
+
export function listZipEntries(buf) {
|
|
68
|
+
const { cdOffset, totalEntries } = findEndOfCentralDirectory(buf);
|
|
69
|
+
const entries = [];
|
|
70
|
+
let p = cdOffset;
|
|
71
|
+
for (let n = 0; n < totalEntries; n++) {
|
|
72
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CEN_SIG) {
|
|
73
|
+
throw new Error("invalid zip: corrupt central directory");
|
|
74
|
+
}
|
|
75
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
76
|
+
const method = buf.readUInt16LE(p + 10);
|
|
77
|
+
const compressedSize = buf.readUInt32LE(p + 20);
|
|
78
|
+
const uncompressedSize = buf.readUInt32LE(p + 24);
|
|
79
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
80
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
81
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
82
|
+
const localHeaderOffset = buf.readUInt32LE(p + 42);
|
|
83
|
+
const name = buf.toString("utf-8", p + 46, p + 46 + nameLen);
|
|
84
|
+
if (flags & 0x1) {
|
|
85
|
+
throw new Error(`encrypted zip entries are not supported: ${name}`);
|
|
86
|
+
}
|
|
87
|
+
entries.push({
|
|
88
|
+
name,
|
|
89
|
+
method,
|
|
90
|
+
compressedSize,
|
|
91
|
+
uncompressedSize,
|
|
92
|
+
localHeaderOffset,
|
|
93
|
+
isDirectory: name.endsWith("/"),
|
|
94
|
+
});
|
|
95
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
96
|
+
}
|
|
97
|
+
return entries;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Decompress one entry's bytes (seeks via the local header it points at). */
|
|
101
|
+
export function readEntryData(buf, entry) {
|
|
102
|
+
const p = entry.localHeaderOffset;
|
|
103
|
+
if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) {
|
|
104
|
+
throw new Error(`invalid zip: bad local header for ${entry.name}`);
|
|
105
|
+
}
|
|
106
|
+
// The local header's OWN name/extra lengths govern where data starts (its
|
|
107
|
+
// extra field can differ from the central directory's).
|
|
108
|
+
const nameLen = buf.readUInt16LE(p + 26);
|
|
109
|
+
const extraLen = buf.readUInt16LE(p + 28);
|
|
110
|
+
const start = p + 30 + nameLen + extraLen;
|
|
111
|
+
const data = buf.subarray(start, start + entry.compressedSize);
|
|
112
|
+
if (entry.method === 0) return Buffer.from(data);
|
|
113
|
+
if (entry.method === 8) return inflateRawSync(data);
|
|
114
|
+
throw new Error(`unsupported zip compression method ${entry.method} for ${entry.name}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Extract an in-memory zip buffer into destDir. Returns the list of file paths
|
|
119
|
+
* written. Validates every entry name against zip-slip BEFORE any write.
|
|
120
|
+
*/
|
|
121
|
+
export function extractZip(buf, destDir) {
|
|
122
|
+
const entries = listZipEntries(buf);
|
|
123
|
+
assertNoZipSlip(entries.map((e) => e.name), destDir);
|
|
124
|
+
mkdirSync(destDir, { recursive: true });
|
|
125
|
+
const placed = [];
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const target = join(destDir, entry.name);
|
|
128
|
+
if (entry.isDirectory) {
|
|
129
|
+
mkdirSync(target, { recursive: true });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
133
|
+
// Symlink entries (unix mode S_IFLNK in external attrs) are intentionally
|
|
134
|
+
// written as regular files containing the link target text.
|
|
135
|
+
writeFileSync(target, readEntryData(buf, entry));
|
|
136
|
+
placed.push(target);
|
|
137
|
+
}
|
|
138
|
+
return placed;
|
|
139
|
+
}
|
package/bin/relay-core.mjs
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* agentrelay (CLI core) —
|
|
4
|
+
* agentrelay (CLI core) — live agent sessions, payments, and deliverable fetch.
|
|
5
5
|
*
|
|
6
|
-
* Wraps the AttentionMarket REST API so a coding agent can
|
|
7
|
-
* "solutions-engineer" capability agents
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Wraps the AttentionMarket REST API so a coding agent can converse with expert
|
|
7
|
+
* "solutions-engineer" capability agents. The caller (a host subagent, or the
|
|
8
|
+
* main agent directly) drives the conversation via the session primitives and
|
|
9
|
+
* banks compact specs in the on-disk cache; raw transcripts stay out of the
|
|
10
|
+
* main context.
|
|
10
11
|
*
|
|
11
12
|
* Commands:
|
|
12
|
-
* agentrelay search <query>
|
|
13
|
-
* agentrelay
|
|
14
|
-
* agentrelay
|
|
15
|
-
* agentrelay
|
|
13
|
+
* agentrelay search <query> broad discovery (cheap; safe in main context)
|
|
14
|
+
* agentrelay session start|send|pay|end drive a live consult
|
|
15
|
+
* agentrelay cache ls|show|put|clear inspect/manage the on-disk spec cache
|
|
16
|
+
* agentrelay fetch download + verify + unpack a paid deliverable
|
|
16
17
|
*
|
|
17
18
|
* Output: a `{ ok, ...payload, errors: [] }` envelope. --json forces machine
|
|
18
19
|
* output (auto-on when stdout is not a TTY). Exit codes: 0 ok, 1 partial,
|
|
@@ -26,8 +27,6 @@ import { readFileSync } from "fs";
|
|
|
26
27
|
import { resolveApiKey } from "./lib/config.mjs";
|
|
27
28
|
import {
|
|
28
29
|
cmdSearch,
|
|
29
|
-
cmdConsult,
|
|
30
|
-
cmdConsultBatch,
|
|
31
30
|
cmdSession,
|
|
32
31
|
cmdFeedback,
|
|
33
32
|
cmdCache,
|
|
@@ -36,22 +35,26 @@ import {
|
|
|
36
35
|
cmdFetchSession,
|
|
37
36
|
} from "./lib/commands.mjs";
|
|
38
37
|
import { cmdFetch } from "./lib/fetch.mjs";
|
|
38
|
+
import { cmdDoctor, fmtDoctor } from "./lib/doctor.mjs";
|
|
39
|
+
import { maybeNotifyUpdate } from "./lib/update-check.mjs";
|
|
39
40
|
|
|
40
41
|
const EXIT = { OK: 0, PARTIAL: 1, USAGE: 2, AUTH: 3 };
|
|
41
42
|
|
|
43
|
+
function packageVersion() {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
46
|
+
} catch {
|
|
47
|
+
return "unknown";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
const FLAGS_WITH_VALUE = new Set([
|
|
43
52
|
"--api-key",
|
|
44
53
|
"--context",
|
|
45
54
|
"--cache-dir",
|
|
46
55
|
"--timeout",
|
|
47
|
-
"--max-turns",
|
|
48
|
-
"--concurrency",
|
|
49
56
|
"--max",
|
|
50
57
|
"--user-context",
|
|
51
|
-
"--want",
|
|
52
|
-
"--want-file",
|
|
53
|
-
"--agents",
|
|
54
|
-
"--driver",
|
|
55
58
|
"--initial",
|
|
56
59
|
"--message",
|
|
57
60
|
"--data",
|
|
@@ -108,8 +111,6 @@ function buildShared(flags) {
|
|
|
108
111
|
noCache: Boolean(flags["no-cache"]),
|
|
109
112
|
refresh: Boolean(flags.refresh),
|
|
110
113
|
timeoutMs: (Number(flags.timeout) || 120) * 1000,
|
|
111
|
-
maxTurns: Number(flags["max-turns"]) || 6,
|
|
112
|
-
concurrency: Number(flags.concurrency) || 5,
|
|
113
114
|
quiet: Boolean(flags.quiet),
|
|
114
115
|
verbose: Boolean(flags.verbose),
|
|
115
116
|
context: loadContext(flags.context),
|
|
@@ -163,19 +164,6 @@ const fmtBudget = (e) => {
|
|
|
163
164
|
].join("\n");
|
|
164
165
|
};
|
|
165
166
|
|
|
166
|
-
const fmtDeliverable = (d) => {
|
|
167
|
-
const lines = [`▸ ${d.name} (${d.slug})${d.cached ? " [cached]" : ""}`];
|
|
168
|
-
if (d.verdict) lines.push(` verdict: ${d.verdict}`);
|
|
169
|
-
if (d.install?.length) lines.push(` install: ${d.install.join(" && ")}`);
|
|
170
|
-
if (d.endpoints?.length) lines.push(` endpoints: ${d.endpoints.map((x) => x.name || x.url).join(", ")}`);
|
|
171
|
-
if (d.snippets?.length) lines.push(` snippets: ${d.snippets.map((s) => s.path || s.desc || s.lang).join(", ")}`);
|
|
172
|
-
if (d.gotchas?.length) lines.push(` gotchas: ${d.gotchas.join("; ")}`);
|
|
173
|
-
if (d.free_tier) lines.push(` free tier: ${d.free_tier}`);
|
|
174
|
-
if (d.docs?.length) lines.push(` docs: ${d.docs.join(" ")}`);
|
|
175
|
-
lines.push(` (${d.turns} turns, ~${d.tokens_estimate} tokens)`);
|
|
176
|
-
return lines.join("\n");
|
|
177
|
-
};
|
|
178
|
-
|
|
179
167
|
const HELP = `agentrelay — converse with capability agents while you build.
|
|
180
168
|
|
|
181
169
|
Conversation (LLM-driven — drive these from a subagent, or directly when you're stuck):
|
|
@@ -195,6 +183,9 @@ Payments (autonomous spend policy — server-canonical):
|
|
|
195
183
|
[--require-human-over=<$>] [--per-session-cap=<$>]
|
|
196
184
|
agentrelay connect-wallet (wallet connect is set up in the dashboard)
|
|
197
185
|
|
|
186
|
+
Diagnostics:
|
|
187
|
+
agentrelay doctor check node/key/API/npm-prefix/PATH health (--json)
|
|
188
|
+
|
|
198
189
|
Deliverables (after a paid session returns a delivery object):
|
|
199
190
|
agentrelay fetch --session <id> [--message "<m>"] [dest] ONE-STEP after payment:
|
|
200
191
|
drives a turn to mint a fresh signed URL, then
|
|
@@ -202,12 +193,8 @@ Deliverables (after a paid session returns a delivery object):
|
|
|
202
193
|
agentrelay fetch <download_url> [dest] [--checksum <sha256>] [--filename <name>]
|
|
203
194
|
raw-URL download + unpack (never executes anything)
|
|
204
195
|
|
|
205
|
-
Template fallback (code-driven, no LLM — distilled result):
|
|
206
|
-
agentrelay consult <slug> --want <s> [--context f.json] [--deliver-now] [--refresh]
|
|
207
|
-
agentrelay consult-batch --agents a,b,c (--want <s> | --want-file w.json) [--concurrency 5]
|
|
208
|
-
|
|
209
196
|
Global: --api-key <k> --json --context <f> --cache-dir <d> --no-cache
|
|
210
|
-
--timeout <sec> --
|
|
197
|
+
--timeout <sec> --quiet --verbose
|
|
211
198
|
|
|
212
199
|
Key: --api-key | $AGENT_RELAY_API_KEY | ~/.config/agent-relay/credentials.json`;
|
|
213
200
|
|
|
@@ -221,15 +208,26 @@ async function main() {
|
|
|
221
208
|
process.stdout.write(HELP + "\n");
|
|
222
209
|
process.exit(cmd ? EXIT.OK : EXIT.USAGE);
|
|
223
210
|
}
|
|
211
|
+
if (cmd === "consult" || cmd === "consult-batch") {
|
|
212
|
+
return fail(
|
|
213
|
+
`"${cmd}" was removed in v4 — drive a live session instead: ` +
|
|
214
|
+
"`agentrelay session start <slug>` → `session send` → `session end` (see SKILL.md)",
|
|
215
|
+
EXIT.USAGE,
|
|
216
|
+
Boolean(flags.json) || !process.stdout.isTTY,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
224
219
|
if (cmd === "version" || flags.version) {
|
|
225
|
-
|
|
226
|
-
try {
|
|
227
|
-
v = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
228
|
-
} catch { /* fall back to "unknown" */ }
|
|
229
|
-
process.stdout.write(`agentrelay ${v}\n`);
|
|
220
|
+
process.stdout.write(`agentrelay ${packageVersion()}\n`);
|
|
230
221
|
process.exit(EXIT.OK);
|
|
231
222
|
}
|
|
232
223
|
|
|
224
|
+
// Non-blocking: nag from cached state, refresh in a detached child ≤1×/24h.
|
|
225
|
+
try {
|
|
226
|
+
maybeNotifyUpdate({ currentVersion: packageVersion() });
|
|
227
|
+
} catch {
|
|
228
|
+
/* never let the update nag break a command */
|
|
229
|
+
}
|
|
230
|
+
|
|
233
231
|
let shared;
|
|
234
232
|
try {
|
|
235
233
|
shared = buildShared(flags);
|
|
@@ -238,9 +236,9 @@ async function main() {
|
|
|
238
236
|
}
|
|
239
237
|
const json = shared.json;
|
|
240
238
|
|
|
241
|
-
// cache
|
|
242
|
-
// `fetch --session` calls the session API
|
|
243
|
-
const keylessCmd = cmd === "cache" || (cmd === "fetch" && !flags.session);
|
|
239
|
+
// cache, raw-URL fetch, and doctor don't need a key (doctor resolves and
|
|
240
|
+
// reports on the key itself). `fetch --session` calls the session API.
|
|
241
|
+
const keylessCmd = cmd === "cache" || cmd === "doctor" || (cmd === "fetch" && !flags.session);
|
|
244
242
|
if (!keylessCmd && !shared.apiKey) {
|
|
245
243
|
return fail(
|
|
246
244
|
"no API key — set --api-key, $AGENT_RELAY_API_KEY, or ~/.config/agent-relay/credentials.json",
|
|
@@ -263,45 +261,6 @@ async function main() {
|
|
|
263
261
|
return emit(payload, { json, human: fmtSearch });
|
|
264
262
|
}
|
|
265
263
|
|
|
266
|
-
case "consult": {
|
|
267
|
-
const slug = positionals[0];
|
|
268
|
-
if (!slug) return fail("usage: agentrelay consult <slug> --want <str>", EXIT.USAGE, json);
|
|
269
|
-
const payload = await cmdConsult(slug, {
|
|
270
|
-
...shared,
|
|
271
|
-
want: flags.want || null,
|
|
272
|
-
driver: flags.driver || null,
|
|
273
|
-
deliverNow: Boolean(flags["deliver-now"]),
|
|
274
|
-
raw: Boolean(flags.raw),
|
|
275
|
-
});
|
|
276
|
-
return emit(payload, { json, human: (e) => fmtDeliverable(e.deliverable) });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
case "consult-batch": {
|
|
280
|
-
const payload = await cmdConsultBatch({
|
|
281
|
-
...shared,
|
|
282
|
-
agents: flags.agents ? String(flags.agents).split(",").map((s) => s.trim()).filter(Boolean) : null,
|
|
283
|
-
want: flags.want || null,
|
|
284
|
-
wantFile: flags["want-file"] || null,
|
|
285
|
-
driver: flags.driver || null,
|
|
286
|
-
deliverNow: Boolean(flags["deliver-now"]),
|
|
287
|
-
raw: Boolean(flags.raw),
|
|
288
|
-
});
|
|
289
|
-
const exitCode = payload.failures.length > 0 ? EXIT.PARTIAL : EXIT.OK;
|
|
290
|
-
return emit(payload, {
|
|
291
|
-
json,
|
|
292
|
-
ok: payload.failures.length === 0,
|
|
293
|
-
errors: payload.failures.map((f) => `${f.slug}: ${f.reason}`),
|
|
294
|
-
exitCode,
|
|
295
|
-
human: (e) =>
|
|
296
|
-
[
|
|
297
|
-
`consulted ${e.totals.consulted}: ${e.totals.ok} ok, ${e.totals.failed} failed ` +
|
|
298
|
-
`(${e.totals.wall_seconds}s, ~${e.totals.tokens_estimate} tokens)`,
|
|
299
|
-
...e.deliverables.map(fmtDeliverable),
|
|
300
|
-
...e.failures.map((f) => `✗ ${f.slug}: ${f.reason}`),
|
|
301
|
-
].join("\n"),
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
264
|
case "session": {
|
|
306
265
|
const payload = await cmdSession(positionals[0], positionals.slice(1), {
|
|
307
266
|
...shared,
|
|
@@ -375,6 +334,12 @@ async function main() {
|
|
|
375
334
|
});
|
|
376
335
|
}
|
|
377
336
|
|
|
337
|
+
case "doctor": {
|
|
338
|
+
const payload = await cmdDoctor({ ...shared, apiKey: flags["api-key"] || null });
|
|
339
|
+
const exitCode = payload.exit_code;
|
|
340
|
+
return emit(payload, { json, ok: payload.doctor_ok, exitCode, human: fmtDoctor });
|
|
341
|
+
}
|
|
342
|
+
|
|
378
343
|
case "cache": {
|
|
379
344
|
const payload = cmdCache(positionals[0], positionals[1], { ...shared, file: flags.file || null });
|
|
380
345
|
const human = Array.isArray(payload.specs)
|
|
@@ -390,7 +355,7 @@ async function main() {
|
|
|
390
355
|
|
|
391
356
|
default:
|
|
392
357
|
return fail(
|
|
393
|
-
`unknown command "${cmd}" (session|search|
|
|
358
|
+
`unknown command "${cmd}" (session|search|feedback|cache|budget|connect-wallet|fetch|doctor)`,
|
|
394
359
|
EXIT.USAGE,
|
|
395
360
|
json,
|
|
396
361
|
);
|
package/bin/test/cache.test.mjs
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
|
-
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
9
|
+
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { Cache } from "../lib/cache.mjs";
|
|
13
|
-
import { resolveCapability } from "../lib/commands.mjs";
|
|
13
|
+
import { resolveCapability, cmdCache } from "../lib/commands.mjs";
|
|
14
14
|
|
|
15
15
|
const ISO = "2026-05-24T00:00:00.000Z";
|
|
16
16
|
|
|
@@ -69,3 +69,31 @@ test("resolveCapability: falls back to the banked spec's ids (no network)", asyn
|
|
|
69
69
|
assert.equal(r.name, "A Tool");
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
test("cache put: soft contract warnings (missing session_id/summary/features), still stores", () => {
|
|
74
|
+
withCache((dir, cache) => {
|
|
75
|
+
const f = join(dir, "min.json");
|
|
76
|
+
writeFileSync(f, JSON.stringify({ slug: "bare-tool", name: "Bare" }));
|
|
77
|
+
const r = cmdCache("put", "bare-tool", { cacheDir: dir, file: f });
|
|
78
|
+
assert.equal(r.stored, true);
|
|
79
|
+
assert.equal(r.warnings.length, 3);
|
|
80
|
+
assert.match(r.warnings.join(" "), /session_id/);
|
|
81
|
+
assert.match(r.warnings.join(" "), /summary/);
|
|
82
|
+
assert.match(r.warnings.join(" "), /features/);
|
|
83
|
+
assert.ok(cache.readSpec("bare-tool")); // warned, but banked anyway
|
|
84
|
+
|
|
85
|
+
// A contract-complete spec yields no warnings.
|
|
86
|
+
writeFileSync(
|
|
87
|
+
f,
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
slug: "full-tool",
|
|
90
|
+
session_id: "s-1",
|
|
91
|
+
summary: "maps the surface",
|
|
92
|
+
features: [{ feature: "x", how: "y", status: "surface_only" }],
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
const r2 = cmdCache("put", "full-tool", { cacheDir: dir, file: f });
|
|
96
|
+
assert.deepEqual(r2.warnings, []);
|
|
97
|
+
assert.ok(cache.readSpec("full-tool"));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `agentrelay doctor` (pure pieces) and the sysenv probes that are
|
|
3
|
+
* testable without touching the real npm install.
|
|
4
|
+
* Run: `node --test bin/test/doctor.test.mjs`.
|
|
5
|
+
*/
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { sep } from "node:path";
|
|
9
|
+
import { checkNodeVersion, summarize, fmtDoctor } from "../lib/doctor.mjs";
|
|
10
|
+
import { dirOnPath, npmGlobalBinDir, globalBinShimPath, isEphemeralBin } from "../lib/sysenv.mjs";
|
|
11
|
+
|
|
12
|
+
test("checkNodeVersion: >=18 ok, <18 fail", () => {
|
|
13
|
+
assert.equal(checkNodeVersion("18.0.0").status, "ok");
|
|
14
|
+
assert.equal(checkNodeVersion("23.11.0").status, "ok");
|
|
15
|
+
assert.equal(checkNodeVersion("16.20.2").status, "fail");
|
|
16
|
+
assert.match(checkNodeVersion("16.20.2").detail, /needs Node >= 18/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("summarize: warns don't fail the doctor, fails do", () => {
|
|
20
|
+
const okWarn = summarize([
|
|
21
|
+
{ status: "ok" },
|
|
22
|
+
{ status: "warn" },
|
|
23
|
+
]);
|
|
24
|
+
assert.equal(okWarn.ok, true);
|
|
25
|
+
assert.equal(okWarn.exitCode, 0);
|
|
26
|
+
|
|
27
|
+
const withFail = summarize([{ status: "ok" }, { status: "fail" }]);
|
|
28
|
+
assert.equal(withFail.ok, false);
|
|
29
|
+
assert.equal(withFail.exitCode, 1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("fmtDoctor renders one line per check + a verdict line", () => {
|
|
33
|
+
const out = fmtDoctor({
|
|
34
|
+
checks: [
|
|
35
|
+
{ status: "ok", label: "Node.js version", detail: "v20.0.0" },
|
|
36
|
+
{ status: "warn", label: "API key", detail: "not found" },
|
|
37
|
+
],
|
|
38
|
+
doctor_ok: true,
|
|
39
|
+
});
|
|
40
|
+
const lines = out.split("\n");
|
|
41
|
+
assert.equal(lines.length, 3);
|
|
42
|
+
assert.match(lines[0], /^✓ /);
|
|
43
|
+
assert.match(lines[1], /^! /);
|
|
44
|
+
assert.match(lines[2], /no blocking problems/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("dirOnPath: exact component match, trailing-slash tolerant", () => {
|
|
48
|
+
const delim = process.platform === "win32" ? ";" : ":";
|
|
49
|
+
const pathVar = ["/usr/bin", "/opt/homebrew/bin/"].join(delim);
|
|
50
|
+
assert.equal(dirOnPath("/opt/homebrew/bin", pathVar), true);
|
|
51
|
+
assert.equal(dirOnPath("/usr/bin", pathVar), true);
|
|
52
|
+
assert.equal(dirOnPath("/usr/local/bin", pathVar), false);
|
|
53
|
+
assert.equal(dirOnPath(null, pathVar), false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("npmGlobalBinDir/globalBinShimPath shape per platform", () => {
|
|
57
|
+
const prefix = process.platform === "win32" ? "C:\\npm" : "/opt/homebrew";
|
|
58
|
+
const binDir = npmGlobalBinDir(prefix);
|
|
59
|
+
const shim = globalBinShimPath(prefix, "agentrelay");
|
|
60
|
+
if (process.platform === "win32") {
|
|
61
|
+
assert.equal(binDir, prefix);
|
|
62
|
+
assert.ok(shim.endsWith("agentrelay.cmd"));
|
|
63
|
+
} else {
|
|
64
|
+
assert.equal(binDir, `${prefix}/bin`);
|
|
65
|
+
assert.ok(shim.endsWith("/bin/agentrelay"));
|
|
66
|
+
}
|
|
67
|
+
assert.equal(npmGlobalBinDir(null), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("isEphemeralBin flags npx-cache paths and dangling symlinks", () => {
|
|
71
|
+
const npxBin = ["", "Users", "x", ".npm", "_npx", "ab12", "node_modules", ".bin", "agentrelay"].join(sep);
|
|
72
|
+
assert.equal(isEphemeralBin(npxBin, "/tmp/pkg"), true);
|
|
73
|
+
// A path that doesn't exist → realpath throws → treated as not-usable.
|
|
74
|
+
assert.equal(isEphemeralBin("/definitely/not/a/real/bin", "/tmp/pkg"), true);
|
|
75
|
+
assert.equal(isEphemeralBin(null, "/tmp/pkg"), false);
|
|
76
|
+
});
|
package/bin/test/fetch.test.mjs
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
sha256Hex,
|
|
17
17
|
inferSlug,
|
|
18
18
|
uniqueDir,
|
|
19
|
-
zipEntriesFromListing,
|
|
20
19
|
assertNoZipSlip,
|
|
21
20
|
cmdFetch,
|
|
22
21
|
} from "../lib/fetch.mjs";
|
|
@@ -41,20 +40,6 @@ test("inferSlug from filename and URL", () => {
|
|
|
41
40
|
assert.equal(inferSlug("not a url"), "deliverable");
|
|
42
41
|
});
|
|
43
42
|
|
|
44
|
-
test("zipEntriesFromListing parses unzip -l output", () => {
|
|
45
|
-
const listing = [
|
|
46
|
-
"Archive: x.zip",
|
|
47
|
-
" Length Date Time Name",
|
|
48
|
-
"--------- ---------- ----- ----",
|
|
49
|
-
" 12 2026-06-03 10:00 skill/SKILL.md",
|
|
50
|
-
" 0 2026-06-03 10:00 skill/",
|
|
51
|
-
"--------- -------",
|
|
52
|
-
" 12 2 files",
|
|
53
|
-
].join("\n");
|
|
54
|
-
const entries = zipEntriesFromListing(listing);
|
|
55
|
-
assert.deepEqual(entries, ["skill/SKILL.md", "skill/"]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
43
|
test("assertNoZipSlip rejects traversal and absolute paths", () => {
|
|
59
44
|
const dest = join(tmpdir(), "dest");
|
|
60
45
|
assert.throws(() => assertNoZipSlip(["../escape.sh"], dest), /zip-slip|traversal/);
|