@mrrlin-dev/external-agents 0.2.4 → 0.2.5

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/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
- // bootEnv duplicated from server.js so CLI + server load the same env context.
27
- function bootEnv() {
28
- try {
29
- const kiloAuthPath = path.join(os.homedir(), ".local/share/kilo/auth.json");
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.4",
3
+ "version": "0.2.5",
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. Look for credentials in known local stores and populate
20
- // process.env for aider/other CLIs. Never overrides an already-set var.
21
- // UI-set persisted keys loaded FIRST (highest precedence) so a value the
22
- // operator saved via /api/set_credential wins over whatever legacy stores
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
  }