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.
@@ -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
+ }
@@ -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
+ }
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * agentrelay (CLI core) — token-cheap, parallel agent consulting for coding workflows.
4
+ * agentrelay (CLI core) — live agent sessions, payments, and deliverable fetch.
5
5
  *
6
- * Wraps the AttentionMarket REST API so a coding agent can consult expert
7
- * "solutions-engineer" capability agents without flooding its context with raw
8
- * transcripts. The multi-turn qualify→deliver loop, fan-out, and caching run
9
- * HERE (in this process), and only compact Deliverables come back.
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> broad discovery (cheap; safe in main context)
13
- * agentrelay consult <slug> run one agent's full loop → a Deliverable
14
- * agentrelay consult-batch fan-out, parallel, one consolidated result
15
- * agentrelay cache ls|show|clear inspect/manage the on-disk spec cache
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> --max-turns <n> --concurrency <n> --quiet --verbose
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
- let v = "unknown";
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 and raw-URL fetch are the only commands that don't need a key.
242
- // `fetch --session` calls the session API, so it does need one.
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|consult|consult-batch|feedback|cache|budget|connect-wallet|fetch)`,
358
+ `unknown command "${cmd}" (session|search|feedback|cache|budget|connect-wallet|fetch|doctor)`,
394
359
  EXIT.USAGE,
395
360
  json,
396
361
  );
@@ -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
+ });
@@ -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/);