@mrrlin-dev/external-agents 0.2.4 → 0.2.6
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 +28 -4
- package/cli.js +43 -18
- package/lib/credentials.js +100 -0
- package/package.json +1 -1
- package/server.js +4 -78
package/README.md
CHANGED
|
@@ -86,9 +86,32 @@ Open the local dashboard with `external-agents ui` and you'll get a page like:
|
|
|
86
86
|
|
|
87
87
|
Once installed, add one block to your MCP client config.
|
|
88
88
|
|
|
89
|
-
The package ships a dedicated `external-agents-mcp` binary — the MCP server entry — so client configs are plain command lines with no args.
|
|
89
|
+
The package ships a dedicated `external-agents-mcp` binary — the MCP server entry — so client configs are plain command lines with no args. **Both Claude Code and Codex expose a one-liner** for this; you should never have to hand-edit config files unless you want to.
|
|
90
|
+
|
|
91
|
+
### Claude Code
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
claude mcp add external-agents external-agents-mcp
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
(Uses the native `claude mcp add <name> <command>` — the CLI writes the block into `~/.claude.json` for you and Claude Code picks it up on next start.)
|
|
98
|
+
|
|
99
|
+
### Codex
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
codex mcp add external-agents -- external-agents-mcp
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
(Uses the native `codex mcp add <name> -- <command>` — the CLI writes `[mcp_servers.external-agents]` into `~/.codex/config.toml`.)
|
|
106
|
+
|
|
107
|
+
### Cursor
|
|
108
|
+
|
|
109
|
+
Settings → MCP → Add server → command `external-agents-mcp`, no args.
|
|
110
|
+
|
|
111
|
+
### Manual (any other client)
|
|
112
|
+
|
|
113
|
+
If your MCP client doesn't have a one-liner, the config block is:
|
|
90
114
|
|
|
91
|
-
**Claude Code** (`~/.claude.json`):
|
|
92
115
|
```json
|
|
93
116
|
{
|
|
94
117
|
"mcpServers": {
|
|
@@ -100,14 +123,15 @@ The package ships a dedicated `external-agents-mcp` binary — the MCP server en
|
|
|
100
123
|
}
|
|
101
124
|
```
|
|
102
125
|
|
|
103
|
-
|
|
126
|
+
or TOML equivalent:
|
|
127
|
+
|
|
104
128
|
```toml
|
|
105
129
|
[mcp_servers.external-agents]
|
|
106
130
|
command = "external-agents-mcp"
|
|
107
131
|
args = []
|
|
108
132
|
```
|
|
109
133
|
|
|
110
|
-
|
|
134
|
+
No publishing step — the MCP server is just `external-agents-mcp` on your PATH once you `npm i -g @mrrlin-dev/external-agents`. Nothing to host, nothing to expose over the network; it's a stdio-transport MCP server that runs locally, same as any other npm-installed CLI.
|
|
111
135
|
|
|
112
136
|
Your primary agent now has these low-level tools (build your own exec/review flows on top):
|
|
113
137
|
|
package/cli.js
CHANGED
|
@@ -18,28 +18,16 @@ import { loadRegistry } from "./lib/registry.js";
|
|
|
18
18
|
import { readState, writeState, probeInstalled } from "./lib/state.js";
|
|
19
19
|
import { runAny, resolveEscalation, parseExhaustionSignal, getStats } from "./lib/dispatch.js";
|
|
20
20
|
import { pickAgents } from "./lib/pick.js";
|
|
21
|
+
import { persistCredential, bootEnv, KEYS_FILE } from "./lib/credentials.js";
|
|
21
22
|
import fs from "node:fs";
|
|
22
23
|
import os from "node:os";
|
|
23
24
|
import path from "node:path";
|
|
24
25
|
import { spawn } from "node:child_process";
|
|
25
26
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (fs.existsSync(kiloAuthPath)) {
|
|
31
|
-
const a = JSON.parse(fs.readFileSync(kiloAuthPath, "utf-8"));
|
|
32
|
-
if (!process.env.DEEPSEEK_API_KEY && a.deepseek?.key) process.env.DEEPSEEK_API_KEY = a.deepseek.key;
|
|
33
|
-
}
|
|
34
|
-
const llmKeysPath = path.join(os.homedir(), "Library/Application Support/io.datasette.llm/keys.json");
|
|
35
|
-
if (!process.env.GEMINI_API_KEY || !process.env.GEMINI_API_KEY.startsWith("AIza")) {
|
|
36
|
-
if (fs.existsSync(llmKeysPath)) {
|
|
37
|
-
const k = JSON.parse(fs.readFileSync(llmKeysPath, "utf-8"));
|
|
38
|
-
if (k.gemini) process.env.GEMINI_API_KEY = k.gemini;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
} catch {}
|
|
42
|
-
}
|
|
27
|
+
// CLI + MCP server share the same env-loading logic — see lib/credentials.js.
|
|
28
|
+
// This ensures `external-agents set-credential FOO_KEY ...` followed by
|
|
29
|
+
// `external-agents probe some-agent` reads the just-written keys.env, and the
|
|
30
|
+
// two invocation surfaces (CLI here, MCP server in server.js) never drift.
|
|
43
31
|
bootEnv();
|
|
44
32
|
|
|
45
33
|
const REGISTRY_PATH = path.join(path.dirname(new URL(import.meta.url).pathname), "agents.yaml");
|
|
@@ -185,6 +173,41 @@ function cmdProbe(args) {
|
|
|
185
173
|
console.log(JSON.stringify({ id: agentId, ...result, checked }));
|
|
186
174
|
}
|
|
187
175
|
|
|
176
|
+
// `external-agents set-credential ENV_NAME [value]` — persist a credential to
|
|
177
|
+
// ~/.local/state/external-agents/keys.env (0600). Two input paths:
|
|
178
|
+
// - value supplied as an argument (fine for scripts)
|
|
179
|
+
// - value read from stdin when the argument is `-` or omitted (safer for
|
|
180
|
+
// interactive use — no shell-history leak, no ps-listing exposure).
|
|
181
|
+
// After persisting, the current process env is updated so a follow-up probe /
|
|
182
|
+
// dispatch inside the same shell script sees the new value.
|
|
183
|
+
async function cmdSetCredential(args) {
|
|
184
|
+
const [envName, valueArg] = args;
|
|
185
|
+
if (!envName) {
|
|
186
|
+
die("usage: external-agents set-credential <ENV_NAME> [<value> | -]\n <value> may be `-` (or omitted) to read from stdin", 2);
|
|
187
|
+
}
|
|
188
|
+
let value = valueArg;
|
|
189
|
+
if (!value || value === "-") {
|
|
190
|
+
// Read from stdin. If a TTY, prompt on stderr.
|
|
191
|
+
if (process.stdin.isTTY) {
|
|
192
|
+
process.stderr.write(`Enter value for ${envName} (echoed): `);
|
|
193
|
+
}
|
|
194
|
+
value = await new Promise((resolve) => {
|
|
195
|
+
let buf = "";
|
|
196
|
+
process.stdin.setEncoding("utf-8");
|
|
197
|
+
process.stdin.on("data", (chunk) => { buf += chunk; });
|
|
198
|
+
process.stdin.on("end", () => resolve(buf.replace(/\r?\n$/, "")));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const persistedTo = persistCredential(envName, value);
|
|
203
|
+
// Print to stderr so stdout stays clean for scripting; do NOT echo the value.
|
|
204
|
+
console.error(`external-agents: ${envName} persisted to ${persistedTo}`);
|
|
205
|
+
console.error(` Restart your MCP client (Codex / Claude Code) so its external-agents-mcp instance re-reads keys.env at startup.`);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
die(`set-credential failed: ${e.message}`, 2);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
188
211
|
// `external-agents ui` — spawn the loopback dashboard (ui.js) inline so the CLI
|
|
189
212
|
// stays the single entry point. ui.js runs its server at top level and blocks;
|
|
190
213
|
// we spawn it as a child so cli.js does not need to import server-lifecycle code
|
|
@@ -211,6 +234,7 @@ switch (subcmd) {
|
|
|
211
234
|
case "probe": cmdProbe(args); break;
|
|
212
235
|
case "stats": cmdStats(flags); break;
|
|
213
236
|
case "ui": cmdUi(flags); break;
|
|
237
|
+
case "set-credential": await cmdSetCredential(args); break;
|
|
214
238
|
case "help":
|
|
215
239
|
case "--help":
|
|
216
240
|
case undefined:
|
|
@@ -220,7 +244,8 @@ switch (subcmd) {
|
|
|
220
244
|
status [--json]
|
|
221
245
|
probe <agent-id>
|
|
222
246
|
stats [--since ISO] [--json]
|
|
223
|
-
ui [--port N] [--host H] # local dashboard for setting keys + inspecting state (default http://127.0.0.1:4711)
|
|
247
|
+
ui [--port N] [--host H] # local dashboard for setting keys + inspecting state (default http://127.0.0.1:4711)
|
|
248
|
+
set-credential <ENV_NAME> [<value> | -] # persist a key to ~/.local/state/external-agents/keys.env (0600); '-' or omitted = read from stdin`);
|
|
224
249
|
process.exit(subcmd ? 0 : 2);
|
|
225
250
|
default: die(`unknown subcommand: ${subcmd}`, 2);
|
|
226
251
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Persistent credential store for external-agents.
|
|
2
|
+
//
|
|
3
|
+
// Backing file: ~/.local/state/external-agents/keys.env (mode 0600, one
|
|
4
|
+
// KEY=value per line, no quotes). This is the operator-facing store — it wins
|
|
5
|
+
// over legacy per-provider stores (Kilo's auth.json, Simon Willison's llm keys,
|
|
6
|
+
// per-entry @file: refs) at bootEnv time.
|
|
7
|
+
//
|
|
8
|
+
// Exposed as a plain module (no side effects at import time) so both the MCP
|
|
9
|
+
// server (server.js) and the CLI (cli.js) can import from here without spinning
|
|
10
|
+
// up an MCP transport.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
export const KEYS_FILE = path.join(os.homedir(), ".local/state/external-agents/keys.env");
|
|
16
|
+
|
|
17
|
+
export function loadKeysFile() {
|
|
18
|
+
try {
|
|
19
|
+
if (!fs.existsSync(KEYS_FILE)) return {};
|
|
20
|
+
const raw = fs.readFileSync(KEYS_FILE, "utf-8");
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
23
|
+
const t = line.trim();
|
|
24
|
+
if (!t || t.startsWith("#")) continue;
|
|
25
|
+
const eq = t.indexOf("=");
|
|
26
|
+
if (eq < 0) continue;
|
|
27
|
+
const k = t.slice(0, eq).trim();
|
|
28
|
+
const v = t.slice(eq + 1);
|
|
29
|
+
if (k) out[k] = v;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(`external-agents: WARN — keys.env unreadable: ${e.message}`);
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveKeysFile(kv) {
|
|
39
|
+
const dir = path.dirname(KEYS_FILE);
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
const body = Object.entries(kv)
|
|
42
|
+
.filter(([k, v]) => k && typeof v === "string")
|
|
43
|
+
.map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
44
|
+
const tmp = KEYS_FILE + ".tmp." + process.pid + "." + Date.now();
|
|
45
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
46
|
+
fs.renameSync(tmp, KEYS_FILE);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Populate process.env from all credential sources this package knows about,
|
|
50
|
+
// in priority order:
|
|
51
|
+
// 1. keys.env (UI-persisted / `set-credential` — operator's explicit choice)
|
|
52
|
+
// 2. Kilo auth store (~/.local/share/kilo/auth.json) — DeepSeek only
|
|
53
|
+
// 3. Simon Willison's llm CLI keys (~/Library/Application Support/io.datasette.llm/keys.json) — AI Studio Gemini
|
|
54
|
+
// Never overrides an already-set env var. Same logic in server.js and cli.js
|
|
55
|
+
// used to drift — this is the single source of truth now.
|
|
56
|
+
export function bootEnv() {
|
|
57
|
+
try {
|
|
58
|
+
// 1. UI-persisted / set-credential keys — highest priority
|
|
59
|
+
const persisted = loadKeysFile();
|
|
60
|
+
for (const [k, v] of Object.entries(persisted)) {
|
|
61
|
+
if (!process.env[k]) process.env[k] = v;
|
|
62
|
+
}
|
|
63
|
+
// 2. Kilo auth store — DeepSeek key
|
|
64
|
+
const kiloAuthPath = path.join(os.homedir(), ".local/share/kilo/auth.json");
|
|
65
|
+
if (fs.existsSync(kiloAuthPath)) {
|
|
66
|
+
const kiloAuth = JSON.parse(fs.readFileSync(kiloAuthPath, "utf-8"));
|
|
67
|
+
if (!process.env.DEEPSEEK_API_KEY && kiloAuth.deepseek?.key) {
|
|
68
|
+
process.env.DEEPSEEK_API_KEY = kiloAuth.deepseek.key;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 3. llm-key store — AI Studio Gemini `AIza...` key
|
|
72
|
+
const llmKeysPath = path.join(os.homedir(), "Library/Application Support/io.datasette.llm/keys.json");
|
|
73
|
+
if (!process.env.GEMINI_API_KEY || !process.env.GEMINI_API_KEY.startsWith("AIza")) {
|
|
74
|
+
if (fs.existsSync(llmKeysPath)) {
|
|
75
|
+
const llmKeys = JSON.parse(fs.readFileSync(llmKeysPath, "utf-8"));
|
|
76
|
+
if (llmKeys.gemini) process.env.GEMINI_API_KEY = llmKeys.gemini;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error(`external-agents: WARN — bootEnv failed: ${e.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Persist a credential to the keys.env store AND inject into the current
|
|
85
|
+
// process's env so subsequent calls in the same process see it. Returns the
|
|
86
|
+
// path the credential was persisted to so callers can report it back to the
|
|
87
|
+
// operator. Env-var-name is validated: SHOUTY_SNAKE_CASE only.
|
|
88
|
+
export function persistCredential(envName, value) {
|
|
89
|
+
if (!envName || !/^[A-Z_][A-Z0-9_]*$/.test(envName)) {
|
|
90
|
+
throw new Error(`invalid env var name: ${JSON.stringify(envName)} (expected SHOUTY_SNAKE_CASE)`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
93
|
+
throw new Error("credential value must be a non-empty string");
|
|
94
|
+
}
|
|
95
|
+
const persisted = loadKeysFile();
|
|
96
|
+
persisted[envName] = value;
|
|
97
|
+
saveKeysFile(persisted);
|
|
98
|
+
process.env[envName] = value;
|
|
99
|
+
return KEYS_FILE;
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrlin-dev/external-agents",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -16,86 +16,12 @@ import { pickAgents } from "./lib/pick.js";
|
|
|
16
16
|
|
|
17
17
|
const REGISTRY = loadRegistry("./agents.yaml");
|
|
18
18
|
|
|
19
|
-
// Env-var boot injection
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
// contain. File format: one KEY=value per line, no quotes. Mode 0600.
|
|
24
|
-
const KEYS_FILE = path.join(os.homedir(), ".local/state/external-agents/keys.env");
|
|
25
|
-
|
|
26
|
-
function loadKeysFile() {
|
|
27
|
-
try {
|
|
28
|
-
if (!fs.existsSync(KEYS_FILE)) return {};
|
|
29
|
-
const raw = fs.readFileSync(KEYS_FILE, "utf-8");
|
|
30
|
-
const out = {};
|
|
31
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
32
|
-
const t = line.trim();
|
|
33
|
-
if (!t || t.startsWith("#")) continue;
|
|
34
|
-
const eq = t.indexOf("=");
|
|
35
|
-
if (eq < 0) continue;
|
|
36
|
-
const k = t.slice(0, eq).trim();
|
|
37
|
-
const v = t.slice(eq + 1);
|
|
38
|
-
if (k) out[k] = v;
|
|
39
|
-
}
|
|
40
|
-
return out;
|
|
41
|
-
} catch (e) {
|
|
42
|
-
console.error(`external-agents-spike: WARN — keys.env unreadable: ${e.message}`);
|
|
43
|
-
return {};
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function saveKeysFile(kv) {
|
|
48
|
-
const dir = path.dirname(KEYS_FILE);
|
|
49
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
-
const body = Object.entries(kv)
|
|
51
|
-
.filter(([k, v]) => k && typeof v === "string")
|
|
52
|
-
.map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
53
|
-
const tmp = KEYS_FILE + ".tmp." + process.pid + "." + Date.now();
|
|
54
|
-
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
55
|
-
fs.renameSync(tmp, KEYS_FILE);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function bootEnv() {
|
|
59
|
-
try {
|
|
60
|
-
// 1. UI-persisted keys (highest priority — operator explicitly set them)
|
|
61
|
-
const persisted = loadKeysFile();
|
|
62
|
-
for (const [k, v] of Object.entries(persisted)) {
|
|
63
|
-
if (!process.env[k]) process.env[k] = v;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// 2. Kilo auth store (legacy — DeepSeek key only; the Google proxy token
|
|
67
|
-
// in Kilo does NOT work against direct Google API)
|
|
68
|
-
const kiloAuthPath = path.join(os.homedir(), ".local/share/kilo/auth.json");
|
|
69
|
-
if (fs.existsSync(kiloAuthPath)) {
|
|
70
|
-
const kiloAuth = JSON.parse(fs.readFileSync(kiloAuthPath, "utf-8"));
|
|
71
|
-
if (!process.env.DEEPSEEK_API_KEY && kiloAuth.deepseek?.key) {
|
|
72
|
-
process.env.DEEPSEEK_API_KEY = kiloAuth.deepseek.key;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
// 3. llm-key store (AI Studio Gemini key, `AIza...` format)
|
|
76
|
-
const llmKeysPath = path.join(os.homedir(), "Library/Application Support/io.datasette.llm/keys.json");
|
|
77
|
-
if (!process.env.GEMINI_API_KEY || !process.env.GEMINI_API_KEY.startsWith("AIza")) {
|
|
78
|
-
if (fs.existsSync(llmKeysPath)) {
|
|
79
|
-
const llmKeys = JSON.parse(fs.readFileSync(llmKeysPath, "utf-8"));
|
|
80
|
-
if (llmKeys.gemini) process.env.GEMINI_API_KEY = llmKeys.gemini;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
} catch (e) {
|
|
84
|
-
console.error(`external-agents-spike: WARN — bootEnv failed: ${e.message}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
19
|
+
// Env-var boot injection lives in the shared credentials module (single source
|
|
20
|
+
// of truth for CLI + MCP server + UI). Priority: keys.env → Kilo auth store →
|
|
21
|
+
// llm keys. Never overrides an already-set env var.
|
|
22
|
+
import { KEYS_FILE, loadKeysFile, persistCredential, bootEnv } from "./lib/credentials.js";
|
|
87
23
|
bootEnv();
|
|
88
24
|
|
|
89
|
-
// Exposed for the set_credential MCP tool + /api/set_credential UI route.
|
|
90
|
-
export function persistCredential(envName, value) {
|
|
91
|
-
if (!envName || !/^[A-Z_][A-Z0-9_]*$/.test(envName)) throw new Error("invalid env var name");
|
|
92
|
-
const persisted = loadKeysFile();
|
|
93
|
-
persisted[envName] = value;
|
|
94
|
-
saveKeysFile(persisted);
|
|
95
|
-
// Also inject into the running process env so THIS session sees it immediately.
|
|
96
|
-
process.env[envName] = value;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
25
|
function findAgent(id) {
|
|
100
26
|
return REGISTRY.agents.find((a) => a.id === id);
|
|
101
27
|
}
|