@mrrlin-dev/external-agents 0.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,380 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ /**
7
+ * Resolve a per-entry env override map. Value may be:
8
+ * - a literal string → used as-is
9
+ * - "@file:<path>" → read file, strip trailing whitespace; `~/` is expanded
10
+ * Unresolvable @file: entries produce a warning and are omitted.
11
+ */
12
+ function resolveEntryEnv(envMap) {
13
+ if (!envMap || typeof envMap !== "object") return {};
14
+ const out = {};
15
+ for (const [k, v] of Object.entries(envMap)) {
16
+ if (typeof v !== "string") continue;
17
+ if (v.startsWith("@file:")) {
18
+ let p = v.slice("@file:".length);
19
+ if (p.startsWith("~/")) p = path.join(os.homedir(), p.slice(2));
20
+ try {
21
+ out[k] = fs.readFileSync(p, "utf-8").trim();
22
+ } catch (e) {
23
+ console.error(`dispatch: WARN — could not read ${p} for env ${k}: ${e.message}`);
24
+ }
25
+ } else {
26
+ out[k] = v;
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const AIDER_HEADLESS_FLAGS = [
33
+ "--yes",
34
+ "--no-git",
35
+ "--no-auto-commits",
36
+ "--no-check-update",
37
+ "--no-show-model-warnings",
38
+ "--no-analytics",
39
+ ];
40
+
41
+ function isAiderCommand(tokens) {
42
+ return tokens[0] === "aider";
43
+ }
44
+
45
+ function listFiles(dir) {
46
+ const out = [];
47
+ const walk = (rel) => {
48
+ const abs = path.join(dir, rel);
49
+ for (const name of fs.readdirSync(abs)) {
50
+ if (name.startsWith(".aider")) continue;
51
+ const relPath = rel ? path.join(rel, name) : name;
52
+ const absPath = path.join(dir, relPath);
53
+ const st = fs.statSync(absPath);
54
+ if (st.isDirectory()) walk(relPath);
55
+ else out.push({ path: relPath, bytes: st.size });
56
+ }
57
+ };
58
+ try { walk(""); } catch {}
59
+ return out;
60
+ }
61
+
62
+ const EXHAUSTION_RE = /quota|rate.?limit|429|too many requests|insufficient balance|resource[ _]?exhausted|usage limit|credits exhausted/i;
63
+
64
+ export function parseExhaustionSignal(text) {
65
+ const detected = EXHAUSTION_RE.test(text);
66
+
67
+ let reset_at;
68
+ if (detected) {
69
+ const m1 = text.match(/Resets in (\d+)h(?:(\d+)m)?/i);
70
+ if (m1) {
71
+ const h = parseInt(m1[1], 10);
72
+ const m = m1[2] ? parseInt(m1[2], 10) : 0;
73
+ reset_at = Math.floor(Date.now() / 1000) + h * 3600 + m * 60;
74
+ }
75
+
76
+ if (reset_at === undefined) {
77
+ const m2 = text.match(/Retry-After:\s*(\d+)/i);
78
+ if (m2) {
79
+ reset_at = Math.floor(Date.now() / 1000) + parseInt(m2[1], 10);
80
+ }
81
+ }
82
+
83
+ if (reset_at === undefined) {
84
+ const m3 = text.match(/reset in (\d+) seconds/i);
85
+ if (m3) {
86
+ reset_at = Math.floor(Date.now() / 1000) + parseInt(m3[1], 10);
87
+ }
88
+ }
89
+ }
90
+
91
+ return { detected, reset_at };
92
+ }
93
+
94
+ export function runDispatch(agentEntry, prompt, options = {}) {
95
+ const timeoutMs = options.timeoutMs ?? 300000;
96
+ const cliCmd = agentEntry.transports?.edit_exists;
97
+ if (!cliCmd || typeof cliCmd !== "string") {
98
+ throw new Error(`runDispatch: no cli transport for ${agentEntry.id}`);
99
+ }
100
+
101
+ const parts = cliCmd.trim().split(/\s+/);
102
+ const cmd = parts[0];
103
+
104
+ // aider: prompt via --message flag + append headless flags. Otherwise: prompt as final positional.
105
+ let args;
106
+ if (isAiderCommand(parts)) {
107
+ args = [...parts.slice(1), "--message", prompt, ...AIDER_HEADLESS_FLAGS];
108
+ } else {
109
+ args = [...parts.slice(1), prompt];
110
+ }
111
+
112
+ // Fresh temp cwd per call so file effects are isolated + collectable.
113
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), `ea-dispatch-${agentEntry.id}-`));
114
+
115
+ // Per-entry env overrides — applied ONLY to the subprocess, never to parent.
116
+ const entryEnv = resolveEntryEnv(agentEntry.env);
117
+ const childEnv = { ...process.env, ...entryEnv };
118
+
119
+ return new Promise((resolve) => {
120
+ const start = Date.now();
121
+ const child = spawn(cmd, args, { cwd: workdir, env: childEnv });
122
+ let stdout = "";
123
+ let stderr = "";
124
+ let timedOut = false;
125
+
126
+ const timer = setTimeout(() => {
127
+ timedOut = true;
128
+ child.kill("SIGTERM");
129
+ }, timeoutMs);
130
+
131
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
132
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
133
+
134
+ child.on("close", (code) => {
135
+ clearTimeout(timer);
136
+ resolve({
137
+ output: stdout,
138
+ stderr,
139
+ exitCode: timedOut ? 124 : code,
140
+ durationMs: Date.now() - start,
141
+ workdir,
142
+ files: listFiles(workdir),
143
+ });
144
+ });
145
+
146
+ child.on("error", (err) => {
147
+ clearTimeout(timer);
148
+ resolve({
149
+ output: stdout,
150
+ stderr: stderr + "\n" + err.message,
151
+ exitCode: 1,
152
+ durationMs: Date.now() - start,
153
+ workdir,
154
+ files: [],
155
+ });
156
+ });
157
+ });
158
+ }
159
+
160
+ export function resolveEscalation(registry, sourceAgentId) {
161
+ const source = registry.agents.find((a) => a.id === sourceAgentId);
162
+ if (!source) return null;
163
+ return registry.agents.find(
164
+ (a) => a.id !== sourceAgentId && a.provider === source.provider && a.tier === "strong"
165
+ ) || null;
166
+ }
167
+
168
+ /**
169
+ * Pure-generation transport: hits an OpenAI-compatible /chat/completions
170
+ * endpoint via native fetch, dumps the response content into a file in a
171
+ * fresh temp workdir. NO agentic loop, NO tool use — the model outputs text,
172
+ * we write text. Great for "generate the content of this file from spec"
173
+ * tasks where aider's edit-oriented pipeline gets in the way.
174
+ *
175
+ * Registry shape expected:
176
+ * transports:
177
+ * generate:
178
+ * url: "https://…/chat/completions" # OpenAI-compat endpoint
179
+ * env: "GEMINI_API_KEY" # env var holding the Bearer key
180
+ * model: "gemini-3.5-flash" # OPTIONAL — falls back to agentEntry.model
181
+ * output_filename: "generated.md" # OPTIONAL — default "generated.md"
182
+ */
183
+ export async function runGenerate(agentEntry, prompt, options = {}) {
184
+ const g = agentEntry.transports?.generate_new;
185
+ if (!g || typeof g !== "object") {
186
+ throw new Error(`runGenerate: no generate transport for ${agentEntry.id}`);
187
+ }
188
+ if (!g.url) throw new Error(`runGenerate: transports.generate_new.url missing for ${agentEntry.id}`);
189
+ const envName = g.env;
190
+ let apiKey; // optional — Ollama and some local endpoints need no auth
191
+ if (envName && envName !== "OLLAMA_UNUSED_KEY") {
192
+ apiKey = process.env[envName];
193
+ if (!apiKey) {
194
+ return {
195
+ output: "",
196
+ stderr: `env var ${envName} not set`,
197
+ exitCode: 1,
198
+ durationMs: 0,
199
+ workdir: fs.mkdtempSync(path.join(os.tmpdir(), `ea-gen-${agentEntry.id}-`)),
200
+ files: [],
201
+ };
202
+ }
203
+ }
204
+
205
+ const model = g.model || agentEntry.model;
206
+ if (!model) throw new Error(`runGenerate: no model set for ${agentEntry.id}`);
207
+
208
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), `ea-gen-${agentEntry.id}-`));
209
+ const filename = g.output_filename || "generated.md";
210
+ const outPath = path.join(workdir, filename);
211
+
212
+ const timeoutMs = options.timeoutMs ?? 300000;
213
+ const ctrl = new AbortController();
214
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
215
+ const start = Date.now();
216
+
217
+ try {
218
+ const headers = { "Content-Type": "application/json" };
219
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
220
+ const resp = await fetch(g.url, {
221
+ method: "POST",
222
+ headers,
223
+ body: JSON.stringify({
224
+ model,
225
+ messages: [{ role: "user", content: prompt }],
226
+ stream: false,
227
+ }),
228
+ signal: ctrl.signal,
229
+ });
230
+ clearTimeout(timer);
231
+ const durationMs = Date.now() - start;
232
+
233
+ const bodyText = await resp.text();
234
+ if (!resp.ok) {
235
+ return {
236
+ output: bodyText,
237
+ stderr: `HTTP ${resp.status} ${resp.statusText}`,
238
+ exitCode: 1,
239
+ durationMs,
240
+ workdir,
241
+ files: [],
242
+ status: resp.status,
243
+ };
244
+ }
245
+ let data;
246
+ try {
247
+ data = JSON.parse(bodyText);
248
+ } catch {
249
+ return { output: bodyText, stderr: "non-JSON response", exitCode: 1, durationMs, workdir, files: [] };
250
+ }
251
+ const content = data?.choices?.[0]?.message?.content ?? "";
252
+ const usage = data?.usage || {};
253
+ fs.writeFileSync(outPath, content);
254
+
255
+ return {
256
+ output: content,
257
+ stderr: "",
258
+ exitCode: 0,
259
+ durationMs,
260
+ workdir,
261
+ files: [{ path: filename, bytes: Buffer.byteLength(content, "utf-8") }],
262
+ tokens_in: usage.prompt_tokens,
263
+ tokens_out: usage.completion_tokens,
264
+ };
265
+ } catch (err) {
266
+ clearTimeout(timer);
267
+ const durationMs = Date.now() - start;
268
+ const timedOut = err?.name === "AbortError";
269
+ return {
270
+ output: "",
271
+ stderr: err?.message || String(err),
272
+ exitCode: timedOut ? 124 : 1,
273
+ durationMs,
274
+ workdir,
275
+ files: [],
276
+ };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Route to the right runner based on which transport the agent declares.
282
+ *
283
+ * Selection order:
284
+ * 1. If options.transport is explicitly "generate_new" or "edit_exists", use that (error
285
+ * if the entry doesn't declare it). This is how callers override.
286
+ * 2. Otherwise, prefer generate (simpler caller contract — a real file
287
+ * always lands in workdir), fall back to cli/aider (agentic).
288
+ *
289
+ * Always appends one JSONL row to ~/.local/state/external-agents/dispatch-log.jsonl
290
+ * regardless of outcome, so get_stats can aggregate. Telemetry is best-effort.
291
+ */
292
+ const DISPATCH_LOG = path.join(os.homedir(), ".local", "state", "external-agents", "dispatch-log.jsonl");
293
+
294
+ function logDispatch(row) {
295
+ try {
296
+ fs.mkdirSync(path.dirname(DISPATCH_LOG), { recursive: true, mode: 0o700 });
297
+ fs.appendFileSync(DISPATCH_LOG, JSON.stringify(row) + "\n", { mode: 0o600 });
298
+ } catch (e) {
299
+ console.error(`external-agents: telemetry write failed: ${e.message}`);
300
+ }
301
+ }
302
+
303
+ export async function runAny(agentEntry, prompt, options = {}) {
304
+ const forced = options.transport;
305
+ let transport;
306
+ let result;
307
+
308
+ if (forced === "generate_new") {
309
+ if (!agentEntry?.transports?.generate_new) {
310
+ throw new Error(`runAny: transport 'generate' requested but not declared for ${agentEntry?.id}`);
311
+ }
312
+ transport = "generate_new";
313
+ result = await runGenerate(agentEntry, prompt, options);
314
+ } else if (forced === "edit_exists") {
315
+ if (!agentEntry?.transports?.edit_exists) {
316
+ throw new Error(`runAny: transport 'cli' requested but not declared for ${agentEntry?.id}`);
317
+ }
318
+ transport = "edit_exists";
319
+ result = await runDispatch(agentEntry, prompt, options);
320
+ } else if (agentEntry?.transports?.generate_new) {
321
+ transport = "generate_new";
322
+ result = await runGenerate(agentEntry, prompt, options);
323
+ } else if (agentEntry?.transports?.edit_exists) {
324
+ transport = "edit_exists";
325
+ result = await runDispatch(agentEntry, prompt, options);
326
+ } else {
327
+ throw new Error(`runAny: no known transport for ${agentEntry?.id ?? "<unknown>"}`);
328
+ }
329
+
330
+ // Telemetry — one row per dispatch, best-effort.
331
+ logDispatch({
332
+ ts: Math.floor(Date.now() / 1000),
333
+ agent_id: agentEntry.id,
334
+ provider: agentEntry.provider,
335
+ model: agentEntry.model,
336
+ transport,
337
+ outcome: result.exitCode === 0 ? "success" : (result.exitCode === 124 ? "timeout" : "error"),
338
+ exit_code: result.exitCode,
339
+ duration_ms: result.durationMs,
340
+ tokens_in: result.tokens_in ?? null,
341
+ tokens_out: result.tokens_out ?? null,
342
+ prompt_bytes: Buffer.byteLength(prompt || "", "utf-8"),
343
+ });
344
+
345
+ return { ...result, transport };
346
+ }
347
+
348
+ export function getStats(sinceIso) {
349
+ if (!fs.existsSync(DISPATCH_LOG)) return { total: 0, by_agent: {}, by_transport: {}, span: {} };
350
+ const raw = fs.readFileSync(DISPATCH_LOG, "utf-8");
351
+ const rows = [];
352
+ for (const line of raw.split("\n")) {
353
+ if (!line.trim()) continue;
354
+ try { rows.push(JSON.parse(line)); } catch {}
355
+ }
356
+ const since = sinceIso ? Math.floor(Date.parse(sinceIso) / 1000) : 0;
357
+ const filtered = rows.filter((r) => (r.ts || 0) >= since);
358
+ const by_agent = {};
359
+ const by_transport = {};
360
+ let first = Infinity, last = 0;
361
+ for (const r of filtered) {
362
+ const a = (by_agent[r.agent_id] ??= { count: 0, tokens_in: 0, tokens_out: 0, duration_ms: 0, outcomes: {}, transports: {} });
363
+ a.count++;
364
+ a.tokens_in += r.tokens_in || 0;
365
+ a.tokens_out += r.tokens_out || 0;
366
+ a.duration_ms += r.duration_ms || 0;
367
+ a.outcomes[r.outcome] = (a.outcomes[r.outcome] || 0) + 1;
368
+ a.transports[r.transport] = (a.transports[r.transport] || 0) + 1;
369
+ const t = (by_transport[r.transport] ??= { count: 0, tokens_in: 0, tokens_out: 0 });
370
+ t.count++; t.tokens_in += r.tokens_in || 0; t.tokens_out += r.tokens_out || 0;
371
+ if (r.ts < first) first = r.ts;
372
+ if (r.ts > last) last = r.ts;
373
+ }
374
+ return {
375
+ total: filtered.length,
376
+ by_agent,
377
+ by_transport,
378
+ span: filtered.length ? { first_ts: first, last_ts: last } : {},
379
+ };
380
+ }
package/lib/pick.js ADDED
@@ -0,0 +1,52 @@
1
+ export function pickAgents(registry, state, opts = {}) {
2
+ const n = Math.max(1, opts.n || 1);
3
+ const filter = opts.filter || {};
4
+ const minDistinct = opts.min_distinct_providers;
5
+
6
+ const excludeSet = new Set(filter.exclude_ids || []);
7
+ const requestedTags = filter.tags || [];
8
+
9
+ const requestedTransport = filter.transport; // "generate_new" | "edit_exists" | undefined
10
+
11
+ const candidates = registry.agents.filter((entry) => {
12
+ if (excludeSet.has(entry.id)) return false;
13
+
14
+ const entryState = state[entry.id]?.state;
15
+ if (entryState && entryState !== "healthy") return false;
16
+
17
+ if (filter.tier && entry.tier !== filter.tier) return false;
18
+
19
+ if (requestedTags.length > 0) {
20
+ const entryTags = new Set(entry.tags || []);
21
+ for (const t of requestedTags) {
22
+ if (!entryTags.has(t)) return false;
23
+ }
24
+ }
25
+
26
+ if (requestedTransport && !entry.transports?.[requestedTransport]) return false;
27
+
28
+ return true;
29
+ });
30
+
31
+ candidates.sort((a, b) => {
32
+ const pa = a.preference_order ?? 999;
33
+ const pb = b.preference_order ?? 999;
34
+ if (pa !== pb) return pa - pb;
35
+ const la = state[a.id]?.last_used_at ?? 0;
36
+ const lb = state[b.id]?.last_used_at ?? 0;
37
+ return la - lb;
38
+ });
39
+
40
+ const picked = [];
41
+ const seenProviders = new Set();
42
+ for (const entry of candidates) {
43
+ if (picked.length >= n) break;
44
+ if (minDistinct != null && seenProviders.size < minDistinct && seenProviders.has(entry.provider)) {
45
+ continue;
46
+ }
47
+ picked.push(entry.id);
48
+ seenProviders.add(entry.provider);
49
+ }
50
+
51
+ return picked;
52
+ }
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import yaml from "js-yaml";
3
+
4
+ function validate(parsed) {
5
+ if (!parsed || typeof parsed !== "object" || !parsed.schema_version) {
6
+ throw new Error("loadRegistry: missing top-level 'schema_version' in YAML");
7
+ }
8
+ if (!Array.isArray(parsed.agents)) {
9
+ throw new Error("loadRegistry: missing or invalid 'agents' array in YAML");
10
+ }
11
+ for (const agent of parsed.agents) {
12
+ if (!agent.id) {
13
+ throw new Error("loadRegistry: agent entry missing 'id'");
14
+ }
15
+ if (!agent.provider) {
16
+ throw new Error(`loadRegistry: agent "${agent.id ?? "<unknown>"}" missing 'provider'`);
17
+ }
18
+ if (!agent.transports || typeof agent.transports !== "object") {
19
+ throw new Error(`loadRegistry: agent "${agent.id}" missing or invalid 'transports'`);
20
+ }
21
+ }
22
+ }
23
+
24
+ export function loadRegistry(yamlPath) {
25
+ const raw = fs.readFileSync(yamlPath, "utf-8");
26
+ const parsed = yaml.load(raw);
27
+ validate(parsed);
28
+ return parsed;
29
+ }
package/lib/state.js ADDED
@@ -0,0 +1,113 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ const STATE_DIR = path.join(os.homedir(), ".local", "state", "external-agents");
7
+ const STATE_FILE = path.join(STATE_DIR, "state.json");
8
+ const LOCK_DIR = path.join(STATE_DIR, ".lock");
9
+
10
+ export function getStatePath() {
11
+ return STATE_FILE;
12
+ }
13
+
14
+ function ensureDir() {
15
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
16
+ }
17
+
18
+ function acquireLock() {
19
+ for (let i = 0; i < 500; i++) {
20
+ try {
21
+ fs.mkdirSync(LOCK_DIR);
22
+ return true;
23
+ } catch (e) {
24
+ if (e.code !== "EEXIST") throw e;
25
+ const wait = spawnSync("/bin/sh", ["-c", "sleep 0.02"]);
26
+ if (wait.error) throw wait.error;
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function releaseLock() {
33
+ try { fs.rmdirSync(LOCK_DIR); } catch {}
34
+ }
35
+
36
+ export function readState() {
37
+ try {
38
+ const raw = fs.readFileSync(STATE_FILE, "utf-8");
39
+ return JSON.parse(raw);
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ export function writeState(patch) {
46
+ ensureDir();
47
+ const gotLock = acquireLock();
48
+ try {
49
+ const current = readState();
50
+ const merged = { ...current, ...patch };
51
+ const tmp = STATE_FILE + ".tmp." + process.pid + "." + Date.now();
52
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
53
+ fs.renameSync(tmp, STATE_FILE);
54
+ return merged;
55
+ } finally {
56
+ if (gotLock) releaseLock();
57
+ }
58
+ }
59
+
60
+ export function probeInstalled(agentEntry) {
61
+ const cliCmd = agentEntry?.transports?.edit_exists;
62
+ if (!cliCmd || typeof cliCmd !== "string") {
63
+ return { state: "errored_transient", note: "no cli transport" };
64
+ }
65
+ const bin = cliCmd.trim().split(/\s+/)[0];
66
+ const r = spawnSync("command", ["-v", bin], { shell: "/bin/bash" });
67
+ if (r.status !== 0) {
68
+ return { state: "not_installed", note: `binary missing: ${bin}` };
69
+ }
70
+
71
+ // Binary present. Check auth prerequisites:
72
+ // - per-entry `env` override (with optional @file: refs) OR
73
+ // - `auth: "env:XXX"` — the var must be in process.env
74
+ // If a per-entry env override is present, it takes precedence: we verify
75
+ // every @file: reference resolves. Otherwise fall back to auth-field.
76
+ const auth = agentEntry.auth || "";
77
+
78
+ if (agentEntry.env && typeof agentEntry.env === "object") {
79
+ for (const [k, v] of Object.entries(agentEntry.env)) {
80
+ if (typeof v === "string" && v.startsWith("@file:")) {
81
+ let p = v.slice("@file:".length);
82
+ if (p.startsWith("~/")) p = path.join(os.homedir(), p.slice(2));
83
+ try {
84
+ const s = fs.statSync(p);
85
+ if (!s.isFile()) throw new Error("not a regular file");
86
+ } catch (e) {
87
+ return { state: "needs_auth", note: `env override ${k}: cannot read ${p}` };
88
+ }
89
+ }
90
+ }
91
+ return { state: "healthy", note: `binary present: ${bin}; per-entry env resolved` };
92
+ }
93
+
94
+ if (auth.startsWith("env:")) {
95
+ const varName = auth.slice("env:".length).split(/\s+/)[0];
96
+ if (!process.env[varName]) {
97
+ return { state: "needs_auth", note: `env var ${varName} not set (add to shell or ADR-0016 store)` };
98
+ }
99
+ }
100
+
101
+ // For entries with only generate transport (no cli), the "binary" is aider-agnostic
102
+ // (native fetch). Also handle the Ollama sentinel — no real env var needed.
103
+ const genEnv = agentEntry.transports?.generate_new?.env;
104
+ if (genEnv && genEnv !== "OLLAMA_UNUSED_KEY" && !process.env[genEnv] && !(agentEntry.env && agentEntry.env[genEnv])) {
105
+ // If we already passed the cli auth check above, this is a "generate needs its own env" case.
106
+ // Skip when auth check already flagged the same var.
107
+ if (!auth.startsWith("env:") || !auth.includes(genEnv)) {
108
+ return { state: "needs_auth", note: `generate env var ${genEnv} not set` };
109
+ }
110
+ }
111
+
112
+ return { state: "healthy", note: `binary present: ${bin}` };
113
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@mrrlin-dev/external-agents",
3
+ "version": "0.1.0",
4
+ "description": "One MCP server for every LLM you talk to — direct-API dispatcher across Gemini, DeepSeek, Groq, OpenRouter, Cerebras, and more. Part of mrrlin.com.",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "external-agents": "./cli.js",
9
+ "external-agents-mcp": "./server.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node server.js",
13
+ "ui": "node ui.js",
14
+ "cli": "node cli.js"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "llm",
20
+ "aider",
21
+ "gemini",
22
+ "deepseek",
23
+ "groq",
24
+ "openrouter",
25
+ "cerebras",
26
+ "agent",
27
+ "dispatcher",
28
+ "orchestration",
29
+ "mrrlin"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20.0.0"
33
+ },
34
+ "author": "mrrlin-dev",
35
+ "license": "MIT",
36
+ "homepage": "https://mrrlin.com",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/mrrlin-dev/external-agents.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/mrrlin-dev/external-agents/issues"
43
+ },
44
+ "files": [
45
+ "server.js",
46
+ "cli.js",
47
+ "ui.js",
48
+ "lib/",
49
+ "agents.yaml",
50
+ "docs/",
51
+ "README.md",
52
+ "LICENSE"
53
+ ],
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "latest",
56
+ "js-yaml": "^4"
57
+ }
58
+ }