@lunaroute/cli 0.2.0 → 0.2.2

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 +425 -51
  3. package/package.json +1 -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
@@ -3,6 +3,9 @@
3
3
  // src/index.ts
4
4
  import { Command, InvalidArgumentError, Option } from "commander";
5
5
 
6
+ // package.json
7
+ var version = "0.2.2";
8
+
6
9
  // src/login.ts
7
10
  import { createServer } from "http";
8
11
  import { hostname } from "os";
@@ -75,6 +78,71 @@ async function getUsage(ctx, limit) {
75
78
  const q = limit ? `?limit=${limit}` : "";
76
79
  return cliGet(ctx, `/v1/cli/usage${q}`);
77
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
+ }
78
146
 
79
147
  // src/config.ts
80
148
  import { homedir } from "os";
@@ -203,6 +271,7 @@ async function startLoopbackServer() {
203
271
  const state = url.searchParams.get("state") ?? "";
204
272
  res.statusCode = 200;
205
273
  res.setHeader("Content-Type", "text/html");
274
+ res.setHeader("Access-Control-Allow-Origin", "*");
206
275
  res.end(
207
276
  "<html><body><h2>LunaRoute CLI authorized.</h2><p>You can close this tab and return to your terminal.</p></body></html>"
208
277
  );
@@ -397,6 +466,75 @@ async function confirm(question, opts = {}) {
397
466
  rl.close();
398
467
  }
399
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
+ }
400
538
 
401
539
  // src/commands/setup.ts
402
540
  import { spawn } from "child_process";
@@ -588,21 +726,24 @@ function buildPlan3(ctx) {
588
726
  { name: ctx.keyEnvVar, value: "__KEY__" },
589
727
  // Point at the routing root; Claude Code appends /v1/messages itself.
590
728
  { name: "ANTHROPIC_BASE_URL", value: ctx.routingUrl },
591
- // Claude Code sends ANTHROPIC_API_KEY as the native x-api-key header. The
592
- // routing edge recognizes the lr_ prefix, authenticates it, and strips the
593
- // header before forwarding upstream — so the key never leaks and never
594
- // sits in the URL path.
595
- { name: "ANTHROPIC_API_KEY", value: `$${ctx.keyEnvVar}` },
729
+ // Claude Code sends ANTHROPIC_AUTH_TOKEN as `Authorization: Bearer`. The
730
+ // routing edge recognizes the lr_ prefix, authenticates it, and strips
731
+ // the header before forwarding upstream — so the key never leaks and
732
+ // never sits in the URL path. Bearer also outranks a stored claude.ai
733
+ // subscription OAuth login; ANTHROPIC_API_KEY does not (unapproved env
734
+ // keys are ignored and the OAuth token 401s through the gateway, kata
735
+ // 009h).
736
+ { name: "ANTHROPIC_AUTH_TOKEN", value: `$${ctx.keyEnvVar}` },
596
737
  { name: "ANTHROPIC_MODEL", value: firstModel }
597
738
  ],
598
739
  notes: [
599
740
  "\nClaude Code: add the exports above to your shell profile, then restart Claude Code.",
600
741
  `Change ANTHROPIC_MODEL to any of: ${ctx.models.map((m) => m.id).join(", ")}`,
601
- "(Auth travels in the native x-api-key header via ANTHROPIC_API_KEY.)",
602
- "If your Claude Code build requires a bearer token instead, set ANTHROPIC_AUTH_TOKEN=$LUNAROUTE_API_KEY \u2014 LunaRoute also authenticates lr_ keys sent as Authorization: Bearer.",
742
+ "(Auth travels in the Authorization: Bearer header via ANTHROPIC_AUTH_TOKEN.)",
743
+ "If your Claude Code build predates ANTHROPIC_AUTH_TOKEN support, set ANTHROPIC_API_KEY=$LUNAROUTE_API_KEY instead \u2014 LunaRoute also authenticates lr_ keys sent as x-api-key. Bearer wins whenever both are set.",
603
744
  "To set these in ~/.claude/settings.json instead, note its env values are literal \u2014 the key would be baked into the file rather than read from $LUNAROUTE_API_KEY.",
604
745
  "If Claude Code hangs or keeps retrying when connecting, your key is likely wrong \u2014 it retries auth errors silently. Debug with the curl below, which fails fast with the real error:",
605
- ` curl ${ctx.routingUrl}/v1/messages -H "x-api-key: $${ctx.keyEnvVar}" -H "content-type: application/json" -d '{"model":"${firstModel}","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}'`
746
+ ` curl ${ctx.routingUrl}/v1/messages -H "Authorization: Bearer $${ctx.keyEnvVar}" -H "content-type: application/json" -d '{"model":"${firstModel}","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}'`
606
747
  ]
607
748
  };
608
749
  }
@@ -651,24 +792,24 @@ function buildPlan5(ctx) {
651
792
 
652
793
  // src/setup/routing-url.ts
653
794
  function validatedRoutingUrl(routingUrl) {
654
- function fail(reason) {
795
+ function fail2(reason) {
655
796
  throw new Error(
656
797
  `routingUrl rejected (reason: ${reason}; <redacted URL>) \u2014 check --routing-url or the profile's routing URL`
657
798
  );
658
799
  }
659
- if (!/^https?:\/\/[^/?#]/.test(routingUrl)) fail("invalid-url");
660
- if (/[?#]/.test(routingUrl)) fail("query-or-fragment");
661
- if (routingUrl !== routingUrl.trim()) fail("whitespace");
662
- if (routingUrl.endsWith("/")) fail("trailing-slash");
800
+ if (!/^https?:\/\/[^/?#]/.test(routingUrl)) fail2("invalid-url");
801
+ if (/[?#]/.test(routingUrl)) fail2("query-or-fragment");
802
+ if (routingUrl !== routingUrl.trim()) fail2("whitespace");
803
+ if (routingUrl.endsWith("/")) fail2("trailing-slash");
663
804
  let parsed;
664
805
  try {
665
806
  parsed = new URL(routingUrl);
666
807
  } catch {
667
- fail("invalid-url");
808
+ fail2("invalid-url");
668
809
  }
669
- if (parsed.username !== "" || parsed.password !== "") fail("userinfo");
670
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") fail("invalid-url");
671
- if (parsed.hostname === "") fail("invalid-url");
810
+ if (parsed.username !== "" || parsed.password !== "") fail2("userinfo");
811
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") fail2("invalid-url");
812
+ if (parsed.hostname === "") fail2("invalid-url");
672
813
  return routingUrl;
673
814
  }
674
815
 
@@ -744,23 +885,23 @@ var ADAPTERS = {
744
885
  var PI_INSTALL_PACKAGES = ["npm:@lunaroute/pi-extension", "npm:pi-mcp-adapter"];
745
886
  async function runSetup(harness, opts, deps = {
746
887
  confirm,
747
- spawn: (command, args) => spawn(command, args, { stdio: "inherit" })
888
+ spawn: (command, args) => spawn(command, args, { stdio: "inherit" }),
889
+ runLogin
748
890
  }) {
749
891
  if (harness === "pi" && opts.extension && opts.models) {
750
892
  console.error("Choose one of --extension or --models.");
751
893
  return 1;
752
894
  }
753
895
  const adapter = ADAPTERS[harness];
754
- if (!adapter) {
755
- console.error(`Unknown harness "${harness}". Choose one of: ${Object.keys(ADAPTERS).join(", ")}.`);
756
- return 1;
757
- }
758
- const creds = loadProfile(opts.profile);
759
- if (!creds || !creds.routing_key) {
760
- console.error('Not logged in. Run "lunaroute login" first.');
896
+ if (!adapter && harness !== "hermes") {
897
+ console.error(
898
+ `Unknown harness "${harness}". Choose one of: ${[...Object.keys(ADAPTERS), "hermes"].join(", ")}.`
899
+ );
761
900
  return 1;
762
901
  }
763
- const routingUrlRaw = opts.routingUrl || creds.routing_url;
902
+ const stored = loadProfile(opts.profile);
903
+ const settings = resolveSettings(opts.profile);
904
+ const routingUrlRaw = opts.routingUrl || stored?.routing_url || settings.routing_url;
764
905
  if (!routingUrlRaw) {
765
906
  console.error("No routing URL in profile; pass --routing-url.");
766
907
  return 1;
@@ -773,10 +914,15 @@ async function runSetup(harness, opts, deps = {
773
914
  return 1;
774
915
  }
775
916
  if (harness === "pi" && !opts.print) {
776
- return runPiSetup(opts, deps);
917
+ return runPiSetup(opts, stored, routingUrl, deps);
777
918
  }
778
919
  if (harness === "opencode") {
779
- return runOpencodeSetup(creds, routingUrl, opts, deps);
920
+ return runOpencodeSetup(stored, settings, routingUrl, opts, deps);
921
+ }
922
+ const creds = await requireCreds(opts, deps, stored);
923
+ if (!creds) return 1;
924
+ if (harness === "hermes") {
925
+ return runHermesSetup(creds, routingUrl, opts, deps);
780
926
  }
781
927
  let models;
782
928
  try {
@@ -811,7 +957,39 @@ Wrote: ${summary.written.join(", ")}`);
811
957
  return 1;
812
958
  }
813
959
  }
814
- async function runPiSetup(opts, deps) {
960
+ var NOT_LOGGED_IN = 'Not logged in. Run "lunaroute login" first.';
961
+ var OPENCODE_LOGIN_HINT = "Or re-run and choose the extension \u2014 it logs in via /connect inside opencode.";
962
+ var PI_LOGIN_HINT = "Or re-run and choose the extension \u2014 login happens via /login lunaroute inside pi.";
963
+ async function requireCreds(opts, deps, stored, hint) {
964
+ if (stored?.routing_key) return stored;
965
+ const decline = [NOT_LOGGED_IN, hint].filter(Boolean).join(" ");
966
+ try {
967
+ const ok = await deps.confirm('Not logged in. Run "lunaroute login" now? (opens your browser)');
968
+ if (!ok) {
969
+ console.error(decline);
970
+ return null;
971
+ }
972
+ } catch (err) {
973
+ if (err instanceof NonInteractiveTerminalError) {
974
+ console.error(decline);
975
+ return null;
976
+ }
977
+ throw err;
978
+ }
979
+ try {
980
+ await deps.runLogin(opts.profile);
981
+ } catch (err) {
982
+ console.error(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
983
+ return null;
984
+ }
985
+ const fresh = loadProfile(opts.profile);
986
+ if (!fresh?.routing_key) {
987
+ console.error(NOT_LOGGED_IN);
988
+ return null;
989
+ }
990
+ return fresh;
991
+ }
992
+ async function runPiSetup(opts, stored, routingUrl, deps) {
815
993
  try {
816
994
  if (opts.extension) {
817
995
  const ok = await deps.confirm(
@@ -823,19 +1001,28 @@ async function runPiSetup(opts, deps) {
823
1001
  console.log(` pi install ${PI_INSTALL_PACKAGES.join(" && pi install ")}`);
824
1002
  return 0;
825
1003
  }
826
- return installPiExtension(deps.spawn);
1004
+ return installPiExtension(deps, opts);
827
1005
  }
828
1006
  if (opts.models) {
829
- return applyPiModels(opts, await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", { yes: opts.yes }));
1007
+ const creds2 = await requireCreds(opts, deps, stored, PI_LOGIN_HINT);
1008
+ if (!creds2) return 1;
1009
+ return applyPiModels(
1010
+ creds2,
1011
+ routingUrl,
1012
+ await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", { yes: opts.yes })
1013
+ );
830
1014
  }
831
1015
  if (await deps.confirm(
832
1016
  "Install the LunaRoute Pi extension + MCP adapter via 'pi install' (recommended \u2014 auto-registers models)?",
833
1017
  { yes: opts.yes }
834
1018
  )) {
835
- return installPiExtension(deps.spawn);
1019
+ return installPiExtension(deps, opts);
836
1020
  }
1021
+ const creds = await requireCreds(opts, deps, stored, PI_LOGIN_HINT);
1022
+ if (!creds) return 1;
837
1023
  return applyPiModels(
838
- opts,
1024
+ creds,
1025
+ routingUrl,
839
1026
  await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", {
840
1027
  yes: opts.yes
841
1028
  })
@@ -851,24 +1038,96 @@ async function runPiSetup(opts, deps) {
851
1038
  return 1;
852
1039
  }
853
1040
  }
854
- async function runOpencodeSetup(creds, routingUrl, opts, deps) {
1041
+ async function runHermesSetup(creds, routingUrl, opts, deps) {
1042
+ const commands = hermesConfigSetArgs(routingUrl, creds.routing_key);
1043
+ try {
1044
+ if (opts.print) {
1045
+ console.log("\n# would run:");
1046
+ for (const args of commands) console.log(` hermes ${args.join(" ")}`);
1047
+ console.log(
1048
+ "\nThen start hermes and run /model custom:lunaroute:<model> to pick a LunaRoute model (models auto-discover from /v1/models)."
1049
+ );
1050
+ return 0;
1051
+ }
1052
+ if (await deps.confirm("Configure LunaRoute for Hermes via 'hermes config set'? (recommended \u2014 models auto-discover)", {
1053
+ yes: opts.yes
1054
+ })) {
1055
+ return runHermesConfigSets(deps.spawn, routingUrl, creds.routing_key);
1056
+ }
1057
+ console.log("Skipped. Re-run with --yes, or configure manually:");
1058
+ for (const args of commands) console.log(` hermes ${args.join(" ")}`);
1059
+ console.log("\nThen start hermes and run /model custom:lunaroute:<model> to pick a LunaRoute model.");
1060
+ return 0;
1061
+ } catch (err) {
1062
+ if (err instanceof NonInteractiveTerminalError) {
1063
+ console.error("No interactive terminal available. Re-run with --yes to accept prompts, or --print to preview.");
1064
+ return 1;
1065
+ }
1066
+ console.error(err instanceof Error ? err.message : String(err));
1067
+ return 1;
1068
+ }
1069
+ }
1070
+ function hermesConfigSetArgs(routingUrl, key) {
1071
+ return [
1072
+ ["config", "set", "providers.lunaroute.api", `${routingUrl}/v1`],
1073
+ ["config", "set", "providers.lunaroute.key_env", "LUNAROUTE_API_KEY"],
1074
+ ["config", "set", "providers.lunaroute.transport", "chat_completions"],
1075
+ ["config", "set", "LUNAROUTE_API_KEY", key]
1076
+ ];
1077
+ }
1078
+ async function runHermesConfigSets(spawn3, routingUrl, key) {
1079
+ const commands = hermesConfigSetArgs(routingUrl, key);
1080
+ const setKeys = commands.map((c) => c[2]);
1081
+ for (let i = 0; i < commands.length; i++) {
1082
+ const code = await new Promise((resolve) => {
1083
+ const child = spawn3("hermes", commands[i]);
1084
+ child.on("error", (err) => {
1085
+ const e = err;
1086
+ if (e?.code === "ENOENT") {
1087
+ console.error(
1088
+ `Error: "hermes" not found on PATH. Install Hermes Agent first: https://hermes-agent.nousresearch.com (exit 127)`
1089
+ );
1090
+ resolve(127);
1091
+ return;
1092
+ }
1093
+ console.error(err instanceof Error ? err.message : String(err));
1094
+ resolve(1);
1095
+ });
1096
+ child.on("exit", (code2) => {
1097
+ resolve(typeof code2 === "number" ? code2 : 1);
1098
+ });
1099
+ });
1100
+ if (code !== 0) {
1101
+ const done = setKeys.slice(0, i);
1102
+ console.error(
1103
+ `'hermes config set ${setKeys[i]}' failed (exit ${code}). Set so far: ${done.length ? done.join(", ") : "nothing"}. Re-run 'lunaroute setup hermes' to retry (all steps are idempotent).`
1104
+ );
1105
+ return code || 1;
1106
+ }
1107
+ }
1108
+ console.log("\nConfigured. Start hermes (or restart it if already running), then:");
1109
+ console.log(" /model custom:lunaroute:<model> # switch to a LunaRoute model (models auto-discover from /v1/models)");
1110
+ console.log(" hermes model # interactive provider/model picker");
1111
+ return 0;
1112
+ }
1113
+ async function runOpencodeSetup(stored, settings, routingUrl, opts, deps) {
855
1114
  try {
856
1115
  if (opts.print) {
857
1116
  const plan = buildPlan(
858
- { routingUrl, orgId: creds.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
1117
+ { routingUrl, orgId: settings.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
859
1118
  { extension: true }
860
1119
  );
861
- return applyAndReport(plan, creds.routing_key, false);
1120
+ return applyAndReport(plan, stored?.routing_key ?? "", false);
862
1121
  }
863
1122
  if (await deps.confirm(
864
1123
  "Install the LunaRoute OpenCode extension (recommended \u2014 /connect login, models auto-sync)?",
865
1124
  { yes: opts.yes }
866
1125
  )) {
867
1126
  const plan = buildPlan(
868
- { routingUrl, orgId: creds.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
1127
+ { routingUrl, orgId: settings.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
869
1128
  { extension: true }
870
1129
  );
871
- const code = await applyAndReport(plan, creds.routing_key, !opts.print);
1130
+ const code = await applyAndReport(plan, stored?.routing_key ?? "", true);
872
1131
  if (code !== 0) return code;
873
1132
  if (await deps.confirm("Start opencode now? (if it's already open, quit and reopen it to load the extension)", {
874
1133
  yes: opts.yes
@@ -878,6 +1137,8 @@ async function runOpencodeSetup(creds, routingUrl, opts, deps) {
878
1137
  console.log("\nStart opencode when ready \u2014 then run /connect to log in and /models to pick a LunaRoute model.");
879
1138
  return 0;
880
1139
  }
1140
+ const creds = await requireCreds(opts, deps, stored, OPENCODE_LOGIN_HINT);
1141
+ if (!creds) return 1;
881
1142
  const write = await deps.confirm(
882
1143
  "Merge LunaRoute provider into opencode.json instead? (backup kept, other providers preserved)",
883
1144
  { yes: opts.yes }
@@ -942,10 +1203,10 @@ async function launchOpencode(spawn3) {
942
1203
  });
943
1204
  });
944
1205
  }
945
- async function installPiExtension(spawn3) {
1206
+ async function installPiExtension(deps, opts) {
946
1207
  for (const pkg of PI_INSTALL_PACKAGES) {
947
1208
  const code = await new Promise((resolve) => {
948
- const child = spawn3("pi", ["install", pkg]);
1209
+ const child = deps.spawn("pi", ["install", pkg]);
949
1210
  child.on("error", (err) => {
950
1211
  const e = err;
951
1212
  if (e?.code === "ENOENT") {
@@ -968,22 +1229,42 @@ async function installPiExtension(spawn3) {
968
1229
  return code || 1;
969
1230
  }
970
1231
  }
971
- console.log("\nInstalled. Next steps inside pi:");
1232
+ if (await deps.confirm("Start pi now? (then run /login lunaroute to sign in)", { yes: opts.yes })) {
1233
+ return launchPi(deps.spawn);
1234
+ }
1235
+ console.log("\nStart pi when ready \u2014 then inside pi:");
972
1236
  console.log(" 1. /login lunaroute \u2014 browser login issues and stores an lr_ key.");
973
1237
  console.log(" 2. /model \u2014 pick a lunaroute/* model (models auto-sync from /v1/models).");
974
1238
  return 0;
975
1239
  }
976
- async function applyPiModels(opts, write) {
977
- const creds = loadProfile(opts.profile);
1240
+ async function launchPi(spawn3) {
1241
+ return new Promise((resolve) => {
1242
+ const child = spawn3("pi", []);
1243
+ child.on("error", (err) => {
1244
+ const e = err;
1245
+ if (e?.code === "ENOENT") {
1246
+ console.error(`Error: "pi" not found on PATH. Install pi first: https://pi.dev (exit 127)`);
1247
+ resolve(127);
1248
+ return;
1249
+ }
1250
+ console.error(err instanceof Error ? err.message : String(err));
1251
+ resolve(1);
1252
+ });
1253
+ child.on("exit", (code) => {
1254
+ resolve(typeof code === "number" ? code : 1);
1255
+ });
1256
+ });
1257
+ }
1258
+ async function applyPiModels(creds, routingUrl, write) {
978
1259
  let models;
979
1260
  try {
980
- models = await fetchModels(opts.routingUrl || creds.routing_url || "");
1261
+ models = await fetchModels(routingUrl);
981
1262
  } catch (err) {
982
1263
  console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
983
1264
  return 1;
984
1265
  }
985
1266
  const plan = buildPlan2({
986
- routingUrl: opts.routingUrl || creds.routing_url,
1267
+ routingUrl,
987
1268
  orgId: creds.org_id,
988
1269
  models,
989
1270
  keyEnvVar: "LUNAROUTE_API_KEY"
@@ -1177,12 +1458,12 @@ async function readMemory(ctx, p) {
1177
1458
  }
1178
1459
 
1179
1460
  // src/commands/memory.ts
1180
- var NOT_LOGGED_IN = 'Not logged in. Run "lunaroute login" first.';
1461
+ var NOT_LOGGED_IN2 = 'Not logged in. Run "lunaroute login" first.';
1181
1462
  var NO_PROJECT = "No project context: run inside a git repo with an origin remote, or set LUNAROUTE_PROJECT_ID.";
1182
1463
  function contextFor(profile) {
1183
1464
  const s = resolveSettings(profile);
1184
1465
  if (!s.routing_key) {
1185
- console.error(NOT_LOGGED_IN);
1466
+ console.error(NOT_LOGGED_IN2);
1186
1467
  return 1;
1187
1468
  }
1188
1469
  const projectId = resolveProjectId();
@@ -1250,6 +1531,86 @@ async function runMemoryRead(profile, id, opts) {
1250
1531
  return 0;
1251
1532
  }
1252
1533
 
1534
+ // src/commands/searchKeys.ts
1535
+ var notLoggedIn = () => {
1536
+ console.error('Not logged in. Run "lunaroute login" first.');
1537
+ return 1;
1538
+ };
1539
+ var fail = (err) => {
1540
+ console.error(err instanceof Error ? err.message : String(err));
1541
+ return 1;
1542
+ };
1543
+ async function runSearchKeysList(profile, opts) {
1544
+ const s = resolveSettings(profile);
1545
+ if (!s.routing_key) return notLoggedIn();
1546
+ const ctx = { apiUrl: s.api_url, apiKey: s.routing_key };
1547
+ try {
1548
+ if (opts.json) {
1549
+ console.log(await listSearchKeysRaw(ctx));
1550
+ return 0;
1551
+ }
1552
+ const keys = await listSearchKeys(ctx);
1553
+ if (keys.length === 0) {
1554
+ console.log("No search provider keys configured.");
1555
+ return 0;
1556
+ }
1557
+ console.log(
1558
+ renderTable(
1559
+ ["PROVIDER", "CREATED", "UPDATED"],
1560
+ keys.map((k) => [k.provider, k.created_at, k.updated_at])
1561
+ )
1562
+ );
1563
+ return 0;
1564
+ } catch (err) {
1565
+ return fail(err);
1566
+ }
1567
+ }
1568
+ async function runSearchKeysSet(profile, opts) {
1569
+ const s = resolveSettings(profile);
1570
+ if (!s.routing_key) return notLoggedIn();
1571
+ const ctx = { apiUrl: s.api_url, apiKey: s.routing_key };
1572
+ try {
1573
+ let provider = opts.provider;
1574
+ let apiKey = opts.apiKey ?? process.env.LUNAROUTE_SEARCH_API_KEY;
1575
+ const providers = await listSearchProviders(ctx);
1576
+ const known = providers.map((p) => p.key);
1577
+ if (!provider) {
1578
+ provider = await promptInput(`Provider (${known.join(", ")})`);
1579
+ }
1580
+ if (!known.includes(provider)) {
1581
+ console.error(`unknown provider "${provider}" \u2014 this server offers: ${known.join(", ")}`);
1582
+ return 1;
1583
+ }
1584
+ if (!apiKey) {
1585
+ apiKey = await promptInput("API key", { hidden: true });
1586
+ }
1587
+ const meta = await putSearchKey(ctx, provider, apiKey);
1588
+ console.log(`Stored key for ${meta.provider} (updated ${meta.updated_at}).`);
1589
+ return 0;
1590
+ } catch (err) {
1591
+ return fail(err);
1592
+ }
1593
+ }
1594
+ async function runSearchKeysRemove(profile, provider, opts) {
1595
+ const s = resolveSettings(profile);
1596
+ if (!s.routing_key) return notLoggedIn();
1597
+ const ctx = { apiUrl: s.api_url, apiKey: s.routing_key };
1598
+ try {
1599
+ if (!opts.yes) {
1600
+ const ok = await confirm(`Remove the ${provider} search key?`);
1601
+ if (!ok) {
1602
+ console.log("Aborted.");
1603
+ return 0;
1604
+ }
1605
+ }
1606
+ await deleteSearchKey(ctx, provider);
1607
+ console.log(`Removed key for ${provider}.`);
1608
+ return 0;
1609
+ } catch (err) {
1610
+ return fail(err);
1611
+ }
1612
+ }
1613
+
1253
1614
  // src/mcp.ts
1254
1615
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1255
1616
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -1305,7 +1666,7 @@ async function runMcp(profile) {
1305
1666
  read: (p) => readMemory(ctx, p),
1306
1667
  resolveProjectId
1307
1668
  };
1308
- const server = new McpServer({ name: "lunaroute-memory", version: "0.1.0" });
1669
+ const server = new McpServer({ name: "lunaroute-memory", version });
1309
1670
  server.registerTool(
1310
1671
  "search",
1311
1672
  {
@@ -1440,7 +1801,7 @@ function buildRunSpec(ctx) {
1440
1801
  args: [],
1441
1802
  env: {
1442
1803
  ANTHROPIC_BASE_URL: ctx.routingUrl,
1443
- ANTHROPIC_API_KEY: ctx.apiKey,
1804
+ ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
1444
1805
  ANTHROPIC_MODEL: ctx.model,
1445
1806
  // Pins the Haiku/background model so Claude Code's background tasks
1446
1807
  // (titles, summaries, compaction) route through LunaRoute instead of
@@ -1538,7 +1899,7 @@ async function runRun(harness, opts, deps = realDeps) {
1538
1899
 
1539
1900
  // src/index.ts
1540
1901
  var program = new Command();
1541
- program.name("lunaroute").description("LunaRoute CLI \u2014 configure coding harnesses and manage your account.").version("0.1.0").option("-p, --profile <name>", "credential profile to use", "default");
1902
+ program.name("lunaroute").description("LunaRoute CLI \u2014 configure coding harnesses and manage your account.").version(version).option("-p, --profile <name>", "credential profile to use", "default");
1542
1903
  program.command("login").description("Authorize this device via the browser and store a routing key.").action(async () => {
1543
1904
  await login(program.opts().profile);
1544
1905
  });
@@ -1548,7 +1909,7 @@ program.command("whoami").description("Show the signed-in user and organization.
1548
1909
  program.command("logout").description("Remove stored credentials for the active profile.").action(() => {
1549
1910
  logout(program.opts().profile);
1550
1911
  });
1551
- program.command("setup <harness>").description("Configure a coding harness (opencode | pi | claude-code | copilot-cli | generic).").option("--print", "print the config instead of writing files", false).option("--routing-url <url>", "override the routing base URL").option("--yes", "accept all confirmation prompts without a TTY (for scripts)", false).option("--extension", "pi only: jump straight to installing the Pi extension + MCP adapter").option("--models", "pi only: jump straight to writing the models.json provider block").action(async (harness, opts) => {
1912
+ program.command("setup <harness>").description("Configure a coding harness (opencode | pi | hermes | claude-code | copilot-cli | openclaw | generic).").option("--print", "print the config instead of writing files", false).option("--routing-url <url>", "override the routing base URL").option("--yes", "accept all confirmation prompts without a TTY (for scripts)", false).option("--extension", "pi only: jump straight to installing the Pi extension + MCP adapter").option("--models", "pi only: jump straight to writing the models.json provider block").action(async (harness, opts) => {
1552
1913
  const code = await runSetup(harness, {
1553
1914
  profile: program.opts().profile,
1554
1915
  print: opts.print,
@@ -1617,6 +1978,19 @@ skill.command("install").description("Install the LunaRoute memory skill + MCP s
1617
1978
  const code = await runSkillInstall(program.opts().profile, opts);
1618
1979
  if (code !== 0) process.exit(code);
1619
1980
  });
1981
+ var searchKeys = program.command("search-keys").description("Manage web-search provider keys (BYOK) for your organization.");
1982
+ searchKeys.command("list").description("List configured search provider keys.").option("--json", "output the raw API envelope", false).action(async (opts) => {
1983
+ const code = await runSearchKeysList(program.opts().profile, { json: opts.json });
1984
+ if (code !== 0) process.exit(code);
1985
+ });
1986
+ 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) => {
1987
+ const code = await runSearchKeysSet(program.opts().profile, { provider: opts.provider, apiKey: opts.apiKey });
1988
+ if (code !== 0) process.exit(code);
1989
+ });
1990
+ searchKeys.command("remove <provider>").description("Remove a search provider key.").option("--yes", "skip the confirmation prompt", false).action(async (provider, opts) => {
1991
+ const code = await runSearchKeysRemove(program.opts().profile, provider, { yes: opts.yes });
1992
+ if (code !== 0) process.exit(code);
1993
+ });
1620
1994
  program.parseAsync(process.argv).catch((err) => {
1621
1995
  console.error(err instanceof Error ? err.message : err);
1622
1996
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunaroute/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "LunaRoute CLI — configure coding harnesses and manage your LunaRoute account.",
5
5
  "repository": {
6
6
  "type": "git",