@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +248 -5
  3. 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
- function buildPlan(ctx) {
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 = typeof obj.provider === "object" && obj.provider !== null ? obj.provider : {};
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 routingUrl = opts.routingUrl || creds.routing_url;
629
- if (!routingUrl) {
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
- const summary = await applyPlan(adapter(ctx), { print: opts.print, key: creds.routing_key });
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunaroute/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "LunaRoute CLI — configure coding harnesses and manage your LunaRoute account.",
5
5
  "repository": {
6
6
  "type": "git",