@lunaroute/cli 0.1.1 → 0.2.0
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 +1 -1
- package/dist/index.js +248 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Prefer a script? `setup pi --extension` / `--models` pick a flow, `--yes` accept
|
|
|
37
37
|
| `lunaroute login` | Authorize this device via the browser and store a routing key |
|
|
38
38
|
| `lunaroute whoami` | Show the signed-in user and organization |
|
|
39
39
|
| `lunaroute logout` | Remove stored credentials |
|
|
40
|
-
| `lunaroute setup <harness>` | Configure `opencode` \| `pi` \| `claude-code` \| `copilot-cli` \| `generic`. pi setup is interactive (asks before writing); `--extension` / `--models` pick a flow, `--yes` accepts prompts, `--print` previews |
|
|
40
|
+
| `lunaroute setup <harness>` | Configure `opencode` \| `pi` \| `openclaw` \| `claude-code` \| `copilot-cli` \| `generic`. pi setup is interactive (asks before writing); `--extension` / `--models` pick a flow, `--yes` accepts prompts, `--print` previews |
|
|
41
41
|
| `lunaroute run <harness>` | Launch `claude` \| `claude-code` \| `codex` on LunaRoute for this session |
|
|
42
42
|
| `lunaroute models` | List available LunaRoute models |
|
|
43
43
|
| `lunaroute pricing` | Per-model pricing (credits per million tokens) |
|
package/dist/index.js
CHANGED
|
@@ -411,6 +411,9 @@ function configHome() {
|
|
|
411
411
|
function opencodeConfigPath() {
|
|
412
412
|
return join2(configHome(), "opencode", "opencode.json");
|
|
413
413
|
}
|
|
414
|
+
function openclawConfigPath() {
|
|
415
|
+
return join2(homedir2(), ".openclaw", "openclaw.json");
|
|
416
|
+
}
|
|
414
417
|
function piModelsPath() {
|
|
415
418
|
return join2(homedir2(), ".pi", "agent", "models.json");
|
|
416
419
|
}
|
|
@@ -441,7 +444,55 @@ function gitRepoRoot(cwd = process.cwd()) {
|
|
|
441
444
|
}
|
|
442
445
|
|
|
443
446
|
// src/setup/adapters/opencode.ts
|
|
444
|
-
|
|
447
|
+
var EXTENSION = "@lunaroute/opencode-extension";
|
|
448
|
+
var PROD_GATEWAY_HOST = "gw.lunaroute.com";
|
|
449
|
+
function buildPlan(ctx, opts = {}) {
|
|
450
|
+
if (opts.extension) return extensionPlan(ctx);
|
|
451
|
+
return providerPlan(ctx);
|
|
452
|
+
}
|
|
453
|
+
function extensionPlan(ctx) {
|
|
454
|
+
return {
|
|
455
|
+
fileWrites: [
|
|
456
|
+
{
|
|
457
|
+
kind: "json",
|
|
458
|
+
path: opencodeConfigPath(),
|
|
459
|
+
merge: (existing) => {
|
|
460
|
+
const obj = existing ?? {};
|
|
461
|
+
const raw = obj.plugin;
|
|
462
|
+
const plugins = Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : [];
|
|
463
|
+
if (!plugins.includes(EXTENSION)) plugins.push(EXTENSION);
|
|
464
|
+
const next = { ...obj, plugin: plugins };
|
|
465
|
+
if (!isProdGateway(ctx.routingUrl)) {
|
|
466
|
+
const providers = objectOrEmpty(obj.provider);
|
|
467
|
+
const lr = objectOrEmpty(providers.lunaroute);
|
|
468
|
+
next.provider = {
|
|
469
|
+
...providers,
|
|
470
|
+
lunaroute: { ...lr, options: { ...objectOrEmpty(lr.options), baseURL: `${ctx.routingUrl}/v1` } }
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return next;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
],
|
|
477
|
+
// No env key: login happens in-app via /connect.
|
|
478
|
+
exports: [],
|
|
479
|
+
notes: [
|
|
480
|
+
"\nopencode: the extension installs on the next opencode start (npm plugins auto-install at startup).",
|
|
481
|
+
"Inside opencode: run /connect to log in, then /models to pick a LunaRoute model."
|
|
482
|
+
]
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function isProdGateway(url) {
|
|
486
|
+
try {
|
|
487
|
+
return new URL(url).host === PROD_GATEWAY_HOST;
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function objectOrEmpty(v) {
|
|
493
|
+
return typeof v === "object" && v !== null ? v : {};
|
|
494
|
+
}
|
|
495
|
+
function providerPlan(ctx) {
|
|
445
496
|
const models = {};
|
|
446
497
|
for (const m of ctx.models) models[m.id] = { name: m.id };
|
|
447
498
|
const block = {
|
|
@@ -462,7 +513,7 @@ function buildPlan(ctx) {
|
|
|
462
513
|
path: opencodeConfigPath(),
|
|
463
514
|
merge: (existing) => {
|
|
464
515
|
const obj = existing ?? {};
|
|
465
|
-
const existingProviders =
|
|
516
|
+
const existingProviders = objectOrEmpty(obj.provider);
|
|
466
517
|
const provider = { ...existingProviders, lunaroute: block };
|
|
467
518
|
return { ...obj, provider };
|
|
468
519
|
}
|
|
@@ -598,10 +649,94 @@ function buildPlan5(ctx) {
|
|
|
598
649
|
};
|
|
599
650
|
}
|
|
600
651
|
|
|
652
|
+
// src/setup/routing-url.ts
|
|
653
|
+
function validatedRoutingUrl(routingUrl) {
|
|
654
|
+
function fail(reason) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
`routingUrl rejected (reason: ${reason}; <redacted URL>) \u2014 check --routing-url or the profile's routing URL`
|
|
657
|
+
);
|
|
658
|
+
}
|
|
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");
|
|
663
|
+
let parsed;
|
|
664
|
+
try {
|
|
665
|
+
parsed = new URL(routingUrl);
|
|
666
|
+
} catch {
|
|
667
|
+
fail("invalid-url");
|
|
668
|
+
}
|
|
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");
|
|
672
|
+
return routingUrl;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/setup/adapters/openclaw.ts
|
|
676
|
+
function asRecord(v) {
|
|
677
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
|
|
678
|
+
}
|
|
679
|
+
function buildPlan6(ctx) {
|
|
680
|
+
const base = validatedRoutingUrl(ctx.routingUrl);
|
|
681
|
+
const models = ctx.models.map((m) => ({ id: m.id, name: m.id }));
|
|
682
|
+
return {
|
|
683
|
+
fileWrites: [
|
|
684
|
+
{
|
|
685
|
+
kind: "json",
|
|
686
|
+
path: openclawConfigPath(),
|
|
687
|
+
merge: (existing) => {
|
|
688
|
+
const obj = asRecord(existing);
|
|
689
|
+
const modelsCfg = asRecord(obj.models);
|
|
690
|
+
const providers = asRecord(modelsCfg.providers);
|
|
691
|
+
const agents = asRecord(obj.agents);
|
|
692
|
+
const defaults = asRecord(agents.defaults);
|
|
693
|
+
const model = asRecord(defaults.model);
|
|
694
|
+
return {
|
|
695
|
+
...obj,
|
|
696
|
+
models: {
|
|
697
|
+
...modelsCfg,
|
|
698
|
+
mode: modelsCfg.mode ?? "merge",
|
|
699
|
+
providers: {
|
|
700
|
+
...providers,
|
|
701
|
+
lunaroute: {
|
|
702
|
+
baseUrl: `${base}/v1`,
|
|
703
|
+
// OpenClaw interpolates the env var and sends it as
|
|
704
|
+
// Authorization: Bearer lr_…; the edge authenticates the lr_
|
|
705
|
+
// key and strips the header before forwarding upstream.
|
|
706
|
+
apiKey: `\${${ctx.keyEnvVar}}`,
|
|
707
|
+
api: "openai-completions",
|
|
708
|
+
models
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
agents: {
|
|
713
|
+
...agents,
|
|
714
|
+
defaults: {
|
|
715
|
+
...defaults,
|
|
716
|
+
model: {
|
|
717
|
+
...model,
|
|
718
|
+
// Preserve an existing primary model, like OpenClaw onboarding.
|
|
719
|
+
primary: model.primary ?? `lunaroute/${ctx.models[0]?.id ?? "<model>"}`
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
],
|
|
727
|
+
exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
|
|
728
|
+
notes: [
|
|
729
|
+
`
|
|
730
|
+
openclaw: restart the gateway, then \`openclaw models list\` and \`openclaw models set lunaroute/${ctx.models[0]?.id ?? "<model>"}\` (only if you have no default model already). If lunaroute/* models do not appear in \`openclaw models list\`, check that models.mode is merge \u2014 setup preserves whatever mode you have.`
|
|
731
|
+
]
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
601
735
|
// src/commands/setup.ts
|
|
602
736
|
var ADAPTERS = {
|
|
603
737
|
opencode: buildPlan,
|
|
604
738
|
pi: buildPlan2,
|
|
739
|
+
openclaw: buildPlan6,
|
|
605
740
|
"claude-code": buildPlan3,
|
|
606
741
|
"copilot-cli": buildPlan4,
|
|
607
742
|
generic: buildPlan5
|
|
@@ -625,14 +760,24 @@ async function runSetup(harness, opts, deps = {
|
|
|
625
760
|
console.error('Not logged in. Run "lunaroute login" first.');
|
|
626
761
|
return 1;
|
|
627
762
|
}
|
|
628
|
-
const
|
|
629
|
-
if (!
|
|
763
|
+
const routingUrlRaw = opts.routingUrl || creds.routing_url;
|
|
764
|
+
if (!routingUrlRaw) {
|
|
630
765
|
console.error("No routing URL in profile; pass --routing-url.");
|
|
631
766
|
return 1;
|
|
632
767
|
}
|
|
768
|
+
let routingUrl;
|
|
769
|
+
try {
|
|
770
|
+
routingUrl = validatedRoutingUrl(routingUrlRaw.replace(/\/+$/, "").trim());
|
|
771
|
+
} catch (err) {
|
|
772
|
+
console.error(err instanceof Error ? err.message : err);
|
|
773
|
+
return 1;
|
|
774
|
+
}
|
|
633
775
|
if (harness === "pi" && !opts.print) {
|
|
634
776
|
return runPiSetup(opts, deps);
|
|
635
777
|
}
|
|
778
|
+
if (harness === "opencode") {
|
|
779
|
+
return runOpencodeSetup(creds, routingUrl, opts, deps);
|
|
780
|
+
}
|
|
636
781
|
let models;
|
|
637
782
|
try {
|
|
638
783
|
models = await fetchModels(routingUrl);
|
|
@@ -646,8 +791,15 @@ async function runSetup(harness, opts, deps = {
|
|
|
646
791
|
models,
|
|
647
792
|
keyEnvVar: "LUNAROUTE_API_KEY"
|
|
648
793
|
};
|
|
794
|
+
let plan;
|
|
649
795
|
try {
|
|
650
|
-
|
|
796
|
+
plan = adapter(ctx);
|
|
797
|
+
} catch (err) {
|
|
798
|
+
console.error(err instanceof Error ? err.message : err);
|
|
799
|
+
return 1;
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
const summary = await applyPlan(plan, { print: opts.print, key: creds.routing_key });
|
|
651
803
|
if (!opts.print && summary.written.length > 0) {
|
|
652
804
|
console.log(`
|
|
653
805
|
Wrote: ${summary.written.join(", ")}`);
|
|
@@ -699,6 +851,97 @@ async function runPiSetup(opts, deps) {
|
|
|
699
851
|
return 1;
|
|
700
852
|
}
|
|
701
853
|
}
|
|
854
|
+
async function runOpencodeSetup(creds, routingUrl, opts, deps) {
|
|
855
|
+
try {
|
|
856
|
+
if (opts.print) {
|
|
857
|
+
const plan = buildPlan(
|
|
858
|
+
{ routingUrl, orgId: creds.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
|
|
859
|
+
{ extension: true }
|
|
860
|
+
);
|
|
861
|
+
return applyAndReport(plan, creds.routing_key, false);
|
|
862
|
+
}
|
|
863
|
+
if (await deps.confirm(
|
|
864
|
+
"Install the LunaRoute OpenCode extension (recommended \u2014 /connect login, models auto-sync)?",
|
|
865
|
+
{ yes: opts.yes }
|
|
866
|
+
)) {
|
|
867
|
+
const plan = buildPlan(
|
|
868
|
+
{ routingUrl, orgId: creds.org_id, models: [], keyEnvVar: "LUNAROUTE_API_KEY" },
|
|
869
|
+
{ extension: true }
|
|
870
|
+
);
|
|
871
|
+
const code = await applyAndReport(plan, creds.routing_key, !opts.print);
|
|
872
|
+
if (code !== 0) return code;
|
|
873
|
+
if (await deps.confirm("Start opencode now? (if it's already open, quit and reopen it to load the extension)", {
|
|
874
|
+
yes: opts.yes
|
|
875
|
+
})) {
|
|
876
|
+
return launchOpencode(deps.spawn);
|
|
877
|
+
}
|
|
878
|
+
console.log("\nStart opencode when ready \u2014 then run /connect to log in and /models to pick a LunaRoute model.");
|
|
879
|
+
return 0;
|
|
880
|
+
}
|
|
881
|
+
const write = await deps.confirm(
|
|
882
|
+
"Merge LunaRoute provider into opencode.json instead? (backup kept, other providers preserved)",
|
|
883
|
+
{ yes: opts.yes }
|
|
884
|
+
);
|
|
885
|
+
return applyOpencodeProvider(creds, routingUrl, write);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
if (err instanceof NonInteractiveTerminalError) {
|
|
888
|
+
console.error(
|
|
889
|
+
"No interactive terminal available. Re-run with --yes to accept prompts, or --print to preview without writing."
|
|
890
|
+
);
|
|
891
|
+
return 1;
|
|
892
|
+
}
|
|
893
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
894
|
+
return 1;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
async function applyOpencodeProvider(creds, routingUrl, write) {
|
|
898
|
+
let models;
|
|
899
|
+
try {
|
|
900
|
+
models = await fetchModels(routingUrl);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
|
|
903
|
+
return 1;
|
|
904
|
+
}
|
|
905
|
+
const plan = buildPlan({
|
|
906
|
+
routingUrl,
|
|
907
|
+
orgId: creds.org_id,
|
|
908
|
+
models,
|
|
909
|
+
keyEnvVar: "LUNAROUTE_API_KEY"
|
|
910
|
+
});
|
|
911
|
+
return applyAndReport(plan, creds.routing_key, write);
|
|
912
|
+
}
|
|
913
|
+
async function applyAndReport(plan, key, write) {
|
|
914
|
+
try {
|
|
915
|
+
const summary = await applyPlan(plan, { print: !write, key });
|
|
916
|
+
if (write && summary.written.length > 0) {
|
|
917
|
+
console.log(`
|
|
918
|
+
Wrote: ${summary.written.join(", ")}`);
|
|
919
|
+
if (summary.backedUp.length > 0) console.log(` Backups: ${summary.backedUp.join(", ")}`);
|
|
920
|
+
}
|
|
921
|
+
return 0;
|
|
922
|
+
} catch (err) {
|
|
923
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
924
|
+
return 1;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
async function launchOpencode(spawn3) {
|
|
928
|
+
return new Promise((resolve) => {
|
|
929
|
+
const child = spawn3("opencode", []);
|
|
930
|
+
child.on("error", (err) => {
|
|
931
|
+
const e = err;
|
|
932
|
+
if (e?.code === "ENOENT") {
|
|
933
|
+
console.error(`Error: "opencode" not found on PATH. Install OpenCode first: https://opencode.ai (exit 127)`);
|
|
934
|
+
resolve(127);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
938
|
+
resolve(1);
|
|
939
|
+
});
|
|
940
|
+
child.on("exit", (code) => {
|
|
941
|
+
resolve(typeof code === "number" ? code : 1);
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
}
|
|
702
945
|
async function installPiExtension(spawn3) {
|
|
703
946
|
for (const pkg of PI_INSTALL_PACKAGES) {
|
|
704
947
|
const code = await new Promise((resolve) => {
|