@lunaroute/cli 0.2.1 → 0.2.3

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.
Files changed (3) hide show
  1. package/README.md +2 -3
  2. package/dist/index.js +382 -84
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -15,11 +15,10 @@ Requires Node.js 20+.
15
15
  ## Quickstart
16
16
 
17
17
  ```sh
18
- lunaroute login # authorize this device in your browser
19
- lunaroute setup pi # or: claude-code | opencode | copilot-cli | generic
18
+ lunaroute setup pi # or: opencode | claude-code | copilot-cli | generic
20
19
  ```
21
20
 
22
- `setup` asks before touching anything: for `pi` it first offers to install the LunaRoute Pi extension + MCP adapter (recommended — models then auto-sync), and falls back to a `~/.pi/agent/models.json` merge if you decline. It finishes with an `export LUNAROUTE_API_KEY=…` line — run it in your shell, then start your agent and it routes through LunaRoute.
21
+ One command from a fresh machine: `setup` asks before touching anything for `pi` it first offers to install the LunaRoute Pi extension + MCP adapter (recommended — models then auto-sync, and you sign in with `/login lunaroute` inside pi), then offers to start pi for you. `opencode` works the same way with its extension (`/connect` to sign in). Decline an extension — or pick a harness whose config needs a key — and `setup` offers to run `lunaroute login` right there (it opens your browser) before writing. The fallback config finishes with an `export LUNAROUTE_API_KEY=…` line — run it in your shell, then start your agent and it routes through LunaRoute.
23
22
 
24
23
  Prefer a script? `setup pi --extension` / `--models` pick a flow, `--yes` accepts all prompts, and `--print` previews without writing.
25
24
 
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command, InvalidArgumentError, Option } from "commander";
5
5
 
6
6
  // package.json
7
- var version = "0.2.1";
7
+ var version = "0.2.3";
8
8
 
9
9
  // src/login.ts
10
10
  import { createServer } from "http";
@@ -78,6 +78,71 @@ async function getUsage(ctx, limit) {
78
78
  const q = limit ? `?limit=${limit}` : "";
79
79
  return cliGet(ctx, `/v1/cli/usage${q}`);
80
80
  }
81
+ async function cliRequestRaw(ctx, method, path, body) {
82
+ const res = await fetch(`${ctx.apiUrl}${path}`, {
83
+ method,
84
+ headers: {
85
+ Authorization: `Bearer ${ctx.apiKey}`,
86
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {}
87
+ },
88
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
89
+ });
90
+ if (!res.ok) {
91
+ let detail = `HTTP ${res.status}`;
92
+ let envelope = false;
93
+ try {
94
+ const parsed2 = await res.json();
95
+ const code = parsed2?.error?.code;
96
+ const message = parsed2?.error?.message;
97
+ if (code) {
98
+ envelope = true;
99
+ detail = message ? `${code}: ${message}` : code;
100
+ } else if (message) {
101
+ detail = message;
102
+ }
103
+ } catch {
104
+ }
105
+ if (res.status === 404 && !envelope) {
106
+ throw new Error("this server does not support search-keys (server upgrade required)");
107
+ }
108
+ throw new Error(`request failed: ${detail}`);
109
+ }
110
+ const text = await res.text();
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(text);
114
+ } catch {
115
+ throw new Error("request failed: unexpected response body");
116
+ }
117
+ if (parsed?.success === false && parsed.error?.code) {
118
+ const { code, message } = parsed.error;
119
+ throw new Error(`request failed: ${message ? `${code}: ${message}` : code}`);
120
+ }
121
+ if (parsed?.success !== true || parsed.data === void 0) {
122
+ throw new Error("request failed: unexpected response body");
123
+ }
124
+ return text;
125
+ }
126
+ async function cliRequest(ctx, method, path, body) {
127
+ const text = await cliRequestRaw(ctx, method, path, body);
128
+ const parsed = JSON.parse(text);
129
+ return parsed.data;
130
+ }
131
+ async function listSearchProviders(ctx) {
132
+ return cliRequest(ctx, "GET", "/v1/cli/search-providers");
133
+ }
134
+ async function listSearchKeys(ctx) {
135
+ return cliRequest(ctx, "GET", "/v1/cli/search-provider-keys");
136
+ }
137
+ async function listSearchKeysRaw(ctx) {
138
+ return cliRequestRaw(ctx, "GET", "/v1/cli/search-provider-keys");
139
+ }
140
+ async function putSearchKey(ctx, provider, apiKey) {
141
+ return cliRequest(ctx, "PUT", "/v1/cli/search-provider-keys", { provider, api_key: apiKey });
142
+ }
143
+ async function deleteSearchKey(ctx, provider) {
144
+ await cliRequest(ctx, "DELETE", `/v1/cli/search-provider-keys?provider=${encodeURIComponent(provider)}`);
145
+ }
81
146
 
82
147
  // src/config.ts
83
148
  import { homedir } from "os";
@@ -206,6 +271,7 @@ async function startLoopbackServer() {
206
271
  const state = url.searchParams.get("state") ?? "";
207
272
  res.statusCode = 200;
208
273
  res.setHeader("Content-Type", "text/html");
274
+ res.setHeader("Access-Control-Allow-Origin", "*");
209
275
  res.end(
210
276
  "<html><body><h2>LunaRoute CLI authorized.</h2><p>You can close this tab and return to your terminal.</p></body></html>"
211
277
  );
@@ -400,9 +466,101 @@ async function confirm(question, opts = {}) {
400
466
  rl.close();
401
467
  }
402
468
  }
469
+ async function promptInput(question, opts = {}) {
470
+ if (!process.stdin.isTTY && opts.default === void 0) {
471
+ throw new NonInteractiveTerminalError(`${question} requires a TTY \u2014 pass a flag instead`);
472
+ }
473
+ if (!process.stdin.isTTY) {
474
+ return opts.default;
475
+ }
476
+ process.stdout.write(question + (opts.default !== void 0 ? ` [${opts.default}] ` : ": "));
477
+ const raw = !!opts.hidden && !!process.stdin.isTTY;
478
+ if (raw) {
479
+ process.stdin.setRawMode(true);
480
+ }
481
+ const chunks = [];
482
+ process.stdin.resume();
483
+ await new Promise((resolve) => {
484
+ const off = () => {
485
+ process.stdin.removeListener("data", onData);
486
+ process.stdin.pause();
487
+ };
488
+ const trimOneCodepoint = () => {
489
+ const all = Buffer.concat(chunks);
490
+ let end = all.length;
491
+ while (end > 0 && (all[end - 1] & 192) === 128) end--;
492
+ if (end > 0) end--;
493
+ chunks.length = 0;
494
+ if (end > 0) chunks.push(all.subarray(0, end));
495
+ };
496
+ const onData = (c) => {
497
+ let i = 0;
498
+ let runStart = -1;
499
+ const flushRun = (upto) => {
500
+ if (runStart >= 0) {
501
+ chunks.push(c.subarray(runStart, upto));
502
+ runStart = -1;
503
+ }
504
+ };
505
+ while (i < c.length) {
506
+ const b = c[i];
507
+ if (raw && b === 3) {
508
+ off();
509
+ process.stdin.setRawMode(false);
510
+ process.exit(130);
511
+ }
512
+ if (b === 13 || b === 10) {
513
+ flushRun(i);
514
+ off();
515
+ resolve();
516
+ return;
517
+ }
518
+ if (raw && (b === 127 || b === 8)) {
519
+ flushRun(i);
520
+ trimOneCodepoint();
521
+ i++;
522
+ continue;
523
+ }
524
+ if (runStart < 0) runStart = i;
525
+ i++;
526
+ }
527
+ flushRun(c.length);
528
+ };
529
+ process.stdin.on("data", onData);
530
+ });
531
+ if (raw) {
532
+ process.stdin.setRawMode(false);
533
+ process.stdout.write("\n");
534
+ }
535
+ const value = Buffer.concat(chunks).toString("utf8").trim();
536
+ return value === "" && opts.default !== void 0 ? opts.default : value;
537
+ }
403
538
 
404
- // src/commands/setup.ts
405
- import { spawn } from "child_process";
539
+ // src/spawn.ts
540
+ import { spawn as nodeSpawn } from "child_process";
541
+ import { spawn as crossSpawn } from "cross-spawn";
542
+ function makeRealSpawn(platform = process.platform) {
543
+ if (platform === "win32") {
544
+ return (command, args, env) => crossSpawn(command, args, { stdio: "inherit", env });
545
+ }
546
+ return (command, args, env) => nodeSpawn(command, args, { stdio: "inherit", env });
547
+ }
548
+ function describeSpawnError(command, installHint, err) {
549
+ const code = err?.code;
550
+ if (code === "ENOENT") {
551
+ return {
552
+ message: `Error: "${command}" not found on PATH. ${installHint} (exit 127)`,
553
+ exitCode: 127
554
+ };
555
+ }
556
+ if (code === "EINVAL") {
557
+ return {
558
+ message: `Error: "${command}" could not be launched \u2014 Node on Windows refuses to execute npm .cmd shims directly (EINVAL). The tool is likely installed; try running it manually. (exit 126)`,
559
+ exitCode: 126
560
+ };
561
+ }
562
+ return { message: err instanceof Error ? err.message : String(err), exitCode: 1 };
563
+ }
406
564
 
407
565
  // src/setup/paths.ts
408
566
  import { execSync } from "child_process";
@@ -657,24 +815,24 @@ function buildPlan5(ctx) {
657
815
 
658
816
  // src/setup/routing-url.ts
659
817
  function validatedRoutingUrl(routingUrl) {
660
- function fail(reason) {
818
+ function fail2(reason) {
661
819
  throw new Error(
662
820
  `routingUrl rejected (reason: ${reason}; <redacted URL>) \u2014 check --routing-url or the profile's routing URL`
663
821
  );
664
822
  }
665
- if (!/^https?:\/\/[^/?#]/.test(routingUrl)) fail("invalid-url");
666
- if (/[?#]/.test(routingUrl)) fail("query-or-fragment");
667
- if (routingUrl !== routingUrl.trim()) fail("whitespace");
668
- if (routingUrl.endsWith("/")) fail("trailing-slash");
823
+ if (!/^https?:\/\/[^/?#]/.test(routingUrl)) fail2("invalid-url");
824
+ if (/[?#]/.test(routingUrl)) fail2("query-or-fragment");
825
+ if (routingUrl !== routingUrl.trim()) fail2("whitespace");
826
+ if (routingUrl.endsWith("/")) fail2("trailing-slash");
669
827
  let parsed;
670
828
  try {
671
829
  parsed = new URL(routingUrl);
672
830
  } catch {
673
- fail("invalid-url");
831
+ fail2("invalid-url");
674
832
  }
675
- if (parsed.username !== "" || parsed.password !== "") fail("userinfo");
676
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") fail("invalid-url");
677
- if (parsed.hostname === "") fail("invalid-url");
833
+ if (parsed.username !== "" || parsed.password !== "") fail2("userinfo");
834
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") fail2("invalid-url");
835
+ if (parsed.hostname === "") fail2("invalid-url");
678
836
  return routingUrl;
679
837
  }
680
838
 
@@ -750,7 +908,8 @@ var ADAPTERS = {
750
908
  var PI_INSTALL_PACKAGES = ["npm:@lunaroute/pi-extension", "npm:pi-mcp-adapter"];
751
909
  async function runSetup(harness, opts, deps = {
752
910
  confirm,
753
- spawn: (command, args) => spawn(command, args, { stdio: "inherit" })
911
+ spawn: makeRealSpawn(),
912
+ runLogin
754
913
  }) {
755
914
  if (harness === "pi" && opts.extension && opts.models) {
756
915
  console.error("Choose one of --extension or --models.");
@@ -763,12 +922,9 @@ async function runSetup(harness, opts, deps = {
763
922
  );
764
923
  return 1;
765
924
  }
766
- const creds = loadProfile(opts.profile);
767
- if (!creds || !creds.routing_key) {
768
- console.error('Not logged in. Run "lunaroute login" first.');
769
- return 1;
770
- }
771
- const routingUrlRaw = opts.routingUrl || creds.routing_url;
925
+ const stored = loadProfile(opts.profile);
926
+ const settings = resolveSettings(opts.profile);
927
+ const routingUrlRaw = opts.routingUrl || stored?.routing_url || settings.routing_url;
772
928
  if (!routingUrlRaw) {
773
929
  console.error("No routing URL in profile; pass --routing-url.");
774
930
  return 1;
@@ -781,11 +937,13 @@ async function runSetup(harness, opts, deps = {
781
937
  return 1;
782
938
  }
783
939
  if (harness === "pi" && !opts.print) {
784
- return runPiSetup(opts, deps);
940
+ return runPiSetup(opts, stored, routingUrl, deps);
785
941
  }
786
942
  if (harness === "opencode") {
787
- return runOpencodeSetup(creds, routingUrl, opts, deps);
943
+ return runOpencodeSetup(stored, settings, routingUrl, opts, deps);
788
944
  }
945
+ const creds = await requireCreds(opts, deps, stored);
946
+ if (!creds) return 1;
789
947
  if (harness === "hermes") {
790
948
  return runHermesSetup(creds, routingUrl, opts, deps);
791
949
  }
@@ -822,7 +980,39 @@ Wrote: ${summary.written.join(", ")}`);
822
980
  return 1;
823
981
  }
824
982
  }
825
- async function runPiSetup(opts, deps) {
983
+ var NOT_LOGGED_IN = 'Not logged in. Run "lunaroute login" first.';
984
+ var OPENCODE_LOGIN_HINT = "Or re-run and choose the extension \u2014 it logs in via /connect inside opencode.";
985
+ var PI_LOGIN_HINT = "Or re-run and choose the extension \u2014 login happens via /login lunaroute inside pi.";
986
+ async function requireCreds(opts, deps, stored, hint) {
987
+ if (stored?.routing_key) return stored;
988
+ const decline = [NOT_LOGGED_IN, hint].filter(Boolean).join(" ");
989
+ try {
990
+ const ok = await deps.confirm('Not logged in. Run "lunaroute login" now? (opens your browser)');
991
+ if (!ok) {
992
+ console.error(decline);
993
+ return null;
994
+ }
995
+ } catch (err) {
996
+ if (err instanceof NonInteractiveTerminalError) {
997
+ console.error(decline);
998
+ return null;
999
+ }
1000
+ throw err;
1001
+ }
1002
+ try {
1003
+ await deps.runLogin(opts.profile);
1004
+ } catch (err) {
1005
+ console.error(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
1006
+ return null;
1007
+ }
1008
+ const fresh = loadProfile(opts.profile);
1009
+ if (!fresh?.routing_key) {
1010
+ console.error(NOT_LOGGED_IN);
1011
+ return null;
1012
+ }
1013
+ return fresh;
1014
+ }
1015
+ async function runPiSetup(opts, stored, routingUrl, deps) {
826
1016
  try {
827
1017
  if (opts.extension) {
828
1018
  const ok = await deps.confirm(
@@ -834,19 +1024,28 @@ async function runPiSetup(opts, deps) {
834
1024
  console.log(` pi install ${PI_INSTALL_PACKAGES.join(" && pi install ")}`);
835
1025
  return 0;
836
1026
  }
837
- return installPiExtension(deps.spawn);
1027
+ return installPiExtension(deps, opts);
838
1028
  }
839
1029
  if (opts.models) {
840
- return applyPiModels(opts, await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", { yes: opts.yes }));
1030
+ const creds2 = await requireCreds(opts, deps, stored, PI_LOGIN_HINT);
1031
+ if (!creds2) return 1;
1032
+ return applyPiModels(
1033
+ creds2,
1034
+ routingUrl,
1035
+ await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", { yes: opts.yes })
1036
+ );
841
1037
  }
842
1038
  if (await deps.confirm(
843
1039
  "Install the LunaRoute Pi extension + MCP adapter via 'pi install' (recommended \u2014 auto-registers models)?",
844
1040
  { yes: opts.yes }
845
1041
  )) {
846
- return installPiExtension(deps.spawn);
1042
+ return installPiExtension(deps, opts);
847
1043
  }
1044
+ const creds = await requireCreds(opts, deps, stored, PI_LOGIN_HINT);
1045
+ if (!creds) return 1;
848
1046
  return applyPiModels(
849
- opts,
1047
+ creds,
1048
+ routingUrl,
850
1049
  await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", {
851
1050
  yes: opts.yes
852
1051
  })
@@ -899,23 +1098,20 @@ function hermesConfigSetArgs(routingUrl, key) {
899
1098
  ["config", "set", "LUNAROUTE_API_KEY", key]
900
1099
  ];
901
1100
  }
902
- async function runHermesConfigSets(spawn3, routingUrl, key) {
1101
+ async function runHermesConfigSets(spawn, routingUrl, key) {
903
1102
  const commands = hermesConfigSetArgs(routingUrl, key);
904
1103
  const setKeys = commands.map((c) => c[2]);
905
1104
  for (let i = 0; i < commands.length; i++) {
906
1105
  const code = await new Promise((resolve) => {
907
- const child = spawn3("hermes", commands[i]);
1106
+ const child = spawn("hermes", commands[i]);
908
1107
  child.on("error", (err) => {
909
- const e = err;
910
- if (e?.code === "ENOENT") {
911
- console.error(
912
- `Error: "hermes" not found on PATH. Install Hermes Agent first: https://hermes-agent.nousresearch.com (exit 127)`
913
- );
914
- resolve(127);
915
- return;
916
- }
917
- console.error(err instanceof Error ? err.message : String(err));
918
- resolve(1);
1108
+ const { message, exitCode } = describeSpawnError(
1109
+ "hermes",
1110
+ "Install Hermes Agent first: https://hermes-agent.nousresearch.com",
1111
+ err
1112
+ );
1113
+ console.error(message);
1114
+ resolve(exitCode);
919
1115
  });
920
1116
  child.on("exit", (code2) => {
921
1117
  resolve(typeof code2 === "number" ? code2 : 1);
@@ -934,24 +1130,24 @@ async function runHermesConfigSets(spawn3, routingUrl, key) {
934
1130
  console.log(" hermes model # interactive provider/model picker");
935
1131
  return 0;
936
1132
  }
937
- async function runOpencodeSetup(creds, routingUrl, opts, deps) {
1133
+ async function runOpencodeSetup(stored, settings, routingUrl, opts, deps) {
938
1134
  try {
939
1135
  if (opts.print) {
940
1136
  const plan = buildPlan(
941
- { routingUrl, orgId: creds.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
1137
+ { routingUrl, orgId: settings.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
942
1138
  { extension: true }
943
1139
  );
944
- return applyAndReport(plan, creds.routing_key, false);
1140
+ return applyAndReport(plan, stored?.routing_key ?? "", false);
945
1141
  }
946
1142
  if (await deps.confirm(
947
1143
  "Install the LunaRoute OpenCode extension (recommended \u2014 /connect login, models auto-sync)?",
948
1144
  { yes: opts.yes }
949
1145
  )) {
950
1146
  const plan = buildPlan(
951
- { routingUrl, orgId: creds.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
1147
+ { routingUrl, orgId: settings.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
952
1148
  { extension: true }
953
1149
  );
954
- const code = await applyAndReport(plan, creds.routing_key, !opts.print);
1150
+ const code = await applyAndReport(plan, stored?.routing_key ?? "", true);
955
1151
  if (code !== 0) return code;
956
1152
  if (await deps.confirm("Start opencode now? (if it's already open, quit and reopen it to load the extension)", {
957
1153
  yes: opts.yes
@@ -961,6 +1157,8 @@ async function runOpencodeSetup(creds, routingUrl, opts, deps) {
961
1157
  console.log("\nStart opencode when ready \u2014 then run /connect to log in and /models to pick a LunaRoute model.");
962
1158
  return 0;
963
1159
  }
1160
+ const creds = await requireCreds(opts, deps, stored, OPENCODE_LOGIN_HINT);
1161
+ if (!creds) return 1;
964
1162
  const write = await deps.confirm(
965
1163
  "Merge LunaRoute provider into opencode.json instead? (backup kept, other providers preserved)",
966
1164
  { yes: opts.yes }
@@ -1007,37 +1205,35 @@ Wrote: ${summary.written.join(", ")}`);
1007
1205
  return 1;
1008
1206
  }
1009
1207
  }
1010
- async function launchOpencode(spawn3) {
1208
+ async function launchOpencode(spawn) {
1011
1209
  return new Promise((resolve) => {
1012
- const child = spawn3("opencode", []);
1210
+ const child = spawn("opencode", []);
1013
1211
  child.on("error", (err) => {
1014
- const e = err;
1015
- if (e?.code === "ENOENT") {
1016
- console.error(`Error: "opencode" not found on PATH. Install OpenCode first: https://opencode.ai (exit 127)`);
1017
- resolve(127);
1018
- return;
1019
- }
1020
- console.error(err instanceof Error ? err.message : String(err));
1021
- resolve(1);
1212
+ const { message, exitCode } = describeSpawnError(
1213
+ "opencode",
1214
+ "Install OpenCode first: https://opencode.ai",
1215
+ err
1216
+ );
1217
+ console.error(message);
1218
+ resolve(exitCode);
1022
1219
  });
1023
1220
  child.on("exit", (code) => {
1024
1221
  resolve(typeof code === "number" ? code : 1);
1025
1222
  });
1026
1223
  });
1027
1224
  }
1028
- async function installPiExtension(spawn3) {
1225
+ async function installPiExtension(deps, opts) {
1029
1226
  for (const pkg of PI_INSTALL_PACKAGES) {
1030
1227
  const code = await new Promise((resolve) => {
1031
- const child = spawn3("pi", ["install", pkg]);
1228
+ const child = deps.spawn("pi", ["install", pkg]);
1032
1229
  child.on("error", (err) => {
1033
- const e = err;
1034
- if (e?.code === "ENOENT") {
1035
- console.error(`Error: "pi" not found on PATH. Install pi first: https://pi.dev (exit 127)`);
1036
- resolve(127);
1037
- return;
1038
- }
1039
- console.error(err instanceof Error ? err.message : String(err));
1040
- resolve(1);
1230
+ const { message, exitCode } = describeSpawnError(
1231
+ "pi",
1232
+ "Install pi first: https://pi.dev",
1233
+ err
1234
+ );
1235
+ console.error(message);
1236
+ resolve(exitCode);
1041
1237
  });
1042
1238
  child.on("exit", (code2) => {
1043
1239
  resolve(typeof code2 === "number" ? code2 : 1);
@@ -1051,22 +1247,41 @@ async function installPiExtension(spawn3) {
1051
1247
  return code || 1;
1052
1248
  }
1053
1249
  }
1054
- console.log("\nInstalled. Next steps inside pi:");
1250
+ if (await deps.confirm("Start pi now? (then run /login lunaroute to sign in)", { yes: opts.yes })) {
1251
+ return launchPi(deps.spawn);
1252
+ }
1253
+ console.log("\nStart pi when ready \u2014 then inside pi:");
1055
1254
  console.log(" 1. /login lunaroute \u2014 browser login issues and stores an lr_ key.");
1056
1255
  console.log(" 2. /model \u2014 pick a lunaroute/* model (models auto-sync from /v1/models).");
1057
1256
  return 0;
1058
1257
  }
1059
- async function applyPiModels(opts, write) {
1060
- const creds = loadProfile(opts.profile);
1258
+ async function launchPi(spawn) {
1259
+ return new Promise((resolve) => {
1260
+ const child = spawn("pi", []);
1261
+ child.on("error", (err) => {
1262
+ const { message, exitCode } = describeSpawnError(
1263
+ "pi",
1264
+ "Install pi first: https://pi.dev",
1265
+ err
1266
+ );
1267
+ console.error(message);
1268
+ resolve(exitCode);
1269
+ });
1270
+ child.on("exit", (code) => {
1271
+ resolve(typeof code === "number" ? code : 1);
1272
+ });
1273
+ });
1274
+ }
1275
+ async function applyPiModels(creds, routingUrl, write) {
1061
1276
  let models;
1062
1277
  try {
1063
- models = await fetchModels(opts.routingUrl || creds.routing_url || "");
1278
+ models = await fetchModels(routingUrl);
1064
1279
  } catch (err) {
1065
1280
  console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
1066
1281
  return 1;
1067
1282
  }
1068
1283
  const plan = buildPlan2({
1069
- routingUrl: opts.routingUrl || creds.routing_url,
1284
+ routingUrl,
1070
1285
  orgId: creds.org_id,
1071
1286
  models,
1072
1287
  keyEnvVar: "LUNAROUTE_API_KEY"
@@ -1260,12 +1475,12 @@ async function readMemory(ctx, p) {
1260
1475
  }
1261
1476
 
1262
1477
  // src/commands/memory.ts
1263
- var NOT_LOGGED_IN = 'Not logged in. Run "lunaroute login" first.';
1478
+ var NOT_LOGGED_IN2 = 'Not logged in. Run "lunaroute login" first.';
1264
1479
  var NO_PROJECT = "No project context: run inside a git repo with an origin remote, or set LUNAROUTE_PROJECT_ID.";
1265
1480
  function contextFor(profile) {
1266
1481
  const s = resolveSettings(profile);
1267
1482
  if (!s.routing_key) {
1268
- console.error(NOT_LOGGED_IN);
1483
+ console.error(NOT_LOGGED_IN2);
1269
1484
  return 1;
1270
1485
  }
1271
1486
  const projectId = resolveProjectId();
@@ -1333,6 +1548,86 @@ async function runMemoryRead(profile, id, opts) {
1333
1548
  return 0;
1334
1549
  }
1335
1550
 
1551
+ // src/commands/searchKeys.ts
1552
+ var notLoggedIn = () => {
1553
+ console.error('Not logged in. Run "lunaroute login" first.');
1554
+ return 1;
1555
+ };
1556
+ var fail = (err) => {
1557
+ console.error(err instanceof Error ? err.message : String(err));
1558
+ return 1;
1559
+ };
1560
+ async function runSearchKeysList(profile, opts) {
1561
+ const s = resolveSettings(profile);
1562
+ if (!s.routing_key) return notLoggedIn();
1563
+ const ctx = { apiUrl: s.api_url, apiKey: s.routing_key };
1564
+ try {
1565
+ if (opts.json) {
1566
+ console.log(await listSearchKeysRaw(ctx));
1567
+ return 0;
1568
+ }
1569
+ const keys = await listSearchKeys(ctx);
1570
+ if (keys.length === 0) {
1571
+ console.log("No search provider keys configured.");
1572
+ return 0;
1573
+ }
1574
+ console.log(
1575
+ renderTable(
1576
+ ["PROVIDER", "CREATED", "UPDATED"],
1577
+ keys.map((k) => [k.provider, k.created_at, k.updated_at])
1578
+ )
1579
+ );
1580
+ return 0;
1581
+ } catch (err) {
1582
+ return fail(err);
1583
+ }
1584
+ }
1585
+ async function runSearchKeysSet(profile, opts) {
1586
+ const s = resolveSettings(profile);
1587
+ if (!s.routing_key) return notLoggedIn();
1588
+ const ctx = { apiUrl: s.api_url, apiKey: s.routing_key };
1589
+ try {
1590
+ let provider = opts.provider;
1591
+ let apiKey = opts.apiKey ?? process.env.LUNAROUTE_SEARCH_API_KEY;
1592
+ const providers = await listSearchProviders(ctx);
1593
+ const known = providers.map((p) => p.key);
1594
+ if (!provider) {
1595
+ provider = await promptInput(`Provider (${known.join(", ")})`);
1596
+ }
1597
+ if (!known.includes(provider)) {
1598
+ console.error(`unknown provider "${provider}" \u2014 this server offers: ${known.join(", ")}`);
1599
+ return 1;
1600
+ }
1601
+ if (!apiKey) {
1602
+ apiKey = await promptInput("API key", { hidden: true });
1603
+ }
1604
+ const meta = await putSearchKey(ctx, provider, apiKey);
1605
+ console.log(`Stored key for ${meta.provider} (updated ${meta.updated_at}).`);
1606
+ return 0;
1607
+ } catch (err) {
1608
+ return fail(err);
1609
+ }
1610
+ }
1611
+ async function runSearchKeysRemove(profile, provider, opts) {
1612
+ const s = resolveSettings(profile);
1613
+ if (!s.routing_key) return notLoggedIn();
1614
+ const ctx = { apiUrl: s.api_url, apiKey: s.routing_key };
1615
+ try {
1616
+ if (!opts.yes) {
1617
+ const ok = await confirm(`Remove the ${provider} search key?`);
1618
+ if (!ok) {
1619
+ console.log("Aborted.");
1620
+ return 0;
1621
+ }
1622
+ }
1623
+ await deleteSearchKey(ctx, provider);
1624
+ console.log(`Removed key for ${provider}.`);
1625
+ return 0;
1626
+ } catch (err) {
1627
+ return fail(err);
1628
+ }
1629
+ }
1630
+
1336
1631
  // src/mcp.ts
1337
1632
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1338
1633
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -1513,9 +1808,6 @@ async function runSkillInstall(profile, opts) {
1513
1808
  return 0;
1514
1809
  }
1515
1810
 
1516
- // src/commands/run.ts
1517
- import { spawn as spawn2 } from "child_process";
1518
-
1519
1811
  // src/run/adapters/claudeCode.ts
1520
1812
  function buildRunSpec(ctx) {
1521
1813
  return {
@@ -1562,7 +1854,7 @@ var INSTALL_HINT = {
1562
1854
  codex: "Install Codex: https://github.com/openai/codex#install"
1563
1855
  };
1564
1856
  var realDeps = {
1565
- spawn: (command, args, env) => spawn2(command, args, { env, stdio: "inherit" }),
1857
+ spawn: makeRealSpawn(),
1566
1858
  fetchModels
1567
1859
  };
1568
1860
  async function runRun(harness, opts, deps = realDeps) {
@@ -1602,16 +1894,9 @@ async function runRun(harness, opts, deps = realDeps) {
1602
1894
  const child = deps.spawn(spec.command, args, env);
1603
1895
  return new Promise((resolve) => {
1604
1896
  child.on("error", (err) => {
1605
- const e = err;
1606
- if (e?.code === "ENOENT") {
1607
- console.error(
1608
- `Error: "${spec.command}" not found on PATH. ${INSTALL_HINT[harness]} (exit 127)`
1609
- );
1610
- resolve(127);
1611
- return;
1612
- }
1613
- console.error(err instanceof Error ? err.message : String(err));
1614
- resolve(1);
1897
+ const { message, exitCode } = describeSpawnError(spec.command, INSTALL_HINT[harness], err);
1898
+ console.error(message);
1899
+ resolve(exitCode);
1615
1900
  });
1616
1901
  child.on("exit", (code) => {
1617
1902
  resolve(typeof code === "number" ? code : 1);
@@ -1700,6 +1985,19 @@ skill.command("install").description("Install the LunaRoute memory skill + MCP s
1700
1985
  const code = await runSkillInstall(program.opts().profile, opts);
1701
1986
  if (code !== 0) process.exit(code);
1702
1987
  });
1988
+ var searchKeys = program.command("search-keys").description("Manage web-search provider keys (BYOK) for your organization.");
1989
+ searchKeys.command("list").description("List configured search provider keys.").option("--json", "output the raw API envelope", false).action(async (opts) => {
1990
+ const code = await runSearchKeysList(program.opts().profile, { json: opts.json });
1991
+ if (code !== 0) process.exit(code);
1992
+ });
1993
+ searchKeys.command("set").description("Store or replace a search provider key (prompts for missing values).").option("--provider <key>", "provider key (e.g. kagi, brave, exa)").option("--api-key <key>", "API key (visible in shell history \u2014 prefer the prompt or LUNAROUTE_SEARCH_API_KEY)").action(async (opts) => {
1994
+ const code = await runSearchKeysSet(program.opts().profile, { provider: opts.provider, apiKey: opts.apiKey });
1995
+ if (code !== 0) process.exit(code);
1996
+ });
1997
+ searchKeys.command("remove <provider>").description("Remove a search provider key.").option("--yes", "skip the confirmation prompt", false).action(async (provider, opts) => {
1998
+ const code = await runSearchKeysRemove(program.opts().profile, provider, { yes: opts.yes });
1999
+ if (code !== 0) process.exit(code);
2000
+ });
1703
2001
  program.parseAsync(process.argv).catch((err) => {
1704
2002
  console.error(err instanceof Error ? err.message : err);
1705
2003
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunaroute/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "LunaRoute CLI — configure coding harnesses and manage your LunaRoute account.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,10 +30,12 @@
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "commander": "^15.0.0",
33
+ "cross-spawn": "^7.0.6",
33
34
  "open": "^11.0.1",
34
35
  "zod": "^4.4.3"
35
36
  },
36
37
  "devDependencies": {
38
+ "@types/cross-spawn": "^6.0.6",
37
39
  "@types/node": "^26.2.0",
38
40
  "tsup": "^8.3.0",
39
41
  "tsx": "^4.23.12",