agentic-relay 3.8.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,887 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Agent Relay Installer v3 — multi-agent (skill, MCP, deeplink modes)
5
+ *
6
+ * Usage:
7
+ *
8
+ * Interactive (human):
9
+ * npx agentic-relay
10
+ *
11
+ * Non-interactive (AI agent / scripted):
12
+ * npx agentic-relay --all --api-key=am_live_xxx
13
+ * npx agentic-relay --target=claude,codex,gemini --api-key=am_live_xxx
14
+ * npx agentic-relay --target=cursor,boltai
15
+ *
16
+ * Flags:
17
+ * --target=a,b,c Comma-separated list of agent IDs to install (see AGENTS map)
18
+ * --all Install for every Tier 1 + Tier 2 agent (skips manual)
19
+ * --project Also install project-scoped configs (Roo Code) when in a repo
20
+ * --api-key=KEY Set API key non-interactively
21
+ * --librechat=PATH Path to librechat.yaml (required for LibreChat install)
22
+ *
23
+ * Modes:
24
+ * skill Drop SKILL.md + (if supported) append CLAUDE.md/AGENTS.md
25
+ * mcp_json Idempotent JSON merge of agentrelay entry under {mcpServers|servers}
26
+ * mcp_yaml Idempotent YAML block merge under mcpServers:
27
+ * mcp_witsy Bespoke nested merge inside Witsy's settings.json
28
+ * project_roo Workspace-scoped .roo/mcp.json + .roo/rules/agent-relay.md (needs --project + git repo)
29
+ * deeplink Print URL the agent registers (tome://, raycast://) — user clicks Install
30
+ */
31
+
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
33
+ import { homedir } from "os";
34
+ import { join, dirname } from "path";
35
+ import { fileURLToPath } from "url";
36
+ import { createInterface } from "readline/promises";
37
+ import { stdin, stdout } from "process";
38
+ import { execSync } from "child_process";
39
+
40
+ const __dirname = dirname(fileURLToPath(import.meta.url));
41
+ const HOME = homedir();
42
+
43
+ const SECTION_START = "# Agent Relay MCP Server";
44
+ const CREDENTIALS_PATH = join(HOME, ".config", "agent-relay", "credentials.json");
45
+ const LEGACY_CREDENTIALS_PATH = join(HOME, ".config", "penguin", "credentials.json");
46
+ const SIGNUP_URL = "https://attentionmarket-auth.vercel.app";
47
+ const API_BASE =
48
+ "https://peruwnbrqkvmrldhpoom.supabase.co/functions/v1/api-v1";
49
+ const MCP_URL = "https://peruwnbrqkvmrldhpoom.supabase.co/functions/v1/mcp";
50
+ const SERVER_NAME = "agent-relay";
51
+
52
+ // ═══════════════════════════════════════════════════════════════════════
53
+ // Agent matrix
54
+ // ═══════════════════════════════════════════════════════════════════════
55
+
56
+ // Mode legend:
57
+ // skill — drop SKILL.md + optionally append CLAUDE.md content to instructions
58
+ // mcp_json — idempotent merge of agentrelay entry into a JSON config
59
+ // mcp_yaml — idempotent merge into a YAML config (LibreChat)
60
+ // mcp_witsy — Witsy-specific nested merge
61
+ // project_roo — workspace-scoped Roo Code (only with --project flag inside a repo)
62
+ // deeplink — print URL, optionally `open` it on macOS
63
+
64
+ const AGENTS = {
65
+ // ── Tier 1A: skill mode ──────────────────────────────────────────
66
+ claude: {
67
+ mode: "skill",
68
+ label: "Claude Code",
69
+ skillDir: join(HOME, ".claude", "skills", "agent-relay"),
70
+ instructionsPath: join(HOME, ".claude", "CLAUDE.md"),
71
+ detectPath: join(HOME, ".claude"),
72
+ },
73
+ codex: {
74
+ mode: "skill",
75
+ label: "Codex CLI",
76
+ skillDir: join(HOME, ".codex", "skills", "agent-relay"),
77
+ instructionsPath: join(HOME, ".codex", "AGENTS.md"),
78
+ detectPath: join(HOME, ".codex"),
79
+ },
80
+ gemini: {
81
+ mode: "skill",
82
+ label: "Gemini CLI",
83
+ skillDir: join(HOME, ".gemini", "skills", "agent-relay"),
84
+ instructionsPath: join(HOME, ".gemini", "GEMINI.md"),
85
+ detectPath: join(HOME, ".gemini"),
86
+ },
87
+ goose: {
88
+ mode: "skill",
89
+ label: "Goose",
90
+ skillDir: join(HOME, ".config", "goose", "skills", "agent-relay"),
91
+ instructionsPath: null,
92
+ detectPath: join(HOME, ".config", "goose"),
93
+ },
94
+ windsurf: {
95
+ mode: "skill",
96
+ label: "Windsurf",
97
+ skillDir: join(HOME, ".codeium", "windsurf", "skills", "agent-relay"),
98
+ instructionsPath: null,
99
+ detectPath: join(HOME, ".codeium", "windsurf"),
100
+ },
101
+ openclaw: {
102
+ mode: "skill",
103
+ label: "OpenClaw",
104
+ skillDir: join(HOME, ".openclaw", "skills", "agent-relay"),
105
+ instructionsPath: null,
106
+ detectPath: join(HOME, ".openclaw"),
107
+ },
108
+
109
+ // ── Tier 1B: MCP JSON mode ───────────────────────────────────────
110
+ cursor: {
111
+ mode: "mcp_json",
112
+ label: "Cursor",
113
+ configPath: join(HOME, ".cursor", "mcp.json"),
114
+ schemaKey: "mcpServers",
115
+ detectPath: join(HOME, ".cursor"),
116
+ },
117
+ claude_desktop: {
118
+ mode: "mcp_json",
119
+ label: "Claude Desktop",
120
+ configPath: join(
121
+ HOME,
122
+ "Library",
123
+ "Application Support",
124
+ "Claude",
125
+ "claude_desktop_config.json",
126
+ ),
127
+ schemaKey: "mcpServers",
128
+ detectPath: join(HOME, "Library", "Application Support", "Claude"),
129
+ },
130
+ vscode: {
131
+ mode: "mcp_json",
132
+ label: "VS Code",
133
+ configPath: join(
134
+ HOME,
135
+ "Library",
136
+ "Application Support",
137
+ "Code",
138
+ "User",
139
+ "mcp.json",
140
+ ),
141
+ schemaKey: "servers", // NOTE: VS Code uses "servers" not "mcpServers"
142
+ detectPath: join(HOME, "Library", "Application Support", "Code"),
143
+ },
144
+ vscode_insiders: {
145
+ mode: "mcp_json",
146
+ label: "VS Code Insiders",
147
+ configPath: join(
148
+ HOME,
149
+ "Library",
150
+ "Application Support",
151
+ "Code - Insiders",
152
+ "User",
153
+ "mcp.json",
154
+ ),
155
+ schemaKey: "servers",
156
+ detectPath: join(HOME, "Library", "Application Support", "Code - Insiders"),
157
+ },
158
+ cline: {
159
+ mode: "mcp_json",
160
+ label: "Cline",
161
+ configPath: join(
162
+ HOME,
163
+ "Library",
164
+ "Application Support",
165
+ "Code",
166
+ "User",
167
+ "globalStorage",
168
+ "saoudrizwan.claude-dev",
169
+ "settings",
170
+ "cline_mcp_settings.json",
171
+ ),
172
+ schemaKey: "mcpServers",
173
+ detectPath: join(
174
+ HOME,
175
+ "Library",
176
+ "Application Support",
177
+ "Code",
178
+ "User",
179
+ "globalStorage",
180
+ "saoudrizwan.claude-dev",
181
+ ),
182
+ },
183
+ boltai: {
184
+ mode: "mcp_json",
185
+ label: "BoltAI",
186
+ configPath: join(HOME, ".boltai", "mcp.json"),
187
+ schemaKey: "mcpServers",
188
+ detectPath: join(HOME, ".boltai"),
189
+ },
190
+ amazon_q_cli: {
191
+ mode: "mcp_json",
192
+ label: "Amazon Q (CLI)",
193
+ configPath: join(HOME, ".aws", "amazonq", "mcp.json"),
194
+ schemaKey: "mcpServers",
195
+ detectPath: join(HOME, ".aws", "amazonq"),
196
+ },
197
+ amazon_q_ide: {
198
+ mode: "mcp_json",
199
+ label: "Amazon Q (IDE)",
200
+ configPath: join(HOME, ".aws", "amazonq", "default.json"),
201
+ schemaKey: "mcpServers",
202
+ detectPath: join(HOME, ".aws", "amazonq"),
203
+ },
204
+
205
+ // ── Bespoke writers ───────────────────────────────────────────────
206
+ witsy: {
207
+ mode: "mcp_witsy",
208
+ label: "Witsy",
209
+ configPath: join(HOME, "Library", "Application Support", "Witsy", "settings.json"),
210
+ detectPath: join(HOME, "Library", "Application Support", "Witsy"),
211
+ },
212
+ librechat: {
213
+ mode: "mcp_yaml",
214
+ label: "LibreChat",
215
+ configPath: null, // user-supplied via --librechat=path
216
+ schemaKey: "mcpServers",
217
+ detectPath: null,
218
+ },
219
+
220
+ // ── Tier 1C: project-scoped (requires --project + git repo) ──────
221
+ roo_code: {
222
+ mode: "project_roo",
223
+ label: "Roo Code (project)",
224
+ projectScoped: true,
225
+ detectPath: null,
226
+ },
227
+
228
+ // ── Tier 2: deeplink ─────────────────────────────────────────────
229
+ tome: {
230
+ mode: "deeplink",
231
+ label: "Tome",
232
+ deeplink: () =>
233
+ `tome://mcp/install?config=${encodeURIComponent(
234
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: { url: MCP_URL } } }),
235
+ )}`,
236
+ detectPath: join(HOME, "Library", "Application Support", "Tome"),
237
+ },
238
+ raycast: {
239
+ mode: "deeplink",
240
+ label: "Raycast",
241
+ deeplink: () =>
242
+ // Raycast MCP extension URL — users install/configure via the extension UI.
243
+ `raycast://extensions/EvanZhouDev/mcp/manage-mcp`,
244
+ detectPath: join(HOME, "Library", "Application Support", "com.raycast.macos"),
245
+ },
246
+ };
247
+
248
+ // Tier 3 (manual) targets are intentionally not in this build per the plan.
249
+ // Will be added in a follow-up release.
250
+
251
+ // ═══════════════════════════════════════════════════════════════════════
252
+ // Logging helpers
253
+ // ═══════════════════════════════════════════════════════════════════════
254
+
255
+ const log = (m) => console.log(`\x1b[36m[agent-relay]\x1b[0m ${m}`);
256
+ const success = (m) => console.log(`\x1b[32m[agent-relay]\x1b[0m ${m}`);
257
+ const warn = (m) => console.log(`\x1b[33m[agent-relay]\x1b[0m ${m}`);
258
+ const error = (m) => console.error(`\x1b[31m[agent-relay]\x1b[0m ${m}`);
259
+
260
+ function escapeRegex(str) {
261
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
262
+ }
263
+
264
+ // ═══════════════════════════════════════════════════════════════════════
265
+ // CLI args
266
+ // ═══════════════════════════════════════════════════════════════════════
267
+
268
+ 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
+
302
+ return flags;
303
+ }
304
+
305
+ // ═══════════════════════════════════════════════════════════════════════
306
+ // Skill installer
307
+ // ═══════════════════════════════════════════════════════════════════════
308
+
309
+ function installSkill(skillContent, instructionsContent, agent) {
310
+ try {
311
+ mkdirSync(agent.skillDir, { recursive: true });
312
+ writeFileSync(join(agent.skillDir, "SKILL.md"), skillContent);
313
+ success(`${agent.label}: skill → ${agent.skillDir}/SKILL.md`);
314
+ } catch (err) {
315
+ warn(`${agent.label}: could not write skill — ${err.message}`);
316
+ }
317
+
318
+ if (agent.instructionsPath) {
319
+ installInstructions(instructionsContent, agent.instructionsPath, agent.label);
320
+ }
321
+ }
322
+
323
+ function installInstructions(instructions, path, label) {
324
+ try {
325
+ const dir = dirname(path);
326
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
327
+
328
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
329
+
330
+ if (existing.includes(SECTION_START)) {
331
+ const sectionRegex = new RegExp(
332
+ `${escapeRegex(SECTION_START)}[\\s\\S]*?(?=\\n# (?!Agent Relay)|\\s*$)`,
333
+ );
334
+ writeFileSync(path, existing.replace(sectionRegex, instructions));
335
+ success(`${label}: instructions updated → ${path}`);
336
+ } else {
337
+ const sep = existing.length > 0 ? "\n\n" : "";
338
+ writeFileSync(path, existing + sep + instructions + "\n");
339
+ success(`${label}: instructions added → ${path}`);
340
+ }
341
+ } catch (err) {
342
+ warn(`${label}: could not write instructions — ${err.message}`);
343
+ }
344
+ }
345
+
346
+ // ═══════════════════════════════════════════════════════════════════════
347
+ // MCP JSON installer (handles both `mcpServers` and `servers` keys)
348
+ // ═══════════════════════════════════════════════════════════════════════
349
+
350
+ function installMcpJson(agent) {
351
+ try {
352
+ const dir = dirname(agent.configPath);
353
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
354
+
355
+ let config = {};
356
+ if (existsSync(agent.configPath)) {
357
+ try {
358
+ const raw = readFileSync(agent.configPath, "utf-8").trim();
359
+ config = raw.length > 0 ? JSON.parse(raw) : {};
360
+ } catch (parseErr) {
361
+ warn(`${agent.label}: existing config is not valid JSON — overwriting (${parseErr.message})`);
362
+ config = {};
363
+ }
364
+ }
365
+
366
+ const key = agent.schemaKey;
367
+ if (!config[key] || typeof config[key] !== "object") {
368
+ config[key] = {};
369
+ }
370
+
371
+ // VS Code's "servers" entries support {type, url} format
372
+ // Other agents use {url} or {command, args}
373
+ if (key === "servers") {
374
+ config[key][SERVER_NAME] = { type: "http", url: MCP_URL };
375
+ } else {
376
+ config[key][SERVER_NAME] = { url: MCP_URL };
377
+ }
378
+
379
+ writeFileSync(agent.configPath, JSON.stringify(config, null, 2) + "\n");
380
+ success(`${agent.label}: MCP server registered → ${agent.configPath}`);
381
+ } catch (err) {
382
+ warn(`${agent.label}: could not write MCP config — ${err.message}`);
383
+ }
384
+ }
385
+
386
+ // ═══════════════════════════════════════════════════════════════════════
387
+ // Witsy bespoke installer — settings.json has nested mcp config
388
+ // ═══════════════════════════════════════════════════════════════════════
389
+
390
+ function installWitsy(agent) {
391
+ try {
392
+ const dir = dirname(agent.configPath);
393
+ if (!existsSync(dir)) {
394
+ warn(`${agent.label}: not detected (skipping). Install Witsy and run again to configure.`);
395
+ return;
396
+ }
397
+
398
+ let config = {};
399
+ if (existsSync(agent.configPath)) {
400
+ try {
401
+ config = JSON.parse(readFileSync(agent.configPath, "utf-8"));
402
+ } catch (parseErr) {
403
+ warn(`${agent.label}: existing settings.json invalid — skipping (${parseErr.message})`);
404
+ return;
405
+ }
406
+ }
407
+
408
+ // Witsy stores MCP servers under config.mcp.servers as an array of {uuid, url, label, ...}
409
+ // We add a single agentrelay entry, replacing any previous one.
410
+ if (!config.mcp || typeof config.mcp !== "object") config.mcp = {};
411
+ if (!Array.isArray(config.mcp.servers)) config.mcp.servers = [];
412
+
413
+ // Remove any existing agentrelay entry by label match
414
+ config.mcp.servers = config.mcp.servers.filter(
415
+ (s) => !(s && (s.label === SERVER_NAME || s.uuid === SERVER_NAME)),
416
+ );
417
+
418
+ config.mcp.servers.push({
419
+ uuid: SERVER_NAME,
420
+ label: SERVER_NAME,
421
+ type: "http",
422
+ url: MCP_URL,
423
+ enabled: true,
424
+ });
425
+
426
+ writeFileSync(agent.configPath, JSON.stringify(config, null, 2) + "\n");
427
+ success(`${agent.label}: MCP server registered → ${agent.configPath}`);
428
+ } catch (err) {
429
+ warn(`${agent.label}: could not write Witsy config — ${err.message}`);
430
+ }
431
+ }
432
+
433
+ // ═══════════════════════════════════════════════════════════════════════
434
+ // LibreChat YAML installer (line-based block patcher, no YAML lib)
435
+ // ═══════════════════════════════════════════════════════════════════════
436
+
437
+ function installLibreChat(agent, librechatPath) {
438
+ if (!librechatPath) {
439
+ warn(`${agent.label}: needs --librechat=<path> flag pointing to your librechat.yaml`);
440
+ return;
441
+ }
442
+ if (!existsSync(librechatPath)) {
443
+ warn(`${agent.label}: librechat.yaml not found at ${librechatPath}`);
444
+ return;
445
+ }
446
+
447
+ try {
448
+ const yaml = readFileSync(librechatPath, "utf-8");
449
+ const lines = yaml.split("\n");
450
+
451
+ // Build the agentrelay block (2-space indent inside mcpServers:)
452
+ const relayBlock = [
453
+ ` ${SERVER_NAME}:`,
454
+ ` type: streamable-http`,
455
+ ` url: ${MCP_URL}`,
456
+ ];
457
+
458
+ // Find existing mcpServers: section
459
+ const mcpIdx = lines.findIndex((l) => /^mcpServers\s*:/.test(l));
460
+
461
+ if (mcpIdx === -1) {
462
+ // No mcpServers section — append a new one at the bottom
463
+ const trailing = yaml.endsWith("\n") ? "" : "\n";
464
+ writeFileSync(
465
+ librechatPath,
466
+ yaml + trailing + "\n" + "mcpServers:\n" + relayBlock.join("\n") + "\n",
467
+ );
468
+ success(`${agent.label}: mcpServers section added → ${librechatPath}`);
469
+ return;
470
+ }
471
+
472
+ // Find the existing agentrelay entry inside mcpServers
473
+ let entryStart = -1;
474
+ let entryEnd = lines.length;
475
+ for (let i = mcpIdx + 1; i < lines.length; i++) {
476
+ const line = lines[i];
477
+ if (/^\S/.test(line) && line.trim()) {
478
+ // hit a top-level key — end of mcpServers section
479
+ entryEnd = i;
480
+ break;
481
+ }
482
+ if (new RegExp(`^ ${SERVER_NAME}\\s*:`).test(line)) {
483
+ entryStart = i;
484
+ // find end of this entry (next 2-space key or top-level)
485
+ for (let j = i + 1; j < lines.length; j++) {
486
+ const ln = lines[j];
487
+ if (/^\S/.test(ln) && ln.trim()) {
488
+ entryEnd = j;
489
+ break;
490
+ }
491
+ if (/^ \S/.test(ln) && !/^ /.test(ln)) {
492
+ entryEnd = j;
493
+ break;
494
+ }
495
+ }
496
+ break;
497
+ }
498
+ }
499
+
500
+ let updated;
501
+ if (entryStart !== -1) {
502
+ // Replace existing agentrelay entry
503
+ updated = [
504
+ ...lines.slice(0, entryStart),
505
+ ...relayBlock,
506
+ ...lines.slice(entryEnd),
507
+ ].join("\n");
508
+ } else {
509
+ // Insert agentrelay entry at the start of mcpServers section
510
+ updated = [
511
+ ...lines.slice(0, mcpIdx + 1),
512
+ ...relayBlock,
513
+ ...lines.slice(mcpIdx + 1),
514
+ ].join("\n");
515
+ }
516
+
517
+ writeFileSync(librechatPath, updated);
518
+ success(`${agent.label}: agentrelay entry merged → ${librechatPath}`);
519
+ } catch (err) {
520
+ warn(`${agent.label}: could not patch librechat.yaml — ${err.message}`);
521
+ }
522
+ }
523
+
524
+ // ═══════════════════════════════════════════════════════════════════════
525
+ // Roo Code project installer (workspace-scoped)
526
+ // ═══════════════════════════════════════════════════════════════════════
527
+
528
+ function installRoo(agent, instructionsContent) {
529
+ let repoRoot;
530
+ try {
531
+ repoRoot = execSync("git rev-parse --show-toplevel", {
532
+ encoding: "utf-8",
533
+ stdio: ["ignore", "pipe", "ignore"],
534
+ }).trim();
535
+ } catch {
536
+ warn(`${agent.label}: not in a git repo. Run with --project from a repo root.`);
537
+ return;
538
+ }
539
+
540
+ const rooDir = join(repoRoot, ".roo");
541
+ const rulesDir = join(rooDir, "rules");
542
+ try {
543
+ mkdirSync(rulesDir, { recursive: true });
544
+
545
+ // Write .roo/mcp.json
546
+ const mcpPath = join(rooDir, "mcp.json");
547
+ let mcpConfig = {};
548
+ if (existsSync(mcpPath)) {
549
+ try { mcpConfig = JSON.parse(readFileSync(mcpPath, "utf-8")); } catch {}
550
+ }
551
+ if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
552
+ mcpConfig.mcpServers[SERVER_NAME] = { url: MCP_URL };
553
+ writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
554
+
555
+ // Write .roo/rules/agent-relay.md
556
+ const rulesPath = join(rulesDir, "agent-relay.md");
557
+ writeFileSync(rulesPath, instructionsContent + "\n");
558
+
559
+ success(`${agent.label}: project config written → ${rooDir}/`);
560
+ } catch (err) {
561
+ warn(`${agent.label}: could not write project config — ${err.message}`);
562
+ }
563
+ }
564
+
565
+ // ═══════════════════════════════════════════════════════════════════════
566
+ // Deeplink "installer"
567
+ // ═══════════════════════════════════════════════════════════════════════
568
+
569
+ function installDeeplink(agent) {
570
+ const url = agent.deeplink();
571
+ log(`${agent.label}: open this URL to install Agent Relay in the app:`);
572
+ console.log(` \x1b[4m${url}\x1b[0m`);
573
+
574
+ // Try to open it on macOS
575
+ if (process.platform === "darwin") {
576
+ try {
577
+ execSync(`open "${url}"`, { stdio: "ignore" });
578
+ success(`${agent.label}: opened ${agent.label} install URL`);
579
+ } catch {
580
+ // ignore — user can click the URL manually
581
+ }
582
+ }
583
+ }
584
+
585
+ // ═══════════════════════════════════════════════════════════════════════
586
+ // API key
587
+ // ═══════════════════════════════════════════════════════════════════════
588
+
589
+ function saveApiKey(key) {
590
+ mkdirSync(dirname(CREDENTIALS_PATH), { recursive: true });
591
+ writeFileSync(
592
+ CREDENTIALS_PATH,
593
+ JSON.stringify({ api_key: key }, null, 2) + "\n",
594
+ );
595
+ success(`API key saved → ${CREDENTIALS_PATH}`);
596
+ }
597
+
598
+ function getExistingKey() {
599
+ // Prefer the new path, but recognise a legacy ~/.config/penguin key too so an
600
+ // existing user isn't re-prompted (resolveApiKey applies the same fallback).
601
+ for (const path of [CREDENTIALS_PATH, LEGACY_CREDENTIALS_PATH]) {
602
+ if (!existsSync(path)) continue;
603
+ try {
604
+ const creds = JSON.parse(readFileSync(path, "utf-8"));
605
+ if (creds.api_key && creds.api_key.startsWith("am_live_")) return creds.api_key;
606
+ } catch {}
607
+ }
608
+ return null;
609
+ }
610
+
611
+ /**
612
+ * Link a bare global `agentrelay` command so users skip `npx` after install.
613
+ * Installs the exact package copy being run (works whether invoked via npx or a
614
+ * checkout). No-ops if `agentrelay` already resolves; warns + falls back to a
615
+ * manual instruction if the global prefix isn't writable (EACCES etc.).
616
+ */
617
+ function linkGlobalCli() {
618
+ try {
619
+ execSync("agentrelay version", { stdio: "ignore" });
620
+ success("`agentrelay` already on your PATH");
621
+ return;
622
+ } catch {
623
+ // not linked yet — proceed to install it
624
+ }
625
+ const pkgRoot = join(__dirname, "..");
626
+ try {
627
+ execSync(`npm install -g "${pkgRoot}"`, { stdio: "ignore" });
628
+ success("linked global `agentrelay` CLI");
629
+ } catch {
630
+ warn("could not auto-link the global CLI (permissions?). Run once: npm i -g agentic-relay");
631
+ }
632
+ }
633
+
634
+ async function setupApiKeyInteractive() {
635
+ const existing = getExistingKey();
636
+ if (existing) {
637
+ success(`API key already configured (${CREDENTIALS_PATH})`);
638
+ return true;
639
+ }
640
+
641
+ console.log("");
642
+ log("API key setup");
643
+ console.log("");
644
+ console.log(" Your API key starts with \x1b[1mam_live_\x1b[0m.");
645
+ console.log(
646
+ ` Don't have one? Sign up at \x1b[4m${SIGNUP_URL}\x1b[0m (Account tab).`,
647
+ );
648
+ console.log("");
649
+
650
+ const rl = createInterface({ input: stdin, output: stdout });
651
+ try {
652
+ for (;;) {
653
+ const key = (await rl.question(" Paste your API key: ")).trim();
654
+ if (key.startsWith("am_live_") && key.length > 20) {
655
+ saveApiKey(key);
656
+ return true;
657
+ }
658
+ if (key === "" || key.toLowerCase() === "skip") {
659
+ warn("Skipped API key setup. Agents will prompt you for it later.");
660
+ return false;
661
+ }
662
+ console.log(
663
+ ' Key should start with "am_live_". Try again, or type "skip" to set it up later.',
664
+ );
665
+ }
666
+ } finally {
667
+ rl.close();
668
+ }
669
+ }
670
+
671
+ // ═══════════════════════════════════════════════════════════════════════
672
+ // Verify
673
+ // ═══════════════════════════════════════════════════════════════════════
674
+
675
+ async function verifyInstall() {
676
+ const key = getExistingKey();
677
+ if (!key) return;
678
+
679
+ log("Verifying API key…");
680
+ try {
681
+ const resp = await fetch(`${API_BASE}/search`, {
682
+ method: "POST",
683
+ headers: {
684
+ "Content-Type": "application/json",
685
+ "X-AM-API-Key": key,
686
+ },
687
+ body: JSON.stringify({ query: "test", max_results: 1 }),
688
+ });
689
+ const data = await resp.json();
690
+ if (resp.ok && data.capabilities) {
691
+ success("API key verified — search is working");
692
+ } else {
693
+ warn(`API returned ${resp.status}: ${data.error || "unknown error"}`);
694
+ }
695
+ } catch (err) {
696
+ warn(`Verification failed: ${err.message}`);
697
+ }
698
+ }
699
+
700
+ // ═══════════════════════════════════════════════════════════════════════
701
+ // Detection + interactive selection
702
+ // ═══════════════════════════════════════════════════════════════════════
703
+
704
+ function detectInstalledAgents() {
705
+ const installed = [];
706
+ for (const [id, agent] of Object.entries(AGENTS)) {
707
+ if (agent.projectScoped) continue; // skip project-only agents
708
+ if (!agent.detectPath) continue;
709
+ if (existsSync(agent.detectPath)) installed.push(id);
710
+ }
711
+ return installed;
712
+ }
713
+
714
+ async function selectTargetsInteractive() {
715
+ const installed = detectInstalledAgents();
716
+ const allIds = Object.keys(AGENTS).filter((id) => !AGENTS[id].projectScoped);
717
+
718
+ console.log(" Detected installed agents (will install Agent Relay in these by default):");
719
+ if (installed.length === 0) {
720
+ console.log(" \x1b[33m(none detected)\x1b[0m");
721
+ } else {
722
+ installed.forEach((id) => console.log(` • ${AGENTS[id].label}`));
723
+ }
724
+ console.log("");
725
+
726
+ const rl = createInterface({ input: stdin, output: stdout });
727
+ try {
728
+ const ans = (
729
+ await rl.question(
730
+ " Press ENTER to install for detected agents, or type comma-separated IDs (or 'all'): ",
731
+ )
732
+ )
733
+ .trim()
734
+ .toLowerCase();
735
+
736
+ if (!ans) {
737
+ if (installed.length > 0) return installed;
738
+ // Nothing detected — show full list and re-prompt
739
+ console.log("");
740
+ console.log(" Available agent IDs:");
741
+ allIds.forEach((id) =>
742
+ console.log(` ${id.padEnd(18)} ${AGENTS[id].label}`),
743
+ );
744
+ console.log("");
745
+ const ans2 = (
746
+ await rl.question(" Type comma-separated IDs (or 'all'): ")
747
+ ).trim().toLowerCase();
748
+ if (ans2 === "all") return allIds;
749
+ return ans2.split(",").map((s) => s.trim()).filter((s) => AGENTS[s]);
750
+ }
751
+
752
+ if (ans === "all") return allIds;
753
+ return ans.split(",").map((s) => s.trim()).filter((s) => AGENTS[s]);
754
+ } finally {
755
+ rl.close();
756
+ }
757
+ }
758
+
759
+ // ═══════════════════════════════════════════════════════════════════════
760
+ // Main
761
+ // ═══════════════════════════════════════════════════════════════════════
762
+
763
+ async function main() {
764
+ console.log("");
765
+ console.log(" \x1b[1mAgent Relay Installer\x1b[0m");
766
+ console.log(
767
+ " AI-native search for software, tools, services & business solutions",
768
+ );
769
+ console.log("");
770
+
771
+ const flags = parseArgs();
772
+
773
+ // Resolve targets
774
+ let targets = [];
775
+ if (flags.all) {
776
+ targets = Object.keys(AGENTS).filter((id) => !AGENTS[id].projectScoped);
777
+ if (flags.project) {
778
+ // include project-scoped only when --project is also set
779
+ targets.push(...Object.keys(AGENTS).filter((id) => AGENTS[id].projectScoped));
780
+ }
781
+ } else if (flags.targets && flags.targets.length > 0) {
782
+ targets = flags.targets.filter((id) => {
783
+ if (!AGENTS[id]) {
784
+ warn(`Unknown agent id: ${id}`);
785
+ return false;
786
+ }
787
+ return true;
788
+ });
789
+ } else if (flags.legacyClaude || flags.legacyCodex || flags.legacyCursor) {
790
+ if (flags.legacyClaude) targets.push("claude");
791
+ if (flags.legacyCodex) targets.push("codex");
792
+ if (flags.legacyCursor) targets.push("cursor");
793
+ } else {
794
+ targets = await selectTargetsInteractive();
795
+ }
796
+
797
+ if (!targets || targets.length === 0) {
798
+ error("No agents selected. Exiting.");
799
+ process.exit(1);
800
+ }
801
+
802
+ log(
803
+ `Installing for: ${targets.map((t) => AGENTS[t]?.label || t).join(", ")}`,
804
+ );
805
+ console.log("");
806
+
807
+ // Load bundled files
808
+ const skillContent = readFileSync(
809
+ join(__dirname, "..", "skill", "SKILL.md"),
810
+ "utf-8",
811
+ );
812
+ const instructionsContent = readFileSync(
813
+ join(__dirname, "..", "CLAUDE.md"),
814
+ "utf-8",
815
+ ).trim();
816
+
817
+ // Run handlers per target
818
+ for (const id of targets) {
819
+ const agent = AGENTS[id];
820
+ if (!agent) continue;
821
+
822
+ switch (agent.mode) {
823
+ case "skill":
824
+ installSkill(skillContent, instructionsContent, agent);
825
+ break;
826
+ case "mcp_json":
827
+ installMcpJson(agent);
828
+ break;
829
+ case "mcp_witsy":
830
+ installWitsy(agent);
831
+ break;
832
+ case "mcp_yaml":
833
+ installLibreChat(agent, flags.librechat);
834
+ break;
835
+ case "project_roo":
836
+ if (!flags.project) {
837
+ warn(`${agent.label}: pass --project to install project-scoped config`);
838
+ break;
839
+ }
840
+ installRoo(agent, instructionsContent);
841
+ break;
842
+ case "deeplink":
843
+ installDeeplink(agent);
844
+ break;
845
+ default:
846
+ warn(`${agent.label}: unknown mode ${agent.mode}`);
847
+ }
848
+ }
849
+
850
+ // API key
851
+ console.log("");
852
+ if (flags.apiKey) {
853
+ if (flags.apiKey.startsWith("am_live_") && flags.apiKey.length > 20) {
854
+ saveApiKey(flags.apiKey);
855
+ } else {
856
+ error(`Invalid API key format: "${flags.apiKey}". Must start with "am_live_".`);
857
+ }
858
+ } else {
859
+ await setupApiKeyInteractive();
860
+ }
861
+
862
+ await verifyInstall();
863
+
864
+ // Put a bare `agentrelay` command on PATH so users don't need npx every time.
865
+ console.log("");
866
+ linkGlobalCli();
867
+
868
+ console.log("");
869
+ success("Done! Agent Relay is installed.");
870
+ console.log("");
871
+ console.log(
872
+ " Your agents will now search Agent Relay before web search or training knowledge.",
873
+ );
874
+ console.log("");
875
+ console.log(` Try it: agentrelay search "weather api"`);
876
+ console.log(` API key: ${CREDENTIALS_PATH}`);
877
+ console.log("");
878
+ log(
879
+ "For MCP-based agents, you may need to authenticate once via the agent's MCP console.",
880
+ );
881
+ console.log("");
882
+ }
883
+
884
+ main().catch((err) => {
885
+ error(`Install failed: ${err.message}`);
886
+ process.exit(1);
887
+ });