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 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
@@ -21,6 +21,7 @@ const CLI_COMMANDS = new Set([
21
21
  "budget",
22
22
  "connect-wallet",
23
23
  "fetch",
24
+ "doctor",
24
25
  "version",
25
26
  "help",
26
27
  // Removed commands — still routed to the CLI core so they fail with a clear
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, realpathSync } from "fs";
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 args = process.argv.slice(2);
270
- const flags = {
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 command resolves afterwards
627
- * so a PATH mismatch is reported, not discovered later as `command not found`.
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
- `could not auto-link the global CLI${detail ? ` — ${detail.trim()}` : ""}.` +
707
- ` Run once: npm i -g agentic-relay` +
708
- (/EACCES/.test(stderr) ? " (npm's global prefix isn't writable — fix permissions or use a user-level prefix)" : ""),
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
- let binDir = null;
717
- let globalBin = null;
718
- try {
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
- const onPath = (process.env.PATH || "").split(process.platform === "win32" ? ";" : ":").includes(binDir);
729
- if (!onPath) warn(`note: ${binDir} doesn't appear to be on your PATH — add it to use \`agentrelay\` directly`);
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
- readSearch(query) {
73
- if (!this.enabled) return null;
74
- const entry = this._readJson(this._searchPath(query));
75
- if (!entry) return null;
76
- if (Date.now() > entry.expires) return null;
77
- return entry.capabilities || null;
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
- return entry.payment_context || null;
82
+ if (!entry.capabilities) return null;
83
+ return entry;
90
84
  }
91
85
 
92
86
  writeSearch(query, capabilities, paymentContext = null) {
@@ -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
- let paymentContext = opts.refresh ? null : cache.readSearchPaymentContext(query);
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 { query, count: capabilities.length, capabilities, payment_context: paymentContext };
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
+ }