@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.
- package/README.md +2 -3
- package/dist/index.js +425 -51
- 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
|
|
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
|
|
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
|
|
592
|
-
// routing edge recognizes the lr_ prefix, authenticates it, and strips
|
|
593
|
-
// header before forwarding upstream — so the key never leaks and
|
|
594
|
-
// sits in the URL path.
|
|
595
|
-
|
|
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
|
|
602
|
-
"If your Claude Code build
|
|
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 "
|
|
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
|
|
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))
|
|
660
|
-
if (/[?#]/.test(routingUrl))
|
|
661
|
-
if (routingUrl !== routingUrl.trim())
|
|
662
|
-
if (routingUrl.endsWith("/"))
|
|
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
|
-
|
|
808
|
+
fail2("invalid-url");
|
|
668
809
|
}
|
|
669
|
-
if (parsed.username !== "" || parsed.password !== "")
|
|
670
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
671
|
-
if (parsed.hostname === "")
|
|
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(
|
|
756
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
1004
|
+
return installPiExtension(deps, opts);
|
|
827
1005
|
}
|
|
828
1006
|
if (opts.models) {
|
|
829
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
1117
|
+
{ routingUrl, orgId: settings.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
|
|
859
1118
|
{ extension: true }
|
|
860
1119
|
);
|
|
861
|
-
return applyAndReport(plan,
|
|
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:
|
|
1127
|
+
{ routingUrl, orgId: settings.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
|
|
869
1128
|
{ extension: true }
|
|
870
1129
|
);
|
|
871
|
-
const code = await applyAndReport(plan,
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
977
|
-
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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);
|