@lunaroute/cli 0.2.4 → 0.2.5

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 +3 -1
  2. package/dist/index.js +180 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -18,7 +18,9 @@ Requires Node.js 20+.
18
18
  lunaroute setup pi # or: opencode | claude-code | copilot-cli | generic
19
19
  ```
20
20
 
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.
21
+ One command from a fresh machine: `setup` asks before touching anything — for `pi` it first offers to install the LunaRoute Pi extension (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.
22
+
23
+ Already have a key from the dashboard? `lunaroute setup pi --key lr_… --yes` is the whole onboarding: it checks the key against the catalog, installs the Pi extension, and stores the key in `~/.pi/agent/auth.json`, so pi starts signed in with models syncing from `/v1/models`. No models.json edit and no shell export. A stale `providers.lunaroute` block from an older CLI is removed in the same run (backup kept).
22
24
 
23
25
  Prefer a script? `setup pi --extension` / `--models` pick a flow, `--yes` accepts all prompts, and `--print` previews without writing.
24
26
 
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command, InvalidArgumentError, Option } from "commander";
5
5
 
6
6
  // package.json
7
- var version = "0.2.4";
7
+ var version = "0.2.5";
8
8
 
9
9
  // src/login.ts
10
10
  import { createServer } from "http";
@@ -359,13 +359,26 @@ function logout(profile) {
359
359
  }
360
360
 
361
361
  // src/catalog.ts
362
+ var KeyRejectedError = class extends Error {
363
+ };
364
+ var NON_CHAT_CAPABILITIES = [
365
+ "image_generation",
366
+ "embeddings",
367
+ "rerank"
368
+ ];
369
+ function isChatModel(m) {
370
+ for (const c of NON_CHAT_CAPABILITIES) {
371
+ if (m.capabilities?.[c] === true) return false;
372
+ }
373
+ return true;
374
+ }
362
375
  async function fetchModels(routingUrl, key) {
363
376
  const url = `${routingUrl}/v1/models`;
364
377
  const headers = key ? { Authorization: `Bearer ${key}` } : {};
365
378
  const res = await fetch(url, { method: "GET", headers });
366
379
  if (!res.ok) {
367
380
  if (res.status === 401) {
368
- throw new Error(
381
+ throw new KeyRejectedError(
369
382
  key ? `the routing service rejected that key (HTTP 401) \u2014 check that you copied the whole lr_ key` : `the routing service requires a key for /v1/models (HTTP 401) \u2014 run "lunaroute login" or pass --key`
370
383
  );
371
384
  }
@@ -379,9 +392,9 @@ async function fetchModels(routingUrl, key) {
379
392
  max_output_tokens: m.max_output_tokens,
380
393
  capabilities: m.capabilities,
381
394
  client_compat: m.client_compat
382
- })).filter((m) => m.id.length > 0);
395
+ })).filter((m) => m.id.length > 0 && isChatModel(m));
383
396
  if (models.length === 0) {
384
- throw new Error(`no models available from ${url}`);
397
+ throw new Error(`no chat models available from ${url}`);
385
398
  }
386
399
  return models.sort((a, b) => a.id.localeCompare(b.id));
387
400
  }
@@ -584,6 +597,9 @@ function openclawConfigPath() {
584
597
  function piModelsPath() {
585
598
  return join2(homedir2(), ".pi", "agent", "models.json");
586
599
  }
600
+ function piAuthPath() {
601
+ return join2(homedir2(), ".pi", "agent", "auth.json");
602
+ }
587
603
  var LUNAROUTE_SKILL_NAME = "lunaroute-memory";
588
604
  function claudeUserConfigPath() {
589
605
  return join2(homedir2(), ".claude.json");
@@ -694,15 +710,17 @@ function providerPlan(ctx) {
694
710
  }
695
711
 
696
712
  // src/setup/adapters/pi.ts
713
+ import { readFileSync as readFileSync3 } from "fs";
697
714
  function buildPiModelEntry(m) {
715
+ if (!m.context_window_tokens || !m.max_output_tokens) return null;
698
716
  const reasoning = m.capabilities?.reasoning === true;
699
717
  const entry = {
700
718
  id: m.id,
701
719
  name: m.display_name ?? m.id,
702
720
  reasoning,
703
721
  input: m.capabilities?.vision ? ["text", "image"] : ["text"],
704
- contextWindow: m.context_window_tokens ?? 0,
705
- maxTokens: m.max_output_tokens ?? 0
722
+ contextWindow: m.context_window_tokens,
723
+ maxTokens: m.max_output_tokens
706
724
  };
707
725
  if (!reasoning) return entry;
708
726
  const pi = m.client_compat?.pi;
@@ -718,13 +736,32 @@ function buildPiModelEntry(m) {
718
736
  return entry;
719
737
  }
720
738
  function buildPlan2(ctx) {
739
+ const skipped = [];
740
+ const models = [];
741
+ for (const m of ctx.models) {
742
+ const entry = buildPiModelEntry(m);
743
+ if (entry) models.push(entry);
744
+ else skipped.push(m.id);
745
+ }
746
+ if (models.length === 0) {
747
+ throw new Error(
748
+ `no LunaRoute model has a context window in the catalog${skipped.length ? ` (skipped: ${skipped.join(", ")})` : ""}`
749
+ );
750
+ }
721
751
  const block = {
722
752
  baseUrl: `${ctx.routingUrl}/v1`,
723
753
  api: "openai-completions",
724
754
  // Real key; rides the native Authorization: Bearer header.
725
755
  apiKey: `$${ctx.keyEnvVar}`,
726
- models: ctx.models.map(buildPiModelEntry)
756
+ models
727
757
  };
758
+ const notes = [
759
+ "\npi: open /model to pick a LunaRoute model (models.json hot-reloads).",
760
+ "Tip: the lunaroute-pi-extension auto-registers models from /v1/models so you can skip this file \u2014 `pi install npm:@lunaroute/pi-extension` then `/login lunaroute`."
761
+ ];
762
+ if (skipped.length > 0) {
763
+ notes.push(`Skipped (no context window in the catalog): ${skipped.join(", ")}`);
764
+ }
728
765
  return {
729
766
  fileWrites: [
730
767
  {
@@ -739,12 +776,60 @@ function buildPlan2(ctx) {
739
776
  }
740
777
  ],
741
778
  exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
779
+ notes
780
+ };
781
+ }
782
+ function stalePiProviderExists() {
783
+ try {
784
+ const obj = JSON.parse(readFileSync3(piModelsPath(), "utf8"));
785
+ return Boolean(obj.providers?.lunaroute);
786
+ } catch {
787
+ return false;
788
+ }
789
+ }
790
+ function buildStaleProviderCleanupPlan() {
791
+ return {
792
+ fileWrites: [
793
+ {
794
+ kind: "json",
795
+ path: piModelsPath(),
796
+ merge: (existing) => {
797
+ const obj = existing ?? {};
798
+ const providers = { ...obj.providers };
799
+ delete providers.lunaroute;
800
+ const next = { ...obj };
801
+ if (Object.keys(providers).length > 0) next.providers = providers;
802
+ else delete next.providers;
803
+ return next;
804
+ }
805
+ }
806
+ ],
807
+ exports: [],
742
808
  notes: [
743
- "\npi: open /model to pick a LunaRoute model (models.json hot-reloads).",
744
- "Tip: the lunaroute-pi-extension auto-registers models from /v1/models so you can skip this file \u2014 `pi install npm:@lunaroute/pi-extension` then `/login lunaroute`."
809
+ "pi: removed the stale providers.lunaroute block from ~/.pi/agent/models.json (backup kept) \u2014 the extension syncs models live."
745
810
  ]
746
811
  };
747
812
  }
813
+ var KEY_EXPIRES_MS = 10 * 365 * 24 * 60 * 60 * 1e3;
814
+ function buildKeyPlan(key) {
815
+ return {
816
+ fileWrites: [
817
+ {
818
+ kind: "json",
819
+ path: piAuthPath(),
820
+ merge: (existing) => {
821
+ const obj = existing ?? {};
822
+ return {
823
+ ...obj,
824
+ lunaroute: { type: "oauth", access: key, refresh: "", expires: Date.now() + KEY_EXPIRES_MS }
825
+ };
826
+ }
827
+ }
828
+ ],
829
+ exports: [],
830
+ notes: ["pi: key stored in ~/.pi/agent/auth.json \u2014 start pi, then /model to pick a lunaroute/* model."]
831
+ };
832
+ }
748
833
 
749
834
  // src/setup/adapters/claudeCode.ts
750
835
  function buildPlan3(ctx) {
@@ -911,7 +996,8 @@ var ADAPTERS = {
911
996
  "copilot-cli": buildPlan4,
912
997
  generic: buildPlan5
913
998
  };
914
- var PI_INSTALL_PACKAGES = ["npm:@lunaroute/pi-extension", "npm:pi-mcp-adapter"];
999
+ var PI_INSTALL_PACKAGES = ["npm:@lunaroute/pi-extension"];
1000
+ var PI_INSTALL_HINT = "Install pi first: npm install -g --ignore-scripts @earendil-works/pi-coding-agent (https://pi.dev)";
915
1001
  async function runSetup(harness, opts, deps = {
916
1002
  confirm,
917
1003
  spawn: makeRealSpawn(),
@@ -921,6 +1007,10 @@ async function runSetup(harness, opts, deps = {
921
1007
  console.error("Choose one of --extension or --models.");
922
1008
  return 1;
923
1009
  }
1010
+ if (opts.key && (opts.extension || opts.models)) {
1011
+ console.error("--key runs the full pi flow; drop --extension/--models.");
1012
+ return 1;
1013
+ }
924
1014
  const adapter = ADAPTERS[harness];
925
1015
  if (!adapter && harness !== "hermes") {
926
1016
  console.error(
@@ -930,22 +1020,11 @@ async function runSetup(harness, opts, deps = {
930
1020
  }
931
1021
  let stored = loadProfile(opts.profile);
932
1022
  const settings = resolveSettings(opts.profile);
933
- if (opts.key) {
934
- if (!LR_KEY_RE.test(opts.key)) {
935
- console.error(
936
- "Invalid key: a LunaRoute routing key starts with lr_ (copy the whole key from the dashboard)."
937
- );
938
- return 1;
939
- }
940
- saveProfile(opts.profile, {
941
- api_url: settings.api_url,
942
- routing_url: opts.routingUrl || settings.routing_url,
943
- front_url: settings.front_url,
944
- org_id: stored?.org_id ?? "",
945
- user_email: stored?.user_email ?? "",
946
- routing_key: opts.key
947
- });
948
- stored = loadProfile(opts.profile);
1023
+ if (opts.key && !LR_KEY_RE.test(opts.key)) {
1024
+ console.error(
1025
+ "Invalid key: a LunaRoute routing key starts with lr_ (copy the whole key from the dashboard)."
1026
+ );
1027
+ return 1;
949
1028
  }
950
1029
  const routingUrlRaw = opts.routingUrl || stored?.routing_url || settings.routing_url;
951
1030
  if (!routingUrlRaw) {
@@ -959,6 +1038,29 @@ async function runSetup(harness, opts, deps = {
959
1038
  console.error(err instanceof Error ? err.message : err);
960
1039
  return 1;
961
1040
  }
1041
+ if (opts.key) {
1042
+ try {
1043
+ await fetchModels(routingUrl, opts.key);
1044
+ } catch (err) {
1045
+ if (err instanceof KeyRejectedError) {
1046
+ console.error(err.message);
1047
+ return 1;
1048
+ }
1049
+ console.warn(`Note: ${err instanceof Error ? err.message : err}`);
1050
+ console.warn(
1051
+ "Continuing without a synced catalog. If your organization has no chat models enabled yet, contact hello@lunaroute.com \u2014 models appear on the next refresh once they are on."
1052
+ );
1053
+ }
1054
+ saveProfile(opts.profile, {
1055
+ api_url: settings.api_url,
1056
+ routing_url: opts.routingUrl || settings.routing_url,
1057
+ front_url: settings.front_url,
1058
+ org_id: stored?.org_id ?? "",
1059
+ user_email: stored?.user_email ?? "",
1060
+ routing_key: opts.key
1061
+ });
1062
+ stored = loadProfile(opts.profile);
1063
+ }
962
1064
  if (harness === "pi" && !opts.print) {
963
1065
  return runPiSetup(opts, stored, routingUrl, deps);
964
1066
  }
@@ -987,7 +1089,7 @@ async function runSetup(harness, opts, deps = {
987
1089
  };
988
1090
  let plan;
989
1091
  try {
990
- plan = adapter(ctx);
1092
+ plan = harness === "pi" && opts.key ? buildKeyPlan("<your-api-key>") : adapter(ctx);
991
1093
  } catch (err) {
992
1094
  console.error(err instanceof Error ? err.message : err);
993
1095
  return 1;
@@ -1043,38 +1145,36 @@ async function runPiSetup(opts, stored, routingUrl, deps) {
1043
1145
  if (opts.key) {
1044
1146
  const creds2 = await requireCreds(opts, deps, stored);
1045
1147
  if (!creds2) return 1;
1046
- let models = [];
1047
- let modelsSynced = true;
1048
- try {
1049
- models = await fetchModels(routingUrl, creds2.routing_key);
1050
- } catch (err) {
1051
- modelsSynced = false;
1052
- console.warn(
1053
- `Note: ${err instanceof Error ? err.message : err}`
1054
- );
1055
- console.warn(
1056
- "Writing the provider block with no models. Your organization has no models enabled yet \u2014 contact hello@lunaroute.com to get them turned on, then re-run this command to sync them."
1148
+ const install = await deps.confirm(
1149
+ "Install the LunaRoute Pi extension via 'pi install'?",
1150
+ { yes: opts.yes }
1151
+ );
1152
+ if (!install) {
1153
+ return applyPiModels(
1154
+ creds2,
1155
+ routingUrl,
1156
+ await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", { yes: opts.yes })
1057
1157
  );
1058
1158
  }
1059
- const plan = buildPlan2({
1060
- routingUrl,
1061
- orgId: creds2.org_id,
1062
- models,
1063
- keyEnvVar: "LUNAROUTE_API_KEY"
1064
- });
1065
- const write = await deps.confirm(
1066
- "Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?",
1159
+ const code = await installPiPackages(deps);
1160
+ if (code !== 0) return code;
1161
+ const store = await deps.confirm(
1162
+ "Store the key in ~/.pi/agent/auth.json (replaces any existing LunaRoute login; other credentials preserved)?",
1067
1163
  { yes: opts.yes }
1068
1164
  );
1069
- const code = await applyAndReport(plan, creds2.routing_key, write);
1070
- if (code === 0 && modelsSynced) {
1071
- console.log("\nDone. Start pi \u2014 it is signed in. Pick a model with /model.");
1165
+ if (!store) {
1166
+ console.log("\nSkipped. Inside pi, run /login lunaroute to sign in.");
1167
+ return 0;
1072
1168
  }
1073
- return code;
1169
+ const plan = combinePlans(buildKeyPlan(creds2.routing_key), stalePiProviderExists() ? buildStaleProviderCleanupPlan() : null);
1170
+ const wcode = await applyAndReport(plan, creds2.routing_key, true);
1171
+ if (wcode !== 0) return wcode;
1172
+ console.log("\nDone. Start pi \u2014 it is signed in. Pick a model with /model.");
1173
+ return 0;
1074
1174
  }
1075
1175
  if (opts.extension) {
1076
1176
  const ok = await deps.confirm(
1077
- "Install the LunaRoute Pi extension + MCP adapter via 'pi install'?",
1177
+ "Install the LunaRoute Pi extension via 'pi install'?",
1078
1178
  { yes: opts.yes }
1079
1179
  );
1080
1180
  if (!ok) {
@@ -1094,7 +1194,7 @@ async function runPiSetup(opts, stored, routingUrl, deps) {
1094
1194
  );
1095
1195
  }
1096
1196
  if (await deps.confirm(
1097
- "Install the LunaRoute Pi extension + MCP adapter via 'pi install' (recommended \u2014 auto-registers models)?",
1197
+ "Install the LunaRoute Pi extension via 'pi install' (recommended \u2014 auto-registers models)?",
1098
1198
  { yes: opts.yes }
1099
1199
  )) {
1100
1200
  return installPiExtension(deps, opts);
@@ -1249,6 +1349,14 @@ async function applyOpencodeProvider(creds, routingUrl, write) {
1249
1349
  });
1250
1350
  return applyAndReport(plan, creds.routing_key, write);
1251
1351
  }
1352
+ function combinePlans(a, b) {
1353
+ if (!b) return a;
1354
+ return {
1355
+ fileWrites: [...a.fileWrites, ...b.fileWrites],
1356
+ exports: [...a.exports, ...b.exports],
1357
+ notes: [...a.notes, ...b.notes]
1358
+ };
1359
+ }
1252
1360
  async function applyAndReport(plan, key, write) {
1253
1361
  try {
1254
1362
  const summary = await applyPlan(plan, { print: !write, key });
@@ -1280,16 +1388,12 @@ async function launchOpencode(spawn) {
1280
1388
  });
1281
1389
  });
1282
1390
  }
1283
- async function installPiExtension(deps, opts) {
1391
+ async function installPiPackages(deps) {
1284
1392
  for (const pkg of PI_INSTALL_PACKAGES) {
1285
1393
  const code = await new Promise((resolve) => {
1286
1394
  const child = deps.spawn("pi", ["install", pkg]);
1287
1395
  child.on("error", (err) => {
1288
- const { message, exitCode } = describeSpawnError(
1289
- "pi",
1290
- "Install pi first: https://pi.dev",
1291
- err
1292
- );
1396
+ const { message, exitCode } = describeSpawnError("pi", PI_INSTALL_HINT, err);
1293
1397
  console.error(message);
1294
1398
  resolve(exitCode);
1295
1399
  });
@@ -1300,11 +1404,26 @@ async function installPiExtension(deps, opts) {
1300
1404
  if (code !== 0) {
1301
1405
  const done = PI_INSTALL_PACKAGES.slice(0, PI_INSTALL_PACKAGES.indexOf(pkg));
1302
1406
  console.error(
1303
- `'pi install ${pkg}' failed (exit ${code}). Installed so far: ${done.length ? done.join(", ") : "nothing"}. Re-run 'lunaroute setup pi --extension' to retry.`
1407
+ `'pi install ${pkg}' failed (exit ${code}). Installed so far: ${done.length ? done.join(", ") : "nothing"}. Re-run the same command to retry.`
1304
1408
  );
1305
1409
  return code || 1;
1306
1410
  }
1307
1411
  }
1412
+ return 0;
1413
+ }
1414
+ async function installPiExtension(deps, opts) {
1415
+ const code = await installPiPackages(deps);
1416
+ if (code !== 0) return code;
1417
+ if (stalePiProviderExists()) {
1418
+ const remove = await deps.confirm(
1419
+ "Remove the stale LunaRoute block from ~/.pi/agent/models.json (backup kept, other providers preserved)?",
1420
+ { yes: opts.yes }
1421
+ );
1422
+ if (remove) {
1423
+ const ccode = await applyAndReport(buildStaleProviderCleanupPlan(), "", true);
1424
+ if (ccode !== 0) return ccode;
1425
+ }
1426
+ }
1308
1427
  if (await deps.confirm("Start pi now? (then run /login lunaroute to sign in)", { yes: opts.yes })) {
1309
1428
  return launchPi(deps.spawn);
1310
1429
  }
@@ -1317,11 +1436,7 @@ async function launchPi(spawn) {
1317
1436
  return new Promise((resolve) => {
1318
1437
  const child = spawn("pi", []);
1319
1438
  child.on("error", (err) => {
1320
- const { message, exitCode } = describeSpawnError(
1321
- "pi",
1322
- "Install pi first: https://pi.dev",
1323
- err
1324
- );
1439
+ const { message, exitCode } = describeSpawnError("pi", PI_INSTALL_HINT, err);
1325
1440
  console.error(message);
1326
1441
  resolve(exitCode);
1327
1442
  });
@@ -1991,7 +2106,7 @@ program.command("whoami").description("Show the signed-in user and organization.
1991
2106
  program.command("logout").description("Remove stored credentials for the active profile.").action(() => {
1992
2107
  logout(program.opts().profile);
1993
2108
  });
1994
- 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").option("--key <key>", "a LunaRoute routing key (lr_\u2026), e.g. from the dashboard's onboarding page \u2014 stores it and skips the browser login").action(async (harness, opts) => {
2109
+ 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").option("--models", "pi only: jump straight to writing the models.json provider block").option("--key <key>", "a LunaRoute routing key (lr_\u2026), e.g. from the dashboard's onboarding page \u2014 stores it and skips the browser login").action(async (harness, opts) => {
1995
2110
  const code = await runSetup(harness, {
1996
2111
  profile: program.opts().profile,
1997
2112
  print: opts.print,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunaroute/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "LunaRoute CLI — configure coding harnesses and manage your LunaRoute account.",
5
5
  "repository": {
6
6
  "type": "git",