@lunaroute/cli 0.1.0 → 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 +55 -0
  2. package/dist/index.js +1623 -0
  3. package/package.json +1 -1
package/dist/index.js ADDED
@@ -0,0 +1,1623 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command, InvalidArgumentError, Option } from "commander";
5
+
6
+ // src/login.ts
7
+ import { createServer } from "http";
8
+ import { hostname } from "os";
9
+ import open from "open";
10
+
11
+ // src/pkce.ts
12
+ import { randomBytes, createHash } from "crypto";
13
+ function genState() {
14
+ return randomBytes(16).toString("hex");
15
+ }
16
+ function genVerifier() {
17
+ return randomBytes(32).toString("hex");
18
+ }
19
+ function challengeFor(verifier) {
20
+ return createHash("sha256").update(verifier).digest("hex");
21
+ }
22
+
23
+ // src/apiClient.ts
24
+ async function exchangeCode(apiUrl, req) {
25
+ const res = await fetch(`${apiUrl}/v1/auth/cli/exchange`, {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify(req)
29
+ });
30
+ if (!res.ok) {
31
+ let detail = `HTTP ${res.status}`;
32
+ try {
33
+ const body = await res.json();
34
+ const code = body?.error?.code;
35
+ const message = body?.error?.message;
36
+ if (code && message) detail = `${code}: ${message}`;
37
+ else if (code) detail = code;
38
+ else if (message) detail = message;
39
+ } catch {
40
+ }
41
+ throw new Error(`exchange failed: ${detail}`);
42
+ }
43
+ return await res.json();
44
+ }
45
+ async function cliGet(ctx, path) {
46
+ const res = await fetch(`${ctx.apiUrl}${path}`, {
47
+ method: "GET",
48
+ headers: { Authorization: `Bearer ${ctx.apiKey}` }
49
+ });
50
+ if (!res.ok) {
51
+ let detail = `HTTP ${res.status}`;
52
+ try {
53
+ const body2 = await res.json();
54
+ const code = body2?.error?.code;
55
+ const message = body2?.error?.message;
56
+ if (code && message) detail = `${code}: ${message}`;
57
+ else if (code) detail = code;
58
+ else if (message) detail = message;
59
+ } catch {
60
+ }
61
+ throw new Error(`request failed: ${detail}`);
62
+ }
63
+ const body = await res.json();
64
+ return body.data;
65
+ }
66
+ async function getModels(ctx) {
67
+ const data = await cliGet(ctx, "/v1/cli/models");
68
+ return data.map((m) => m.id).sort();
69
+ }
70
+ async function getPricing(ctx, model) {
71
+ const q = model ? `?model=${encodeURIComponent(model)}` : "";
72
+ return cliGet(ctx, `/v1/cli/pricing${q}`);
73
+ }
74
+ async function getUsage(ctx, limit) {
75
+ const q = limit ? `?limit=${limit}` : "";
76
+ return cliGet(ctx, `/v1/cli/usage${q}`);
77
+ }
78
+
79
+ // src/config.ts
80
+ import { homedir } from "os";
81
+ import { join } from "path";
82
+ import {
83
+ mkdirSync,
84
+ readFileSync,
85
+ writeFileSync,
86
+ renameSync,
87
+ existsSync,
88
+ chmodSync
89
+ } from "fs";
90
+ function configDir() {
91
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
92
+ return join(base, "lunaroute");
93
+ }
94
+ function configPath() {
95
+ return join(configDir(), "config.json");
96
+ }
97
+ function hasValidShape(parsed) {
98
+ return typeof parsed === "object" && parsed !== null && typeof parsed.profiles === "object" && parsed.profiles !== null;
99
+ }
100
+ function backupCorruptConfig(path) {
101
+ const backup = `${path}.corrupt`;
102
+ try {
103
+ if (existsSync(backup)) {
104
+ console.warn(
105
+ `lunaroute: config file at ${path} is invalid; a prior backup exists at ${backup}`
106
+ );
107
+ return;
108
+ }
109
+ renameSync(path, backup);
110
+ console.warn(
111
+ `lunaroute: config file was invalid and has been moved to ${backup}`
112
+ );
113
+ } catch {
114
+ console.warn(`lunaroute: config file at ${path} is invalid`);
115
+ }
116
+ }
117
+ function readConfig() {
118
+ const path = configPath();
119
+ if (!existsSync(path)) return { profiles: {} };
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(readFileSync(path, "utf8"));
123
+ } catch {
124
+ backupCorruptConfig(path);
125
+ return { profiles: {} };
126
+ }
127
+ if (!hasValidShape(parsed)) {
128
+ backupCorruptConfig(path);
129
+ return { profiles: {} };
130
+ }
131
+ return parsed;
132
+ }
133
+ function writeConfig(cfg) {
134
+ mkdirSync(configDir(), { recursive: true, mode: 448 });
135
+ const path = configPath();
136
+ writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 384 });
137
+ chmodSync(path, 384);
138
+ }
139
+ function loadProfile(name) {
140
+ return readConfig().profiles[name] ?? null;
141
+ }
142
+ function saveProfile(name, creds) {
143
+ const cfg = readConfig();
144
+ cfg.profiles[name] = creds;
145
+ writeConfig(cfg);
146
+ }
147
+ function clearProfile(name) {
148
+ const cfg = readConfig();
149
+ delete cfg.profiles[name];
150
+ writeConfig(cfg);
151
+ }
152
+ function resolveSettings(name) {
153
+ const stored = loadProfile(name) ?? {
154
+ api_url: "https://api.lunaroute.com",
155
+ routing_url: "https://gw.lunaroute.com",
156
+ front_url: "https://app.lunaroute.com",
157
+ org_id: "",
158
+ routing_key: "",
159
+ user_email: ""
160
+ };
161
+ return {
162
+ api_url: process.env.LUNAROUTE_API_URL || stored.api_url,
163
+ routing_url: process.env.LUNAROUTE_ROUTING_URL || stored.routing_url,
164
+ front_url: process.env.LUNAROUTE_FRONT_URL || stored.front_url,
165
+ org_id: stored.org_id,
166
+ routing_key: process.env.LUNAROUTE_API_KEY || stored.routing_key,
167
+ user_email: stored.user_email
168
+ };
169
+ }
170
+
171
+ // src/login.ts
172
+ var LOGIN_TIMEOUT_MS = 3 * 6e4;
173
+ async function waitWithTimeout(server, ms) {
174
+ let timer;
175
+ const timeout = new Promise((_, reject) => {
176
+ timer = setTimeout(
177
+ () => reject(
178
+ new Error(
179
+ "timed out waiting for browser authorization. Re-run `lunaroute login`."
180
+ )
181
+ ),
182
+ ms
183
+ );
184
+ });
185
+ try {
186
+ return await Promise.race([server.waitForCallback(), timeout]);
187
+ } finally {
188
+ if (timer) clearTimeout(timer);
189
+ server.close();
190
+ }
191
+ }
192
+ async function startLoopbackServer() {
193
+ let resolveCb;
194
+ const cbPromise = new Promise((r) => resolveCb = r);
195
+ const server = createServer((req, res) => {
196
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
197
+ if (url.pathname !== "/callback") {
198
+ res.statusCode = 404;
199
+ res.end("not found");
200
+ return;
201
+ }
202
+ const code = url.searchParams.get("code") ?? "";
203
+ const state = url.searchParams.get("state") ?? "";
204
+ res.statusCode = 200;
205
+ res.setHeader("Content-Type", "text/html");
206
+ res.end(
207
+ "<html><body><h2>LunaRoute CLI authorized.</h2><p>You can close this tab and return to your terminal.</p></body></html>"
208
+ );
209
+ resolveCb({ code, state });
210
+ });
211
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
212
+ const addr = server.address();
213
+ const port = typeof addr === "object" && addr ? addr.port : 0;
214
+ return {
215
+ port,
216
+ waitForCallback: () => cbPromise,
217
+ close: () => server.close()
218
+ };
219
+ }
220
+ async function runLogin(profile) {
221
+ const settings = resolveSettings(profile);
222
+ const state = genState();
223
+ const verifier = genVerifier();
224
+ const challenge = challengeFor(verifier);
225
+ const server = await startLoopbackServer();
226
+ const authUrl = `${settings.front_url}/cli-auth?port=${server.port}&state=${state}&challenge=${challenge}`;
227
+ console.log(`Opening your browser to authorize:
228
+ ${authUrl}
229
+ `);
230
+ await open(authUrl).catch(() => {
231
+ console.log(
232
+ "Could not open a browser automatically. Open the URL above manually."
233
+ );
234
+ });
235
+ const cb = await waitWithTimeout(server, LOGIN_TIMEOUT_MS);
236
+ if (cb.state !== state) {
237
+ throw new Error(
238
+ "state mismatch \u2014 aborting (possible cross-process interference)"
239
+ );
240
+ }
241
+ const result = await exchangeCode(settings.api_url, {
242
+ code: cb.code,
243
+ verifier,
244
+ label: hostname()
245
+ });
246
+ saveProfile(profile, {
247
+ api_url: settings.api_url,
248
+ routing_url: settings.routing_url,
249
+ front_url: settings.front_url,
250
+ org_id: result.org_id,
251
+ routing_key: result.full_key,
252
+ user_email: result.user_email
253
+ });
254
+ console.log(`
255
+ \u2713 Logged in as ${result.user_email} (org ${result.org_id}).`);
256
+ console.log(` Routing key saved to profile "${profile}".`);
257
+ }
258
+
259
+ // src/commands/login.ts
260
+ async function login(profile) {
261
+ try {
262
+ await runLogin(profile);
263
+ } catch (err) {
264
+ console.error(`Login failed: ${err.message}`);
265
+ process.exitCode = 1;
266
+ }
267
+ }
268
+
269
+ // src/commands/whoami.ts
270
+ function whoami(profile) {
271
+ const creds = loadProfile(profile);
272
+ if (!creds || !creds.routing_key) {
273
+ console.log('Not logged in. Run "lunaroute login" to get started.');
274
+ return;
275
+ }
276
+ console.log(`Logged in as ${creds.user_email}`);
277
+ console.log(` Organization: ${creds.org_id}`);
278
+ console.log(` Profile: ${profile}`);
279
+ console.log(` API: ${creds.api_url}`);
280
+ }
281
+
282
+ // src/commands/logout.ts
283
+ function logout(profile) {
284
+ if (!loadProfile(profile)) {
285
+ console.log(`No credentials stored for profile "${profile}".`);
286
+ return;
287
+ }
288
+ clearProfile(profile);
289
+ console.log(`Logged out of profile "${profile}".`);
290
+ }
291
+
292
+ // src/catalog.ts
293
+ async function fetchModels(routingUrl) {
294
+ const url = `${routingUrl}/v1/models`;
295
+ const res = await fetch(url, { method: "GET" });
296
+ if (!res.ok) {
297
+ throw new Error(`failed to fetch models: HTTP ${res.status} from ${url}`);
298
+ }
299
+ const body = await res.json();
300
+ const models = (body.data ?? []).map((m) => ({
301
+ id: m.id ?? "",
302
+ display_name: m.display_name,
303
+ context_window_tokens: m.context_window,
304
+ max_output_tokens: m.max_output_tokens,
305
+ capabilities: m.capabilities,
306
+ client_compat: m.client_compat
307
+ })).filter((m) => m.id.length > 0);
308
+ if (models.length === 0) {
309
+ throw new Error(`no models available from ${url}`);
310
+ }
311
+ return models.sort((a, b) => a.id.localeCompare(b.id));
312
+ }
313
+
314
+ // src/setup/apply.ts
315
+ import {
316
+ mkdirSync as mkdirSync2,
317
+ readFileSync as readFileSync2,
318
+ writeFileSync as writeFileSync2,
319
+ copyFileSync,
320
+ existsSync as existsSync2,
321
+ chmodSync as chmodSync2
322
+ } from "fs";
323
+ import { dirname } from "path";
324
+ function readExistingJson(path) {
325
+ if (!existsSync2(path)) return null;
326
+ const raw = readFileSync2(path, "utf8").trim();
327
+ if (raw === "") return null;
328
+ try {
329
+ return JSON.parse(raw);
330
+ } catch {
331
+ throw new Error(
332
+ `refusing to modify ${path}: it exists but is not valid JSON (malformed). Fix or remove it, or re-run with --print to copy the config manually.`
333
+ );
334
+ }
335
+ }
336
+ function resolveContent(fw) {
337
+ if (fw.kind === "text") {
338
+ const curStr2 = existsSync2(fw.path) ? readFileSync2(fw.path, "utf8") : null;
339
+ return { nextStr: fw.content, curStr: curStr2 };
340
+ }
341
+ const existing = readExistingJson(fw.path);
342
+ const curStr = existing === null ? null : `${JSON.stringify(existing, null, 2)}
343
+ `;
344
+ const nextStr = `${JSON.stringify(fw.merge(existing), null, 2)}
345
+ `;
346
+ return { nextStr, curStr };
347
+ }
348
+ async function applyPlan(plan, opts) {
349
+ const summary = { written: [], backedUp: [] };
350
+ for (const fw of plan.fileWrites) {
351
+ const { nextStr, curStr } = resolveContent(fw);
352
+ if (opts.print) {
353
+ console.log(`
354
+ # would write ${fw.path}:
355
+ ${nextStr}`);
356
+ continue;
357
+ }
358
+ if (curStr !== null) {
359
+ if (curStr === nextStr) continue;
360
+ copyFileSync(fw.path, `${fw.path}.bak`);
361
+ summary.backedUp.push(`${fw.path}.bak`);
362
+ }
363
+ const dir = dirname(fw.path);
364
+ const dirExisted = existsSync2(dir);
365
+ mkdirSync2(dir, { recursive: true });
366
+ if (!dirExisted) chmodSync2(dir, fw.dirMode ?? 448);
367
+ writeFileSync2(fw.path, nextStr, { mode: fw.fileMode ?? 384 });
368
+ chmodSync2(fw.path, fw.fileMode ?? 384);
369
+ summary.written.push(fw.path);
370
+ }
371
+ if (plan.exports.length > 0) {
372
+ console.log("\n# Add these to your shell profile:");
373
+ for (const e of plan.exports) {
374
+ const value = e.value === "__KEY__" ? opts.key : e.value;
375
+ console.log(`export ${e.name}=${value}`);
376
+ }
377
+ }
378
+ for (const note of plan.notes) console.log(note);
379
+ return summary;
380
+ }
381
+
382
+ // src/setup/prompt.ts
383
+ import { createInterface } from "readline/promises";
384
+ var NonInteractiveTerminalError = class extends Error {
385
+ };
386
+ async function confirm(question, opts = {}) {
387
+ if (opts.yes) return true;
388
+ const input = opts.input ?? process.stdin;
389
+ if (!input.isTTY) {
390
+ throw new NonInteractiveTerminalError();
391
+ }
392
+ const rl = createInterface({ input, output: opts.output ?? process.stdout });
393
+ try {
394
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
395
+ return answer === "y" || answer === "yes";
396
+ } finally {
397
+ rl.close();
398
+ }
399
+ }
400
+
401
+ // src/commands/setup.ts
402
+ import { spawn } from "child_process";
403
+
404
+ // src/setup/paths.ts
405
+ import { execSync } from "child_process";
406
+ import { homedir as homedir2 } from "os";
407
+ import { join as join2 } from "path";
408
+ function configHome() {
409
+ return process.env.XDG_CONFIG_HOME || join2(homedir2(), ".config");
410
+ }
411
+ function opencodeConfigPath() {
412
+ return join2(configHome(), "opencode", "opencode.json");
413
+ }
414
+ function openclawConfigPath() {
415
+ return join2(homedir2(), ".openclaw", "openclaw.json");
416
+ }
417
+ function piModelsPath() {
418
+ return join2(homedir2(), ".pi", "agent", "models.json");
419
+ }
420
+ var LUNAROUTE_SKILL_NAME = "lunaroute-memory";
421
+ function claudeUserConfigPath() {
422
+ return join2(homedir2(), ".claude.json");
423
+ }
424
+ function claudeUserSkillPath(name = LUNAROUTE_SKILL_NAME) {
425
+ return join2(homedir2(), ".claude", "skills", name, "SKILL.md");
426
+ }
427
+ function claudeProjectMcpPath(root) {
428
+ return join2(root, ".mcp.json");
429
+ }
430
+ function claudeProjectSkillPath(root, name = LUNAROUTE_SKILL_NAME) {
431
+ return join2(root, ".claude", "skills", name, "SKILL.md");
432
+ }
433
+ function gitRepoRoot(cwd = process.cwd()) {
434
+ try {
435
+ const out = execSync("git rev-parse --show-toplevel", {
436
+ cwd,
437
+ encoding: "utf8",
438
+ stdio: ["ignore", "pipe", "ignore"]
439
+ }).trim();
440
+ return out || null;
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+
446
+ // src/setup/adapters/opencode.ts
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) {
496
+ const models = {};
497
+ for (const m of ctx.models) models[m.id] = { name: m.id };
498
+ const block = {
499
+ npm: "@ai-sdk/openai-compatible",
500
+ name: "LunaRoute",
501
+ options: {
502
+ baseURL: `${ctx.routingUrl}/v1`,
503
+ // Real key; the AI-SDK sends it as Authorization: Bearer lr_…, which
504
+ // LunaRoute authenticates and strips at the edge.
505
+ apiKey: `{env:${ctx.keyEnvVar}}`
506
+ },
507
+ models
508
+ };
509
+ return {
510
+ fileWrites: [
511
+ {
512
+ kind: "json",
513
+ path: opencodeConfigPath(),
514
+ merge: (existing) => {
515
+ const obj = existing ?? {};
516
+ const existingProviders = objectOrEmpty(obj.provider);
517
+ const provider = { ...existingProviders, lunaroute: block };
518
+ return { ...obj, provider };
519
+ }
520
+ }
521
+ ],
522
+ exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
523
+ notes: [
524
+ "\nopencode: restart opencode, then run /models and pick a LunaRoute model."
525
+ ]
526
+ };
527
+ }
528
+
529
+ // src/setup/adapters/pi.ts
530
+ function buildPiModelEntry(m) {
531
+ const reasoning = m.capabilities?.reasoning === true;
532
+ const entry = {
533
+ id: m.id,
534
+ name: m.display_name ?? m.id,
535
+ reasoning,
536
+ input: m.capabilities?.vision ? ["text", "image"] : ["text"],
537
+ contextWindow: m.context_window_tokens ?? 0,
538
+ maxTokens: m.max_output_tokens ?? 0
539
+ };
540
+ if (!reasoning) return entry;
541
+ const pi = m.client_compat?.pi;
542
+ if (!pi) return entry;
543
+ if (pi.thinkingLevelMap && typeof pi.thinkingLevelMap === "object") {
544
+ entry.thinkingLevelMap = pi.thinkingLevelMap;
545
+ }
546
+ const compat = {};
547
+ for (const [k, v] of Object.entries(pi)) {
548
+ if (k !== "thinkingLevelMap") compat[k] = v;
549
+ }
550
+ if (Object.keys(compat).length > 0) entry.compat = compat;
551
+ return entry;
552
+ }
553
+ function buildPlan2(ctx) {
554
+ const block = {
555
+ baseUrl: `${ctx.routingUrl}/v1`,
556
+ api: "openai-completions",
557
+ // Real key; rides the native Authorization: Bearer header.
558
+ apiKey: `$${ctx.keyEnvVar}`,
559
+ models: ctx.models.map(buildPiModelEntry)
560
+ };
561
+ return {
562
+ fileWrites: [
563
+ {
564
+ kind: "json",
565
+ path: piModelsPath(),
566
+ merge: (existing) => {
567
+ const obj = existing ?? {};
568
+ const existingProviders = typeof obj.providers === "object" && obj.providers !== null ? obj.providers : {};
569
+ const providers = { ...existingProviders, lunaroute: block };
570
+ return { ...obj, providers };
571
+ }
572
+ }
573
+ ],
574
+ exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
575
+ notes: [
576
+ "\npi: open /model to pick a LunaRoute model (models.json hot-reloads).",
577
+ "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`."
578
+ ]
579
+ };
580
+ }
581
+
582
+ // src/setup/adapters/claudeCode.ts
583
+ function buildPlan3(ctx) {
584
+ const firstModel = ctx.models[0]?.id ?? "<model>";
585
+ return {
586
+ fileWrites: [],
587
+ exports: [
588
+ { name: ctx.keyEnvVar, value: "__KEY__" },
589
+ // Point at the routing root; Claude Code appends /v1/messages itself.
590
+ { 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}` },
596
+ { name: "ANTHROPIC_MODEL", value: firstModel }
597
+ ],
598
+ notes: [
599
+ "\nClaude Code: add the exports above to your shell profile, then restart Claude Code.",
600
+ `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.",
603
+ "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
+ "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"}]}'`
606
+ ]
607
+ };
608
+ }
609
+
610
+ // src/setup/adapters/copilotCli.ts
611
+ function buildPlan4(ctx) {
612
+ const firstModel = ctx.models[0]?.id ?? "<model>";
613
+ return {
614
+ fileWrites: [],
615
+ exports: [
616
+ { name: ctx.keyEnvVar, value: "__KEY__" },
617
+ // OpenAI-compatible provider; Copilot CLI appends /chat/completions itself.
618
+ { name: "COPILOT_PROVIDER_TYPE", value: "openai" },
619
+ { name: "COPILOT_PROVIDER_BASE_URL", value: `${ctx.routingUrl}/v1` },
620
+ // Copilot sends this as Authorization: Bearer. The lr_ prefix tells the
621
+ // routing service it is a LunaRoute key (authenticated at the edge, never
622
+ // forwarded upstream), so the key stays out of the URL path.
623
+ { name: "COPILOT_PROVIDER_API_KEY", value: `$${ctx.keyEnvVar}` },
624
+ { name: "COPILOT_MODEL", value: firstModel }
625
+ ],
626
+ notes: [
627
+ "\nGitHub Copilot CLI: add the exports above to your shell profile, then restart Copilot CLI.",
628
+ `Change COPILOT_MODEL to any of: ${ctx.models.map((m) => m.id).join(", ")}`,
629
+ "Auth rides the Authorization header as a bearer token; LunaRoute recognizes the lr_ key prefix and authenticates it at the edge (it is never sent to the upstream provider).",
630
+ `Anthropic-style alternative: set COPILOT_PROVIDER_TYPE=anthropic and COPILOT_PROVIDER_BASE_URL=${ctx.routingUrl} (no /v1); the key then rides the x-api-key header.`,
631
+ `Verify with: curl ${ctx.routingUrl}/v1/chat/completions -H "Authorization: Bearer $${ctx.keyEnvVar}" -H "content-type: application/json" -d '{"model":"${firstModel}","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}'`
632
+ ]
633
+ };
634
+ }
635
+
636
+ // src/setup/adapters/generic.ts
637
+ function buildPlan5(ctx) {
638
+ return {
639
+ fileWrites: [],
640
+ exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
641
+ notes: [
642
+ "\nGeneric OpenAI-compatible setup:",
643
+ ` Base URL: ${ctx.routingUrl}/v1`,
644
+ ` Auth: header Authorization: Bearer $${ctx.keyEnvVar}`,
645
+ ` Auth (alt): path ${ctx.routingUrl}/a/$${ctx.keyEnvVar}/v1`,
646
+ ` Sample model id: ${ctx.models[0]?.id ?? "<model>"}`,
647
+ ` All models: ${ctx.models.map((m) => m.id).join(", ")}`
648
+ ]
649
+ };
650
+ }
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
+
735
+ // src/commands/setup.ts
736
+ var ADAPTERS = {
737
+ opencode: buildPlan,
738
+ pi: buildPlan2,
739
+ openclaw: buildPlan6,
740
+ "claude-code": buildPlan3,
741
+ "copilot-cli": buildPlan4,
742
+ generic: buildPlan5
743
+ };
744
+ var PI_INSTALL_PACKAGES = ["npm:@lunaroute/pi-extension", "npm:pi-mcp-adapter"];
745
+ async function runSetup(harness, opts, deps = {
746
+ confirm,
747
+ spawn: (command, args) => spawn(command, args, { stdio: "inherit" })
748
+ }) {
749
+ if (harness === "pi" && opts.extension && opts.models) {
750
+ console.error("Choose one of --extension or --models.");
751
+ return 1;
752
+ }
753
+ 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.');
761
+ return 1;
762
+ }
763
+ const routingUrlRaw = opts.routingUrl || creds.routing_url;
764
+ if (!routingUrlRaw) {
765
+ console.error("No routing URL in profile; pass --routing-url.");
766
+ return 1;
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
+ }
775
+ if (harness === "pi" && !opts.print) {
776
+ return runPiSetup(opts, deps);
777
+ }
778
+ if (harness === "opencode") {
779
+ return runOpencodeSetup(creds, routingUrl, opts, deps);
780
+ }
781
+ let models;
782
+ try {
783
+ models = await fetchModels(routingUrl);
784
+ } catch (err) {
785
+ console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
786
+ return 1;
787
+ }
788
+ const ctx = {
789
+ routingUrl,
790
+ orgId: creds.org_id,
791
+ models,
792
+ keyEnvVar: "LUNAROUTE_API_KEY"
793
+ };
794
+ let plan;
795
+ try {
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 });
803
+ if (!opts.print && summary.written.length > 0) {
804
+ console.log(`
805
+ Wrote: ${summary.written.join(", ")}`);
806
+ if (summary.backedUp.length > 0) console.log(` Backups: ${summary.backedUp.join(", ")}`);
807
+ }
808
+ return 0;
809
+ } catch (err) {
810
+ console.error(err instanceof Error ? err.message : String(err));
811
+ return 1;
812
+ }
813
+ }
814
+ async function runPiSetup(opts, deps) {
815
+ try {
816
+ if (opts.extension) {
817
+ const ok = await deps.confirm(
818
+ "Install the LunaRoute Pi extension + MCP adapter via 'pi install'?",
819
+ { yes: opts.yes }
820
+ );
821
+ if (!ok) {
822
+ console.log("Skipped. Re-run with --yes, or install manually:");
823
+ console.log(` pi install ${PI_INSTALL_PACKAGES.join(" && pi install ")}`);
824
+ return 0;
825
+ }
826
+ return installPiExtension(deps.spawn);
827
+ }
828
+ 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 }));
830
+ }
831
+ if (await deps.confirm(
832
+ "Install the LunaRoute Pi extension + MCP adapter via 'pi install' (recommended \u2014 auto-registers models)?",
833
+ { yes: opts.yes }
834
+ )) {
835
+ return installPiExtension(deps.spawn);
836
+ }
837
+ return applyPiModels(
838
+ opts,
839
+ await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", {
840
+ yes: opts.yes
841
+ })
842
+ );
843
+ } catch (err) {
844
+ if (err instanceof NonInteractiveTerminalError) {
845
+ console.error(
846
+ "No interactive terminal available. Re-run with --yes to accept prompts, or --print to preview without writing."
847
+ );
848
+ return 1;
849
+ }
850
+ console.error(err instanceof Error ? err.message : String(err));
851
+ return 1;
852
+ }
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
+ }
945
+ async function installPiExtension(spawn3) {
946
+ for (const pkg of PI_INSTALL_PACKAGES) {
947
+ const code = await new Promise((resolve) => {
948
+ const child = spawn3("pi", ["install", pkg]);
949
+ child.on("error", (err) => {
950
+ const e = err;
951
+ if (e?.code === "ENOENT") {
952
+ console.error(`Error: "pi" not found on PATH. Install pi first: https://pi.dev (exit 127)`);
953
+ resolve(127);
954
+ return;
955
+ }
956
+ console.error(err instanceof Error ? err.message : String(err));
957
+ resolve(1);
958
+ });
959
+ child.on("exit", (code2) => {
960
+ resolve(typeof code2 === "number" ? code2 : 1);
961
+ });
962
+ });
963
+ if (code !== 0) {
964
+ const done = PI_INSTALL_PACKAGES.slice(0, PI_INSTALL_PACKAGES.indexOf(pkg));
965
+ console.error(
966
+ `'pi install ${pkg}' failed (exit ${code}). Installed so far: ${done.length ? done.join(", ") : "nothing"}. Re-run 'lunaroute setup pi --extension' to retry.`
967
+ );
968
+ return code || 1;
969
+ }
970
+ }
971
+ console.log("\nInstalled. Next steps inside pi:");
972
+ console.log(" 1. /login lunaroute \u2014 browser login issues and stores an lr_ key.");
973
+ console.log(" 2. /model \u2014 pick a lunaroute/* model (models auto-sync from /v1/models).");
974
+ return 0;
975
+ }
976
+ async function applyPiModels(opts, write) {
977
+ const creds = loadProfile(opts.profile);
978
+ let models;
979
+ try {
980
+ models = await fetchModels(opts.routingUrl || creds.routing_url || "");
981
+ } catch (err) {
982
+ console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
983
+ return 1;
984
+ }
985
+ const plan = buildPlan2({
986
+ routingUrl: opts.routingUrl || creds.routing_url,
987
+ orgId: creds.org_id,
988
+ models,
989
+ keyEnvVar: "LUNAROUTE_API_KEY"
990
+ });
991
+ try {
992
+ const summary = await applyPlan(plan, { print: !write, key: creds.routing_key });
993
+ if (write && summary.written.length > 0) {
994
+ console.log(`
995
+ Wrote: ${summary.written.join(", ")}`);
996
+ if (summary.backedUp.length > 0) console.log(` Backups: ${summary.backedUp.join(", ")}`);
997
+ }
998
+ return 0;
999
+ } catch (err) {
1000
+ console.error(err instanceof Error ? err.message : String(err));
1001
+ return 1;
1002
+ }
1003
+ }
1004
+
1005
+ // src/commands/models.ts
1006
+ async function runModels(profile, opts) {
1007
+ const s = resolveSettings(profile);
1008
+ if (!s.routing_key) {
1009
+ console.error('Not logged in. Run "lunaroute login" first.');
1010
+ return 1;
1011
+ }
1012
+ let ids;
1013
+ try {
1014
+ ids = await getModels({ apiUrl: s.api_url, apiKey: s.routing_key });
1015
+ } catch (err) {
1016
+ console.error(err instanceof Error ? err.message : String(err));
1017
+ return 1;
1018
+ }
1019
+ if (opts.json) {
1020
+ console.log(JSON.stringify(ids, null, 2));
1021
+ } else {
1022
+ for (const id of ids) console.log(id);
1023
+ }
1024
+ return 0;
1025
+ }
1026
+
1027
+ // src/render.ts
1028
+ function renderTable(headers, rows) {
1029
+ const all = [headers, ...rows];
1030
+ const widths = headers.map(
1031
+ (_, col) => Math.max(...all.map((r) => (r[col] ?? "").length))
1032
+ );
1033
+ const fmt = (r) => r.map((cell, col) => col === r.length - 1 ? cell : (cell ?? "").padEnd(widths[col])).join(" ").replace(/\s+$/, "");
1034
+ return [headers, ...rows].map(fmt).join("\n");
1035
+ }
1036
+
1037
+ // src/commands/pricing.ts
1038
+ async function runPricing(profile, opts) {
1039
+ const s = resolveSettings(profile);
1040
+ if (!s.routing_key) {
1041
+ console.error('Not logged in. Run "lunaroute login" first.');
1042
+ return 1;
1043
+ }
1044
+ let rows;
1045
+ try {
1046
+ rows = await getPricing({ apiUrl: s.api_url, apiKey: s.routing_key }, opts.model);
1047
+ } catch (err) {
1048
+ console.error(err instanceof Error ? err.message : String(err));
1049
+ return 1;
1050
+ }
1051
+ if (opts.json) {
1052
+ console.log(JSON.stringify(rows, null, 2));
1053
+ return 0;
1054
+ }
1055
+ const cell = (n) => n === void 0 ? "\u2014" : String(n);
1056
+ const table = renderTable(
1057
+ ["MODEL", "INPUT (cr/M)", "OUTPUT (cr/M)", "CACHED (cr/M)"],
1058
+ rows.map(
1059
+ (r) => r.priced ? [r.model, cell(r.input_credits_per_million), cell(r.output_credits_per_million), cell(r.cached_input_credits_per_million)] : [r.model, "\u2014", "\u2014", "\u2014"]
1060
+ )
1061
+ );
1062
+ console.log(table);
1063
+ return 0;
1064
+ }
1065
+
1066
+ // src/commands/usage.ts
1067
+ var tok = (n) => n === null || n === void 0 ? "0" : String(n);
1068
+ async function runUsage(profile, opts) {
1069
+ const s = resolveSettings(profile);
1070
+ if (!s.routing_key) {
1071
+ console.error('Not logged in. Run "lunaroute login" first.');
1072
+ return 1;
1073
+ }
1074
+ let usage;
1075
+ try {
1076
+ usage = await getUsage({ apiUrl: s.api_url, apiKey: s.routing_key }, opts.limit);
1077
+ } catch (err) {
1078
+ console.error(err instanceof Error ? err.message : String(err));
1079
+ return 1;
1080
+ }
1081
+ if (opts.json) {
1082
+ console.log(JSON.stringify(usage, null, 2));
1083
+ return 0;
1084
+ }
1085
+ const w = usage.wallet;
1086
+ console.log(
1087
+ `Wallet: available ${w.available_credits} (balance ${w.balance_credits}, reserved ${w.reserved_credits})`
1088
+ );
1089
+ console.log("");
1090
+ const table = renderTable(
1091
+ ["TIME", "MODEL", "IN", "OUT", "CACHED", "\u0394CREDITS", "BALANCE"],
1092
+ usage.entries.map((e) => [
1093
+ e.created_at,
1094
+ e.model || "\u2014",
1095
+ tok(e.input_tokens),
1096
+ tok(e.output_tokens),
1097
+ tok(e.cached_input_tokens),
1098
+ String(e.credit_delta),
1099
+ String(e.balance_after_credits)
1100
+ ])
1101
+ );
1102
+ console.log(table);
1103
+ return 0;
1104
+ }
1105
+
1106
+ // src/projectId.ts
1107
+ import { execSync as execSync2 } from "child_process";
1108
+ function normalizeRemote(url) {
1109
+ let s = url.trim();
1110
+ if (s.includes("://")) {
1111
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
1112
+ s = s.replace(/^[^/@]+@/, "");
1113
+ s = s.replace(/^([^/:]+):\d+/, "$1");
1114
+ } else if (/^[^/]+:/.test(s)) {
1115
+ s = s.replace(/^[^@/]+@/, "");
1116
+ s = s.replace(":", "/");
1117
+ }
1118
+ s = s.replace(/\.git$/, "");
1119
+ s = s.toLowerCase();
1120
+ if (s.length > 128) s = s.slice(0, 128);
1121
+ return s;
1122
+ }
1123
+ function resolveProjectId() {
1124
+ const env = process.env.LUNAROUTE_PROJECT_ID?.trim();
1125
+ if (env) return env;
1126
+ try {
1127
+ const url = execSync2("git config --get remote.origin.url", {
1128
+ cwd: process.cwd(),
1129
+ encoding: "utf8",
1130
+ stdio: ["ignore", "pipe", "ignore"]
1131
+ }).trim();
1132
+ if (!url) return null;
1133
+ return normalizeRemote(url);
1134
+ } catch {
1135
+ return null;
1136
+ }
1137
+ }
1138
+
1139
+ // src/memoryClient.ts
1140
+ async function memoryPost(ctx, path, body) {
1141
+ const res = await fetch(`${ctx.routingUrl}${path}`, {
1142
+ method: "POST",
1143
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.routingKey}` },
1144
+ body: JSON.stringify(body)
1145
+ });
1146
+ if (!res.ok) {
1147
+ let detail = `HTTP ${res.status}`;
1148
+ try {
1149
+ const b = await res.json();
1150
+ const code = b?.error?.code;
1151
+ const message = b?.error?.message;
1152
+ if (code && message) detail = `${code}: ${message}`;
1153
+ else if (message) detail = message;
1154
+ else if (code) detail = code;
1155
+ } catch {
1156
+ }
1157
+ if (res.status === 404 && path.endsWith("/read")) detail = "exchange not found";
1158
+ throw new Error(`memory request failed: ${detail}`);
1159
+ }
1160
+ return await res.json();
1161
+ }
1162
+ async function searchMemory(ctx, p) {
1163
+ const body = { project_id: p.projectId };
1164
+ if (p.query !== void 0) body.query = p.query;
1165
+ if (p.concepts !== void 0) body.concepts = p.concepts;
1166
+ if (p.mode !== void 0) body.mode = p.mode;
1167
+ if (p.limit !== void 0) body.limit = p.limit;
1168
+ if (p.after !== void 0) body.after = p.after;
1169
+ if (p.before !== void 0) body.before = p.before;
1170
+ return memoryPost(ctx, "/v1/memory/search", body);
1171
+ }
1172
+ async function readMemory(ctx, p) {
1173
+ const body = { project_id: p.projectId, id: p.id };
1174
+ if (p.startLine !== void 0) body.start_line = p.startLine;
1175
+ if (p.endLine !== void 0) body.end_line = p.endLine;
1176
+ return memoryPost(ctx, "/v1/memory/read", body);
1177
+ }
1178
+
1179
+ // src/commands/memory.ts
1180
+ var NOT_LOGGED_IN = 'Not logged in. Run "lunaroute login" first.';
1181
+ var NO_PROJECT = "No project context: run inside a git repo with an origin remote, or set LUNAROUTE_PROJECT_ID.";
1182
+ function contextFor(profile) {
1183
+ const s = resolveSettings(profile);
1184
+ if (!s.routing_key) {
1185
+ console.error(NOT_LOGGED_IN);
1186
+ return 1;
1187
+ }
1188
+ const projectId = resolveProjectId();
1189
+ if (!projectId) {
1190
+ console.error(NO_PROJECT);
1191
+ return 1;
1192
+ }
1193
+ return { ctx: { routingUrl: s.routing_url, routingKey: s.routing_key }, projectId };
1194
+ }
1195
+ async function runMemorySearch(profile, opts) {
1196
+ const c = contextFor(profile);
1197
+ if (typeof c === "number") return c;
1198
+ const concepts = opts.concepts ? opts.concepts.split(",").map((x) => x.trim()).filter(Boolean) : void 0;
1199
+ const hasQuery = !!opts.query && opts.query.trim() !== "";
1200
+ const hasConcepts = !!concepts && concepts.length > 0;
1201
+ if (hasQuery === hasConcepts) {
1202
+ console.error("Provide exactly one of a query argument or --concepts.");
1203
+ return 1;
1204
+ }
1205
+ let result;
1206
+ try {
1207
+ result = await searchMemory(c.ctx, {
1208
+ projectId: c.projectId,
1209
+ query: hasQuery ? opts.query : void 0,
1210
+ concepts: hasConcepts ? concepts : void 0,
1211
+ mode: opts.mode,
1212
+ limit: opts.limit,
1213
+ after: opts.after,
1214
+ before: opts.before
1215
+ });
1216
+ } catch (err) {
1217
+ console.error(err instanceof Error ? err.message : String(err));
1218
+ return 1;
1219
+ }
1220
+ if (opts.json) {
1221
+ console.log(JSON.stringify(result, null, 2));
1222
+ return 0;
1223
+ }
1224
+ if (result.results.length === 0) {
1225
+ console.log("No results.");
1226
+ return 0;
1227
+ }
1228
+ for (const h of result.results) {
1229
+ const snippet = h.user_turn.replace(/\s+/g, " ").slice(0, 100);
1230
+ console.log(`${h.exchange_id} score=${h.score.toFixed(3)} ${h.timestamp} ${h.model}`);
1231
+ console.log(` ${snippet}`);
1232
+ }
1233
+ return 0;
1234
+ }
1235
+ async function runMemoryRead(profile, id, opts) {
1236
+ const c = contextFor(profile);
1237
+ if (typeof c === "number") return c;
1238
+ let result;
1239
+ try {
1240
+ result = await readMemory(c.ctx, { projectId: c.projectId, id, startLine: opts.start, endLine: opts.end });
1241
+ } catch (err) {
1242
+ console.error(err instanceof Error ? err.message : String(err));
1243
+ return 1;
1244
+ }
1245
+ if (opts.json) {
1246
+ console.log(JSON.stringify(result, null, 2));
1247
+ return 0;
1248
+ }
1249
+ console.log(result.text);
1250
+ return 0;
1251
+ }
1252
+
1253
+ // src/mcp.ts
1254
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1255
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1256
+ import { z } from "zod";
1257
+ var NO_PROJECT2 = "No project context: run the MCP server inside a git repo with an origin remote, or set LUNAROUTE_PROJECT_ID.";
1258
+ function toolError(text) {
1259
+ return { content: [{ type: "text", text }], isError: true };
1260
+ }
1261
+ function toolOk(data) {
1262
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1263
+ }
1264
+ async function handleSearch(deps, args) {
1265
+ const projectId = deps.resolveProjectId();
1266
+ if (!projectId) return toolError(NO_PROJECT2);
1267
+ const concepts = args.concepts?.map((c) => c.trim()).filter(Boolean);
1268
+ const hasQuery = !!args.query && args.query.trim() !== "";
1269
+ const hasConcepts = !!concepts && concepts.length > 0;
1270
+ if (hasQuery === hasConcepts) return toolError("Provide exactly one of query or concepts.");
1271
+ try {
1272
+ const data = await deps.search({
1273
+ projectId,
1274
+ query: hasQuery ? args.query : void 0,
1275
+ concepts: hasConcepts ? concepts : void 0,
1276
+ mode: args.mode,
1277
+ limit: args.limit,
1278
+ after: args.after,
1279
+ before: args.before
1280
+ });
1281
+ return toolOk(data);
1282
+ } catch (err) {
1283
+ return toolError(err instanceof Error ? err.message : String(err));
1284
+ }
1285
+ }
1286
+ async function handleRead(deps, args) {
1287
+ const projectId = deps.resolveProjectId();
1288
+ if (!projectId) return toolError(NO_PROJECT2);
1289
+ try {
1290
+ const data = await deps.read({ projectId, id: args.id, startLine: args.startLine, endLine: args.endLine });
1291
+ return toolOk(data);
1292
+ } catch (err) {
1293
+ return toolError(err instanceof Error ? err.message : String(err));
1294
+ }
1295
+ }
1296
+ async function runMcp(profile) {
1297
+ const s = resolveSettings(profile);
1298
+ if (!s.routing_key) {
1299
+ console.error('Not logged in. Run "lunaroute login" first.');
1300
+ process.exit(1);
1301
+ }
1302
+ const ctx = { routingUrl: s.routing_url, routingKey: s.routing_key };
1303
+ const deps = {
1304
+ search: (p) => searchMemory(ctx, p),
1305
+ read: (p) => readMemory(ctx, p),
1306
+ resolveProjectId
1307
+ };
1308
+ const server = new McpServer({ name: "lunaroute-memory", version: "0.1.0" });
1309
+ server.registerTool(
1310
+ "search",
1311
+ {
1312
+ title: "Search LunaRoute memory",
1313
+ description: "Search past conversations for this project before starting a task to recover prior decisions and solutions. Provide either a natural-language query or a list of concepts (AND-matched).",
1314
+ inputSchema: {
1315
+ query: z.string().optional(),
1316
+ concepts: z.array(z.string()).optional(),
1317
+ mode: z.enum(["vector", "text", "both"]).optional(),
1318
+ limit: z.number().int().positive().optional(),
1319
+ after: z.string().optional(),
1320
+ before: z.string().optional()
1321
+ }
1322
+ },
1323
+ async (args) => handleSearch(deps, args)
1324
+ );
1325
+ server.registerTool(
1326
+ "read",
1327
+ {
1328
+ title: "Read a LunaRoute memory exchange",
1329
+ description: "Read a full past conversation by id, with optional line pagination.",
1330
+ inputSchema: {
1331
+ id: z.string(),
1332
+ startLine: z.number().int().optional(),
1333
+ endLine: z.number().int().optional()
1334
+ }
1335
+ },
1336
+ async (args) => handleRead(deps, args)
1337
+ );
1338
+ const transport = new StdioServerTransport();
1339
+ await server.connect(transport);
1340
+ }
1341
+
1342
+ // src/skill/content.ts
1343
+ var SKILL_MD = `---
1344
+ name: lunaroute-memory
1345
+ description: >-
1346
+ Search and read this project's cross-session LunaRoute memory. Use BEFORE
1347
+ starting a task to recover prior decisions, solutions, and gotchas from past
1348
+ conversations, and whenever you need context that isn't in the current files.
1349
+ ---
1350
+
1351
+ # LunaRoute Memory
1352
+
1353
+ This project has cross-session memory captured by LunaRoute from past coding
1354
+ sessions. Two tools are available from the \`lunaroute-memory\` MCP server:
1355
+
1356
+ - **search** \u2014 find relevant past exchanges for this project. Pass a natural
1357
+ \`query\`, or \`concepts\` (AND-matched) for precise lookups. Returns ranked
1358
+ hits, each with an \`exchange_id\`, a snippet, the model, and a timestamp.
1359
+ - **read** \u2014 fetch the full text of one exchange by \`id\` (optionally a line
1360
+ range) once a search hit looks relevant.
1361
+
1362
+ ## When to use
1363
+
1364
+ - **Before starting any non-trivial task**, search for prior work on the same
1365
+ area to avoid re-deriving decisions or repeating past mistakes.
1366
+ - When you hit an unfamiliar pattern, error, or design choice, search for it.
1367
+ - After a promising search hit, **read** the full exchange for the detail and
1368
+ rationale the snippet omits.
1369
+
1370
+ Memory is scoped to the current project automatically. An empty search result
1371
+ is not an error \u2014 just proceed normally.
1372
+ `;
1373
+
1374
+ // src/skill/plan.ts
1375
+ function buildSkillPlan(opts) {
1376
+ const args = ["-y", "@lunaroute/cli", "mcp"];
1377
+ if (opts.profile !== "default") args.push("--profile", opts.profile);
1378
+ const entry = { type: "stdio", command: "npx", args };
1379
+ const mergeMcp = (existing) => {
1380
+ const obj = existing ?? {};
1381
+ const servers = typeof obj.mcpServers === "object" && obj.mcpServers !== null ? obj.mcpServers : {};
1382
+ return { ...obj, mcpServers: { ...servers, [LUNAROUTE_SKILL_NAME]: entry } };
1383
+ };
1384
+ const skillPath = opts.project ? claudeProjectSkillPath(opts.root) : claudeUserSkillPath();
1385
+ const mcpPath = opts.project ? claudeProjectMcpPath(opts.root) : claudeUserConfigPath();
1386
+ return {
1387
+ fileWrites: [
1388
+ { kind: "text", path: skillPath, content: SKILL_MD, fileMode: 420, dirMode: 493 },
1389
+ { kind: "json", path: mcpPath, merge: mergeMcp, fileMode: 384 }
1390
+ ],
1391
+ exports: [],
1392
+ notes: [
1393
+ "\nLunaRoute memory installed for Claude Code. Restart Claude Code to pick it up.",
1394
+ 'If you have not yet, run "lunaroute login" \u2014 the memory server authenticates with your stored key at runtime.',
1395
+ 'Verify by asking Claude to "search project memory", or run /lunaroute-memory.'
1396
+ ]
1397
+ };
1398
+ }
1399
+
1400
+ // src/commands/skillInstall.ts
1401
+ async function runSkillInstall(profile, opts) {
1402
+ const s = resolveSettings(profile);
1403
+ if (!s.routing_key) {
1404
+ console.error(
1405
+ 'Warning: not logged in \u2014 the memory server will not authenticate until you run "lunaroute login".'
1406
+ );
1407
+ }
1408
+ let root;
1409
+ if (opts.project) {
1410
+ const r = gitRepoRoot();
1411
+ if (!r) {
1412
+ console.error(
1413
+ "--project requires a git repository (no work tree found). Omit --project to install at the user level."
1414
+ );
1415
+ return 1;
1416
+ }
1417
+ root = r;
1418
+ }
1419
+ const plan = buildSkillPlan({ project: opts.project, profile, root });
1420
+ try {
1421
+ const summary = await applyPlan(plan, { print: opts.print, key: "" });
1422
+ if (!opts.print) {
1423
+ for (const f of summary.written) console.log(`wrote ${f}`);
1424
+ for (const b of summary.backedUp) console.log(`backed up ${b}`);
1425
+ }
1426
+ } catch (err) {
1427
+ console.error(err instanceof Error ? err.message : String(err));
1428
+ return 1;
1429
+ }
1430
+ return 0;
1431
+ }
1432
+
1433
+ // src/commands/run.ts
1434
+ import { spawn as spawn2 } from "child_process";
1435
+
1436
+ // src/run/adapters/claudeCode.ts
1437
+ function buildRunSpec(ctx) {
1438
+ return {
1439
+ command: "claude",
1440
+ args: [],
1441
+ env: {
1442
+ ANTHROPIC_BASE_URL: ctx.routingUrl,
1443
+ ANTHROPIC_API_KEY: ctx.apiKey,
1444
+ ANTHROPIC_MODEL: ctx.model,
1445
+ // Pins the Haiku/background model so Claude Code's background tasks
1446
+ // (titles, summaries, compaction) route through LunaRoute instead of
1447
+ // 404'ing against Anthropic's hardcoded Haiku id. Mirrors the Connect tab
1448
+ // (kata f59a). Same default as ANTHROPIC_MODEL — catalog has no
1449
+ // size/tier field, so users tune it; the point is a valid LunaRoute model.
1450
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: ctx.model,
1451
+ // Populates the /model picker from GET /v1/models at startup. LunaRoute
1452
+ // already serves the Anthropic-native shape (spike-verified, kata nkey).
1453
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
1454
+ }
1455
+ };
1456
+ }
1457
+
1458
+ // src/run/adapters/codex.ts
1459
+ function buildRunSpec2(ctx) {
1460
+ return {
1461
+ command: "codex",
1462
+ args: ["--model", ctx.model],
1463
+ env: {
1464
+ OPENAI_BASE_URL: `${ctx.routingUrl}/v1`,
1465
+ OPENAI_API_KEY: ctx.apiKey
1466
+ }
1467
+ };
1468
+ }
1469
+
1470
+ // src/commands/run.ts
1471
+ var ADAPTERS2 = {
1472
+ claude: buildRunSpec,
1473
+ "claude-code": buildRunSpec,
1474
+ codex: buildRunSpec2
1475
+ };
1476
+ var INSTALL_HINT = {
1477
+ claude: "Install Claude Code: https://docs.anthropic.com/claude-code/install",
1478
+ "claude-code": "Install Claude Code: https://docs.anthropic.com/claude-code/install",
1479
+ codex: "Install Codex: https://github.com/openai/codex#install"
1480
+ };
1481
+ var realDeps = {
1482
+ spawn: (command, args, env) => spawn2(command, args, { env, stdio: "inherit" }),
1483
+ fetchModels
1484
+ };
1485
+ async function runRun(harness, opts, deps = realDeps) {
1486
+ const adapter = ADAPTERS2[harness];
1487
+ if (!adapter) {
1488
+ console.error(`Unknown harness "${harness}". Choose one of: ${Object.keys(ADAPTERS2).join(", ")}.`);
1489
+ return 1;
1490
+ }
1491
+ const creds = loadProfile(opts.profile);
1492
+ if (!creds || !creds.routing_key) {
1493
+ console.error('Not logged in. Run "lunaroute login" first.');
1494
+ return 1;
1495
+ }
1496
+ let model = opts.model;
1497
+ if (!model) {
1498
+ let models;
1499
+ try {
1500
+ models = await deps.fetchModels(creds.routing_url);
1501
+ } catch (err) {
1502
+ console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
1503
+ return 1;
1504
+ }
1505
+ model = models[0]?.id;
1506
+ if (!model) {
1507
+ console.error("No models available from the catalog. Pass --model to pick one.");
1508
+ return 1;
1509
+ }
1510
+ console.error(`# using model ${model} (pass --model to change)`);
1511
+ }
1512
+ const spec = adapter({
1513
+ routingUrl: creds.routing_url,
1514
+ apiKey: creds.routing_key,
1515
+ model
1516
+ });
1517
+ const args = spec.args.concat(opts.passthrough ?? []);
1518
+ const env = { ...process.env, ...spec.env };
1519
+ const child = deps.spawn(spec.command, args, env);
1520
+ return new Promise((resolve) => {
1521
+ child.on("error", (err) => {
1522
+ const e = err;
1523
+ if (e?.code === "ENOENT") {
1524
+ console.error(
1525
+ `Error: "${spec.command}" not found on PATH. ${INSTALL_HINT[harness]} (exit 127)`
1526
+ );
1527
+ resolve(127);
1528
+ return;
1529
+ }
1530
+ console.error(err instanceof Error ? err.message : String(err));
1531
+ resolve(1);
1532
+ });
1533
+ child.on("exit", (code) => {
1534
+ resolve(typeof code === "number" ? code : 1);
1535
+ });
1536
+ });
1537
+ }
1538
+
1539
+ // src/index.ts
1540
+ 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");
1542
+ program.command("login").description("Authorize this device via the browser and store a routing key.").action(async () => {
1543
+ await login(program.opts().profile);
1544
+ });
1545
+ program.command("whoami").description("Show the signed-in user and organization.").action(() => {
1546
+ whoami(program.opts().profile);
1547
+ });
1548
+ program.command("logout").description("Remove stored credentials for the active profile.").action(() => {
1549
+ logout(program.opts().profile);
1550
+ });
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) => {
1552
+ const code = await runSetup(harness, {
1553
+ profile: program.opts().profile,
1554
+ print: opts.print,
1555
+ routingUrl: opts.routingUrl,
1556
+ yes: opts.yes,
1557
+ extension: opts.extension,
1558
+ models: opts.models
1559
+ });
1560
+ if (code !== 0) process.exit(code);
1561
+ });
1562
+ program.command("run <harness>").description("Launch a coding harness (claude | claude-code | codex) configured to use LunaRoute for this session.").option("--model <id>", "model id to launch on (default: first model in the catalog)").allowUnknownOption(true).action(async (harness, opts) => {
1563
+ const raw = process.argv;
1564
+ const ddIdx = raw.indexOf("--");
1565
+ const runIdx = raw.indexOf("run");
1566
+ const passthrough = ddIdx > runIdx && ddIdx !== -1 ? raw.slice(ddIdx + 1) : [];
1567
+ const code = await runRun(
1568
+ harness,
1569
+ { profile: program.opts().profile, model: opts.model, passthrough }
1570
+ );
1571
+ if (code !== 0) process.exit(code);
1572
+ });
1573
+ program.command("models").description("List available LunaRoute models.").option("--json", "output machine-readable JSON", false).action(async (opts) => {
1574
+ const code = await runModels(program.opts().profile, { json: opts.json });
1575
+ if (code !== 0) process.exit(code);
1576
+ });
1577
+ program.command("pricing").description("Show per-model pricing (credits per million tokens).").option("--model <id>", "show pricing for a single model").option("--json", "output machine-readable JSON", false).action(async (opts) => {
1578
+ const code = await runPricing(program.opts().profile, { json: opts.json, model: opts.model });
1579
+ if (code !== 0) process.exit(code);
1580
+ });
1581
+ program.command("usage").description("Show wallet balance and recent usage.").option("--limit <n>", "number of ledger entries (default 20)", (v) => {
1582
+ const n = parseInt(v, 10);
1583
+ if (Number.isNaN(n) || n < 1) {
1584
+ throw new InvalidArgumentError("--limit must be a positive integer");
1585
+ }
1586
+ return n;
1587
+ }).option("--json", "output machine-readable JSON", false).action(async (opts) => {
1588
+ const code = await runUsage(program.opts().profile, { json: opts.json, limit: opts.limit });
1589
+ if (code !== 0) process.exit(code);
1590
+ });
1591
+ program.command("mcp").description("Run a stdio MCP server exposing LunaRoute memory search/read tools.").action(async () => {
1592
+ await runMcp(program.opts().profile);
1593
+ });
1594
+ var memory = program.command("memory").description("Query LunaRoute memory for the current project.");
1595
+ memory.command("search [query]").description("Search past conversations for the current project.").option("--concepts <list>", "comma-separated concepts (AND-matched); alternative to a query").addOption(new Option("--mode <mode>", "search mode").choices(["vector", "text", "both"])).option("--limit <n>", "max results", (v) => {
1596
+ const n = parseInt(v, 10);
1597
+ if (Number.isNaN(n) || n < 1) throw new InvalidArgumentError("--limit must be a positive integer");
1598
+ return n;
1599
+ }).option("--after <ts>", "only results after this ISO timestamp").option("--before <ts>", "only results before this ISO timestamp").option("--json", "output machine-readable JSON", false).action(async (query, opts) => {
1600
+ const code = await runMemorySearch(program.opts().profile, { query, ...opts });
1601
+ if (code !== 0) process.exit(code);
1602
+ });
1603
+ memory.command("read <id>").description("Read a full past conversation by id.").option("--start <n>", "start line (1-based)", (v) => {
1604
+ const n = parseInt(v, 10);
1605
+ if (Number.isNaN(n) || n < 1) throw new InvalidArgumentError("--start must be a positive integer");
1606
+ return n;
1607
+ }).option("--end <n>", "end line (1-based)", (v) => {
1608
+ const n = parseInt(v, 10);
1609
+ if (Number.isNaN(n) || n < 1) throw new InvalidArgumentError("--end must be a positive integer");
1610
+ return n;
1611
+ }).option("--json", "output machine-readable JSON", false).action(async (id, opts) => {
1612
+ const code = await runMemoryRead(program.opts().profile, id, opts);
1613
+ if (code !== 0) process.exit(code);
1614
+ });
1615
+ var skill = program.command("skill").description("Install LunaRoute memory into a coding harness.");
1616
+ skill.command("install").description("Install the LunaRoute memory skill + MCP server into Claude Code.").option("--project", "write committable repo-local config instead of user-level", false).option("--print", "print what would be written instead of writing", false).action(async (opts) => {
1617
+ const code = await runSkillInstall(program.opts().profile, opts);
1618
+ if (code !== 0) process.exit(code);
1619
+ });
1620
+ program.parseAsync(process.argv).catch((err) => {
1621
+ console.error(err instanceof Error ? err.message : err);
1622
+ process.exit(1);
1623
+ });