agentic-relay 4.0.0 → 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/README.md +10 -0
- package/bin/cli.mjs +1 -0
- package/bin/install.mjs +52 -90
- package/bin/lib/cache.mjs +8 -14
- package/bin/lib/commands.mjs +34 -6
- 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/sysenv.mjs +152 -0
- package/bin/lib/update-check.mjs +121 -0
- package/bin/lib/zip.mjs +139 -0
- package/bin/relay-core.mjs +31 -9
- 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/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 +1 -1
- package/skill/SKILL.md +1 -0
package/README.md
CHANGED
|
@@ -135,11 +135,21 @@ gotchas}], capability_id, ad_unit_id }` (+ any extra keys) — see `schema/deliv
|
|
|
135
135
|
| `agentrelay feedback <id>` | `--score <0-100> --text "<s>"`. |
|
|
136
136
|
| `agentrelay cache ls\|show <slug>\|put <slug>\|clear` | Persist/review distillations (`.agent-relay/`). |
|
|
137
137
|
| `agentrelay session pay <id>` / `fetch` / `budget` | Settle an in-policy charge, fetch a paid deliverable, read/set the spend policy. |
|
|
138
|
+
| `agentrelay doctor` | Diagnose the install: Node version, API key + connectivity, npm prefix writability, `~/.npm` ownership, global bin + PATH. `--json` for machines. |
|
|
138
139
|
|
|
139
140
|
Output is a `{ ok, ...payload, errors: [] }` envelope (`--json` auto-on when piped). Key resolution:
|
|
140
141
|
`--api-key` → `$AGENT_RELAY_API_KEY` → `~/.config/agent-relay/credentials.json` (with a read-only fallback to the
|
|
141
142
|
legacy `~/.config/penguin/credentials.json`). The key is never printed.
|
|
142
143
|
|
|
144
|
+
**Staying current:** interactive runs print a one-line stderr notice when a newer version is on npm
|
|
145
|
+
(checked in the background at most once per 24h; never blocks a command, never fires when piped, in CI,
|
|
146
|
+
or under `npx`). Opt out with `AGENT_RELAY_NO_UPDATE_CHECK=1`. Deliverable zips are extracted by a
|
|
147
|
+
built-in pure-JS extractor — no system `unzip` needed, so `fetch` works on Windows too.
|
|
148
|
+
|
|
149
|
+
**Future work (not in this release):** standalone binaries, a Homebrew tap, winget/scoop manifests, and
|
|
150
|
+
code signing. The package uses no npm lifecycle scripts (no postinstall), so npm v12's
|
|
151
|
+
scripts-blocked-by-default change does not affect installs.
|
|
152
|
+
|
|
143
153
|
## What is Agent Relay?
|
|
144
154
|
|
|
145
155
|
AI-native search for software, tools, services, and business solutions. Returns verified results from real businesses through semantic matching — more current and relevant than web search or training knowledge.
|
package/bin/cli.mjs
CHANGED
package/bin/install.mjs
CHANGED
|
@@ -29,13 +29,26 @@
|
|
|
29
29
|
* deeplink Print URL the agent registers (tome://, raycast://) — user clicks Install
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync
|
|
32
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
33
33
|
import { homedir } from "os";
|
|
34
34
|
import { join, dirname } from "path";
|
|
35
35
|
import { fileURLToPath } from "url";
|
|
36
36
|
import { createInterface } from "readline/promises";
|
|
37
37
|
import { stdin, stdout } from "process";
|
|
38
38
|
import { execSync } from "child_process";
|
|
39
|
+
import { parseInstallArgs } from "./lib/install-args.mjs";
|
|
40
|
+
import {
|
|
41
|
+
resolveCommandPath,
|
|
42
|
+
isEphemeralBin,
|
|
43
|
+
npmGlobalPrefix,
|
|
44
|
+
npmGlobalBinDir,
|
|
45
|
+
globalBinShimPath,
|
|
46
|
+
globalPrefixWritable,
|
|
47
|
+
npmCacheOwnedByOther,
|
|
48
|
+
dirOnPath,
|
|
49
|
+
USER_PREFIX_RECIPE,
|
|
50
|
+
CACHE_CHOWN_RECIPE,
|
|
51
|
+
} from "./lib/sysenv.mjs";
|
|
39
52
|
|
|
40
53
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
41
54
|
const HOME = homedir();
|
|
@@ -266,39 +279,8 @@ function escapeRegex(str) {
|
|
|
266
279
|
// ═══════════════════════════════════════════════════════════════════════
|
|
267
280
|
|
|
268
281
|
function parseArgs() {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
targets: null,
|
|
272
|
-
all: args.includes("--all"),
|
|
273
|
-
project: args.includes("--project"),
|
|
274
|
-
apiKey: null,
|
|
275
|
-
librechat: null,
|
|
276
|
-
// Legacy single-flag shortcuts (kept for backward compat)
|
|
277
|
-
legacyClaude: args.includes("--claude"),
|
|
278
|
-
legacyCodex: args.includes("--codex"),
|
|
279
|
-
legacyCursor: args.includes("--cursor"),
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
for (const arg of args) {
|
|
283
|
-
if (arg.startsWith("--target=")) {
|
|
284
|
-
flags.targets = arg
|
|
285
|
-
.slice("--target=".length)
|
|
286
|
-
.split(",")
|
|
287
|
-
.map((s) => s.trim())
|
|
288
|
-
.filter(Boolean);
|
|
289
|
-
} else if (arg.startsWith("--api-key=")) {
|
|
290
|
-
flags.apiKey = arg.slice("--api-key=".length);
|
|
291
|
-
} else if (arg.startsWith("--librechat=")) {
|
|
292
|
-
flags.librechat = arg.slice("--librechat=".length);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Support `--api-key VALUE` (space-separated) too
|
|
297
|
-
const keyIdx = args.indexOf("--api-key");
|
|
298
|
-
if (keyIdx !== -1 && args[keyIdx + 1] && !args[keyIdx + 1].startsWith("--")) {
|
|
299
|
-
flags.apiKey = args[keyIdx + 1];
|
|
300
|
-
}
|
|
301
|
-
|
|
282
|
+
const { flags, warnings } = parseInstallArgs(process.argv.slice(2));
|
|
283
|
+
for (const w of warnings) warn(w);
|
|
302
284
|
return flags;
|
|
303
285
|
}
|
|
304
286
|
|
|
@@ -623,45 +605,10 @@ function getExistingKey() {
|
|
|
623
605
|
* Prefers a registry install (`agentic-relay@<version>`) over the local path
|
|
624
606
|
* (the npx cache dir is ephemeral; npm would link/copy from it). Falls back to
|
|
625
607
|
* the path for unpublished checkouts. Surfaces npm's actual error (EACCES
|
|
626
|
-
* etc.) instead of swallowing it, and verifies the
|
|
627
|
-
*
|
|
608
|
+
* etc.) instead of swallowing it, and verifies the global bin file landed so a
|
|
609
|
+
* PATH mismatch is reported, not discovered later as `command not found`.
|
|
610
|
+
* System probes live in lib/sysenv.mjs (shared with `agentrelay doctor`).
|
|
628
611
|
*/
|
|
629
|
-
function resolveCommandPath(cmd) {
|
|
630
|
-
try {
|
|
631
|
-
const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
|
|
632
|
-
const out = execSync(probe, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
633
|
-
return out.split("\n")[0].trim() || null;
|
|
634
|
-
} catch {
|
|
635
|
-
return null;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** True when a resolved bin path is an ephemeral npx/npm-exec shim (or this package's own copy), not a real install. */
|
|
640
|
-
function isEphemeralBin(binPath, pkgRoot) {
|
|
641
|
-
if (!binPath) return false;
|
|
642
|
-
let real = binPath;
|
|
643
|
-
try {
|
|
644
|
-
real = realpathSync(binPath);
|
|
645
|
-
} catch {
|
|
646
|
-
// dangling symlink — definitely not a usable install
|
|
647
|
-
return true;
|
|
648
|
-
}
|
|
649
|
-
const sep = process.platform === "win32" ? "\\" : "/";
|
|
650
|
-
return (
|
|
651
|
-
real.includes(`${sep}_npx${sep}`) ||
|
|
652
|
-
binPath.includes(`${sep}_npx${sep}`) ||
|
|
653
|
-
real.startsWith(realpathSafe(pkgRoot))
|
|
654
|
-
);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function realpathSafe(p) {
|
|
658
|
-
try {
|
|
659
|
-
return realpathSync(p);
|
|
660
|
-
} catch {
|
|
661
|
-
return p;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
612
|
function linkGlobalCli() {
|
|
666
613
|
const pkgRoot = join(__dirname, "..");
|
|
667
614
|
let version = null;
|
|
@@ -686,6 +633,21 @@ function linkGlobalCli() {
|
|
|
686
633
|
}
|
|
687
634
|
}
|
|
688
635
|
|
|
636
|
+
// Preflight: detect the two environment breakages PROACTIVELY instead of
|
|
637
|
+
// letting `npm install -g` fail with a buried stderr.
|
|
638
|
+
const prefix = npmGlobalPrefix();
|
|
639
|
+
if (npmCacheOwnedByOther()) {
|
|
640
|
+
warn("your ~/.npm cache contains files owned by another user (an old `sudo npm`).");
|
|
641
|
+
warn(`This breaks npx AND npm i -g. Fix it once, then re-run: ${CACHE_CHOWN_RECIPE}`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (prefix && !globalPrefixWritable(prefix)) {
|
|
645
|
+
warn(`npm's global prefix (${prefix}) isn't writable — npm i -g would fail with EACCES.`);
|
|
646
|
+
warn("Use a user-level prefix (the official npm fix):");
|
|
647
|
+
for (const line of USER_PREFIX_RECIPE) warn(` ${line}`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
689
651
|
const attempts = version ? [`agentic-relay@${version}`, `"${pkgRoot}"`] : [`"${pkgRoot}"`];
|
|
690
652
|
let lastErr = null;
|
|
691
653
|
let installedFrom = null;
|
|
@@ -702,31 +664,24 @@ function linkGlobalCli() {
|
|
|
702
664
|
const stderr = String((lastErr && lastErr.stderr) || "");
|
|
703
665
|
const detail =
|
|
704
666
|
stderr.split("\n").find((l) => /EACCES|EEXIST|E404|ERR!|error/i.test(l)) || "";
|
|
705
|
-
warn(
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
667
|
+
warn(`could not auto-link the global CLI${detail ? ` — ${detail.trim()}` : ""}. Run once: npm i -g agentic-relay`);
|
|
668
|
+
if (/EACCES/.test(stderr)) {
|
|
669
|
+
warn("EACCES — use a user-level prefix (the official npm fix):");
|
|
670
|
+
for (const line of USER_PREFIX_RECIPE) warn(` ${line}`);
|
|
671
|
+
}
|
|
710
672
|
return;
|
|
711
673
|
}
|
|
712
674
|
|
|
713
675
|
// Verify the global bin actually landed. Don't probe the bare command here:
|
|
714
676
|
// in this process it resolves to the ephemeral npx shim (which shadows the
|
|
715
677
|
// global install), so check the file in npm's global prefix directly.
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const prefix = execSync("npm prefix -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
720
|
-
binDir = process.platform === "win32" ? prefix : join(prefix, "bin");
|
|
721
|
-
const cand = join(binDir, process.platform === "win32" ? "agentrelay.cmd" : "agentrelay");
|
|
722
|
-
if (existsSync(cand)) globalBin = cand;
|
|
723
|
-
} catch {
|
|
724
|
-
// can't resolve the prefix — fall through to the warn
|
|
725
|
-
}
|
|
726
|
-
if (globalBin) {
|
|
678
|
+
const binDir = npmGlobalBinDir(prefix);
|
|
679
|
+
const shim = globalBinShimPath(prefix, "agentrelay");
|
|
680
|
+
if (shim && existsSync(shim)) {
|
|
727
681
|
success(`linked global \`agentrelay\` CLI (${installedFrom})`);
|
|
728
|
-
|
|
729
|
-
|
|
682
|
+
if (!dirOnPath(binDir)) {
|
|
683
|
+
warn(`note: ${binDir} doesn't appear to be on your PATH — add it to use \`agentrelay\` directly`);
|
|
684
|
+
}
|
|
730
685
|
} else {
|
|
731
686
|
warn(`installed, but no global \`agentrelay\` found in ${binDir || "npm's global bin directory"} — run once: npm i -g agentic-relay`);
|
|
732
687
|
}
|
|
@@ -739,6 +694,13 @@ async function setupApiKeyInteractive() {
|
|
|
739
694
|
return true;
|
|
740
695
|
}
|
|
741
696
|
|
|
697
|
+
// Scripted/CI runs have no TTY — don't hang on a prompt nobody will answer.
|
|
698
|
+
if (!stdin.isTTY) {
|
|
699
|
+
warn("no API key configured and stdin is not a terminal — skipping the prompt.");
|
|
700
|
+
warn("Pass --api-key=<am_live_...> or write ~/.config/agent-relay/credentials.json");
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
|
|
742
704
|
console.log("");
|
|
743
705
|
log("API key setup");
|
|
744
706
|
console.log("");
|
package/bin/lib/cache.mjs
CHANGED
|
@@ -69,24 +69,18 @@ export class Cache {
|
|
|
69
69
|
return join(this.searchDir, `${h}.json`);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// The buyer-side spend policy snapshot the search API returns alongside the
|
|
81
|
-
// menu. Cached with the search so a cache hit still hands the agent a policy
|
|
82
|
-
// to decide auto-pay-vs-bubble against (see SKILL.md Pay). Null on older
|
|
83
|
-
// cache files written before this field existed.
|
|
84
|
-
readSearchPaymentContext(query) {
|
|
72
|
+
/**
|
|
73
|
+
* Whole unexpired search entry — `{ts, capabilities, payment_context}` — or
|
|
74
|
+
* null. Returning the entry (not just capabilities) lets the caller surface
|
|
75
|
+
* how stale the cached payment_context snapshot is (see SKILL.md Pay).
|
|
76
|
+
*/
|
|
77
|
+
readSearchEntry(query) {
|
|
85
78
|
if (!this.enabled) return null;
|
|
86
79
|
const entry = this._readJson(this._searchPath(query));
|
|
87
80
|
if (!entry) return null;
|
|
88
81
|
if (Date.now() > entry.expires) return null;
|
|
89
|
-
|
|
82
|
+
if (!entry.capabilities) return null;
|
|
83
|
+
return entry;
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
writeSearch(query, capabilities, paymentContext = null) {
|
package/bin/lib/commands.mjs
CHANGED
|
@@ -58,9 +58,14 @@ export async function cmdSearch(query, opts) {
|
|
|
58
58
|
// The buyer-side spend policy snapshot the search API returns alongside the
|
|
59
59
|
// menu — the agent decides auto-pay-vs-bubble against it (SKILL.md Pay). Pass
|
|
60
60
|
// it through here so it survives to the CLI's JSON output and gets cached.
|
|
61
|
-
|
|
61
|
+
// On a cache hit the snapshot can be up to the search TTL (1h) stale, so the
|
|
62
|
+
// payload carries from_cache/cached_at for the caller's pay decision.
|
|
63
|
+
const cachedEntry = opts.refresh ? null : cache.readSearchEntry(query);
|
|
64
|
+
let paymentContext = cachedEntry ? (cachedEntry.payment_context ?? null) : null;
|
|
65
|
+
let capabilities = cachedEntry ? cachedEntry.capabilities : null;
|
|
66
|
+
const fromCache = Boolean(cachedEntry);
|
|
67
|
+
const cachedAt = cachedEntry ? (cachedEntry.ts ?? null) : null;
|
|
62
68
|
|
|
63
|
-
let capabilities = opts.refresh ? null : cache.readSearch(query);
|
|
64
69
|
if (!capabilities) {
|
|
65
70
|
const data = await apiSearch(query, {
|
|
66
71
|
apiKey: opts.apiKey,
|
|
@@ -90,7 +95,14 @@ export async function cmdSearch(query, opts) {
|
|
|
90
95
|
}
|
|
91
96
|
capabilities = capabilities.slice(0, maxResults);
|
|
92
97
|
|
|
93
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
query,
|
|
100
|
+
count: capabilities.length,
|
|
101
|
+
from_cache: fromCache,
|
|
102
|
+
cached_at: cachedAt,
|
|
103
|
+
capabilities,
|
|
104
|
+
payment_context: paymentContext,
|
|
105
|
+
};
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
/**
|
|
@@ -183,17 +195,20 @@ export function shapeSessionTurn(r) {
|
|
|
183
195
|
|
|
184
196
|
/**
|
|
185
197
|
* Map a `delivery` object (from a paid session turn) to cmdFetch args. Tolerant of
|
|
186
|
-
* minor key variants so it survives small upstream shape differences.
|
|
198
|
+
* minor key variants so it survives small upstream shape differences. The dest
|
|
199
|
+
* defaults to ./<delivery.slug>/ (slugified — a hostile slug can't traverse);
|
|
200
|
+
* an explicit dest always wins.
|
|
187
201
|
*/
|
|
188
202
|
export function deliveryToFetchArgs(d, { dest = null } = {}) {
|
|
189
203
|
if (!d || typeof d !== "object") return null;
|
|
190
204
|
const url = d.download_url || d.url || null;
|
|
191
205
|
if (!url) return null;
|
|
206
|
+
const slug = d.slug ? slugify(String(d.slug)) : null;
|
|
192
207
|
return {
|
|
193
208
|
url,
|
|
194
209
|
checksum: d.checksum_sha256 || d.checksum || null,
|
|
195
210
|
filename: d.filename || null,
|
|
196
|
-
dest,
|
|
211
|
+
dest: dest || (slug ? `./${slug}` : null),
|
|
197
212
|
};
|
|
198
213
|
}
|
|
199
214
|
|
|
@@ -419,11 +434,24 @@ export function cmdCache(sub, slug, opts) {
|
|
|
419
434
|
}
|
|
420
435
|
deliverable.slug = deliverable.slug || slug;
|
|
421
436
|
deliverable.ts = deliverable.ts || new Date().toISOString();
|
|
437
|
+
// Soft contract check (schema/deliverable.json spine). Warn-only: a spec
|
|
438
|
+
// with gaps is still worth banking, and concurrent breadth subagents must
|
|
439
|
+
// never be blocked by validation.
|
|
440
|
+
const warnings = [];
|
|
441
|
+
if (!deliverable.session_id || !String(deliverable.session_id).trim()) {
|
|
442
|
+
warnings.push("no session_id — build-outcome feedback can't be filed against this consult later");
|
|
443
|
+
}
|
|
444
|
+
if (!deliverable.summary || !String(deliverable.summary).trim()) {
|
|
445
|
+
warnings.push("no summary — `cache ls` triage will show a blank line for this spec");
|
|
446
|
+
}
|
|
447
|
+
if (!Array.isArray(deliverable.features) || deliverable.features.length === 0) {
|
|
448
|
+
warnings.push("no features[] — build-time has no surface map; depth sessions will have to rediscover it");
|
|
449
|
+
}
|
|
422
450
|
// Write ONLY the per-slug spec file — no shared index.json mutation — so N
|
|
423
451
|
// breadth subagents can `cache put` concurrently without clobbering each
|
|
424
452
|
// other. `cache ls` reads the spec files directly.
|
|
425
453
|
cache.writeSpec(slug, deliverable);
|
|
426
|
-
return { stored: true, slug, cache_dir: opts.cacheDir };
|
|
454
|
+
return { stored: true, slug, cache_dir: opts.cacheDir, warnings };
|
|
427
455
|
}
|
|
428
456
|
if (sub === "clear") {
|
|
429
457
|
cache.clear();
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentrelay doctor` — diagnose the install/runtime environment.
|
|
3
|
+
*
|
|
4
|
+
* Every check maps to a failure mode seen in the field: stale global versions,
|
|
5
|
+
* EACCES on the npm prefix, root-owned ~/.npm (kills npx AND npm i -g), the
|
|
6
|
+
* global bin dir missing from PATH, a dead/misconfigured API key. Output is
|
|
7
|
+
* one line per check (✓ ok / ! warn / ✗ fail) or a --json envelope; exit 1
|
|
8
|
+
* only when a required check fails.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { resolveApiKey, CREDENTIALS_PATH, LEGACY_CREDENTIALS_PATH } from "./config.mjs";
|
|
15
|
+
import { search as apiSearch } from "./api.mjs";
|
|
16
|
+
import { compareSemver } from "./update-check.mjs";
|
|
17
|
+
import {
|
|
18
|
+
npmGlobalPrefix,
|
|
19
|
+
npmGlobalBinDir,
|
|
20
|
+
globalBinShimPath,
|
|
21
|
+
globalPrefixWritable,
|
|
22
|
+
npmCacheOwnedByOther,
|
|
23
|
+
dirOnPath,
|
|
24
|
+
isDirWritable,
|
|
25
|
+
USER_PREFIX_RECIPE,
|
|
26
|
+
CACHE_CHOWN_RECIPE,
|
|
27
|
+
} from "./sysenv.mjs";
|
|
28
|
+
|
|
29
|
+
export const MIN_NODE_MAJOR = 18;
|
|
30
|
+
|
|
31
|
+
/** Pure: ok/fail verdict for a Node version string. */
|
|
32
|
+
export function checkNodeVersion(versionString) {
|
|
33
|
+
const major = Number(String(versionString).split(".")[0]);
|
|
34
|
+
return {
|
|
35
|
+
id: "node",
|
|
36
|
+
label: "Node.js version",
|
|
37
|
+
status: major >= MIN_NODE_MAJOR ? "ok" : "fail",
|
|
38
|
+
detail:
|
|
39
|
+
major >= MIN_NODE_MAJOR
|
|
40
|
+
? `v${versionString}`
|
|
41
|
+
: `v${versionString} — agentrelay needs Node >= ${MIN_NODE_MAJOR}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pure: aggregate checks → overall ok + exit code. */
|
|
46
|
+
export function summarize(checks) {
|
|
47
|
+
const failed = checks.filter((c) => c.status === "fail");
|
|
48
|
+
return { ok: failed.length === 0, exitCode: failed.length === 0 ? 0 : 1 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchLatestVersion(timeoutMs = 3000) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch("https://registry.npmjs.org/agentic-relay/latest", {
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) return null;
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
return data.version || null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function apiKeySource(flagKey) {
|
|
69
|
+
if (flagKey && flagKey.trim()) return "--api-key flag";
|
|
70
|
+
if (process.env.AGENT_RELAY_API_KEY?.trim()) return "$AGENT_RELAY_API_KEY";
|
|
71
|
+
try {
|
|
72
|
+
const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
|
|
73
|
+
if (creds.api_key) return CREDENTIALS_PATH;
|
|
74
|
+
} catch {
|
|
75
|
+
/* fall through */
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const creds = JSON.parse(readFileSync(LEGACY_CREDENTIALS_PATH, "utf-8"));
|
|
79
|
+
if (creds.api_key) return `${LEGACY_CREDENTIALS_PATH} (legacy)`;
|
|
80
|
+
} catch {
|
|
81
|
+
/* fall through */
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function cmdDoctor(opts) {
|
|
87
|
+
const checks = [];
|
|
88
|
+
|
|
89
|
+
// 1. Node version
|
|
90
|
+
checks.push(checkNodeVersion(process.versions.node));
|
|
91
|
+
|
|
92
|
+
// 2. CLI version vs registry
|
|
93
|
+
let currentVersion = "unknown";
|
|
94
|
+
try {
|
|
95
|
+
currentVersion = JSON.parse(
|
|
96
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
|
97
|
+
).version;
|
|
98
|
+
} catch {
|
|
99
|
+
/* keep "unknown" */
|
|
100
|
+
}
|
|
101
|
+
const latest = await fetchLatestVersion();
|
|
102
|
+
const behind = latest !== null && compareSemver(latest, currentVersion) > 0;
|
|
103
|
+
checks.push({
|
|
104
|
+
id: "version",
|
|
105
|
+
label: "CLI version",
|
|
106
|
+
status: latest === null ? "warn" : behind ? "warn" : "ok",
|
|
107
|
+
detail:
|
|
108
|
+
latest === null
|
|
109
|
+
? `${currentVersion} (couldn't reach the npm registry to compare)`
|
|
110
|
+
: behind
|
|
111
|
+
? `${currentVersion} — ${latest} is available: npm i -g agentic-relay`
|
|
112
|
+
: `${currentVersion} (latest is ${latest})`,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// 3. API key presence
|
|
116
|
+
const key = resolveApiKey(opts.apiKey || null);
|
|
117
|
+
const source = key ? apiKeySource(opts.apiKey || null) : null;
|
|
118
|
+
checks.push({
|
|
119
|
+
id: "api_key",
|
|
120
|
+
label: "API key",
|
|
121
|
+
status: key ? "ok" : "warn",
|
|
122
|
+
detail: key
|
|
123
|
+
? `configured via ${source}`
|
|
124
|
+
: `not found — set --api-key, $AGENT_RELAY_API_KEY, or ${CREDENTIALS_PATH}`,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// 4. API key live verification (skipped without a key)
|
|
128
|
+
if (key) {
|
|
129
|
+
try {
|
|
130
|
+
await apiSearch("doctor connectivity check", {
|
|
131
|
+
apiKey: key,
|
|
132
|
+
maxResults: 1,
|
|
133
|
+
timeoutMs: Math.min(opts.timeoutMs || 10000, 10000),
|
|
134
|
+
});
|
|
135
|
+
checks.push({ id: "api", label: "API connectivity", status: "ok", detail: "search responds" });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
checks.push({
|
|
138
|
+
id: "api",
|
|
139
|
+
label: "API connectivity",
|
|
140
|
+
status: "fail",
|
|
141
|
+
detail: `search failed: ${err.message}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
checks.push({ id: "api", label: "API connectivity", status: "warn", detail: "skipped (no key)" });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 5. npm global prefix + writability
|
|
149
|
+
const prefix = npmGlobalPrefix();
|
|
150
|
+
if (!prefix) {
|
|
151
|
+
checks.push({ id: "prefix", label: "npm global prefix", status: "warn", detail: "npm not found on PATH" });
|
|
152
|
+
} else if (globalPrefixWritable(prefix)) {
|
|
153
|
+
checks.push({ id: "prefix", label: "npm global prefix", status: "ok", detail: `${prefix} (writable)` });
|
|
154
|
+
} else {
|
|
155
|
+
checks.push({
|
|
156
|
+
id: "prefix",
|
|
157
|
+
label: "npm global prefix",
|
|
158
|
+
status: "warn",
|
|
159
|
+
detail: `${prefix} is not writable — npm i -g will EACCES. Fix: ${USER_PREFIX_RECIPE.join("; ")}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 6. ~/.npm cache ownership
|
|
164
|
+
checks.push(
|
|
165
|
+
npmCacheOwnedByOther()
|
|
166
|
+
? {
|
|
167
|
+
id: "npm_cache",
|
|
168
|
+
label: "npm cache (~/.npm)",
|
|
169
|
+
status: "warn",
|
|
170
|
+
detail: `owned by another user — breaks npx AND npm i -g. Fix: ${CACHE_CHOWN_RECIPE}`,
|
|
171
|
+
}
|
|
172
|
+
: { id: "npm_cache", label: "npm cache (~/.npm)", status: "ok", detail: "owned by you" },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// 7 + 8. global bin shim exists + its dir is on PATH
|
|
176
|
+
const shim = globalBinShimPath(prefix, "agentrelay");
|
|
177
|
+
const binDir = npmGlobalBinDir(prefix);
|
|
178
|
+
const shimExists = Boolean(shim) && existsSync(shim);
|
|
179
|
+
checks.push({
|
|
180
|
+
id: "global_bin",
|
|
181
|
+
label: "global `agentrelay` bin",
|
|
182
|
+
status: shimExists ? "ok" : "warn",
|
|
183
|
+
detail: shimExists ? shim : `not found in ${binDir || "npm's global bin dir"} — run: npm i -g agentic-relay`,
|
|
184
|
+
});
|
|
185
|
+
checks.push({
|
|
186
|
+
id: "path",
|
|
187
|
+
label: "global bin dir on PATH",
|
|
188
|
+
status: dirOnPath(binDir) ? "ok" : "warn",
|
|
189
|
+
detail: dirOnPath(binDir)
|
|
190
|
+
? binDir
|
|
191
|
+
: `${binDir || "npm's global bin dir"} is not on PATH — add it to your shell profile`,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// 9. working directory writable (cache + deliverables land here)
|
|
195
|
+
checks.push({
|
|
196
|
+
id: "cwd",
|
|
197
|
+
label: "working dir writable",
|
|
198
|
+
status: isDirWritable(process.cwd()) ? "ok" : "warn",
|
|
199
|
+
detail: process.cwd(),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// 10. zip capability (informational — pure-JS, no system unzip needed)
|
|
203
|
+
checks.push({
|
|
204
|
+
id: "zip",
|
|
205
|
+
label: "deliverable unzip",
|
|
206
|
+
status: "ok",
|
|
207
|
+
detail: "pure-JS extractor (no system `unzip` required)",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const { ok, exitCode } = summarize(checks);
|
|
211
|
+
return { checks, doctor_ok: ok, exit_code: exitCode, config_dir: join(homedir(), ".config", "agent-relay") };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const ICONS = { ok: "✓", warn: "!", fail: "✗" };
|
|
215
|
+
|
|
216
|
+
export function fmtDoctor(envelope) {
|
|
217
|
+
const lines = (envelope.checks || []).map(
|
|
218
|
+
(c) => `${ICONS[c.status] || "?"} ${c.label.padEnd(26)} ${c.detail}`,
|
|
219
|
+
);
|
|
220
|
+
lines.push(envelope.doctor_ok ? "doctor: no blocking problems found" : "doctor: blocking problems found (✗)");
|
|
221
|
+
return lines.join("\n");
|
|
222
|
+
}
|