@lunaroute/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/dist/index.js +1380 -0
- package/package.json +1 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,1380 @@
|
|
|
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 piModelsPath() {
|
|
415
|
+
return join2(homedir2(), ".pi", "agent", "models.json");
|
|
416
|
+
}
|
|
417
|
+
var LUNAROUTE_SKILL_NAME = "lunaroute-memory";
|
|
418
|
+
function claudeUserConfigPath() {
|
|
419
|
+
return join2(homedir2(), ".claude.json");
|
|
420
|
+
}
|
|
421
|
+
function claudeUserSkillPath(name = LUNAROUTE_SKILL_NAME) {
|
|
422
|
+
return join2(homedir2(), ".claude", "skills", name, "SKILL.md");
|
|
423
|
+
}
|
|
424
|
+
function claudeProjectMcpPath(root) {
|
|
425
|
+
return join2(root, ".mcp.json");
|
|
426
|
+
}
|
|
427
|
+
function claudeProjectSkillPath(root, name = LUNAROUTE_SKILL_NAME) {
|
|
428
|
+
return join2(root, ".claude", "skills", name, "SKILL.md");
|
|
429
|
+
}
|
|
430
|
+
function gitRepoRoot(cwd = process.cwd()) {
|
|
431
|
+
try {
|
|
432
|
+
const out = execSync("git rev-parse --show-toplevel", {
|
|
433
|
+
cwd,
|
|
434
|
+
encoding: "utf8",
|
|
435
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
436
|
+
}).trim();
|
|
437
|
+
return out || null;
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/setup/adapters/opencode.ts
|
|
444
|
+
function buildPlan(ctx) {
|
|
445
|
+
const models = {};
|
|
446
|
+
for (const m of ctx.models) models[m.id] = { name: m.id };
|
|
447
|
+
const block = {
|
|
448
|
+
npm: "@ai-sdk/openai-compatible",
|
|
449
|
+
name: "LunaRoute",
|
|
450
|
+
options: {
|
|
451
|
+
baseURL: `${ctx.routingUrl}/v1`,
|
|
452
|
+
// Real key; the AI-SDK sends it as Authorization: Bearer lr_…, which
|
|
453
|
+
// LunaRoute authenticates and strips at the edge.
|
|
454
|
+
apiKey: `{env:${ctx.keyEnvVar}}`
|
|
455
|
+
},
|
|
456
|
+
models
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
fileWrites: [
|
|
460
|
+
{
|
|
461
|
+
kind: "json",
|
|
462
|
+
path: opencodeConfigPath(),
|
|
463
|
+
merge: (existing) => {
|
|
464
|
+
const obj = existing ?? {};
|
|
465
|
+
const existingProviders = typeof obj.provider === "object" && obj.provider !== null ? obj.provider : {};
|
|
466
|
+
const provider = { ...existingProviders, lunaroute: block };
|
|
467
|
+
return { ...obj, provider };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
],
|
|
471
|
+
exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
|
|
472
|
+
notes: [
|
|
473
|
+
"\nopencode: restart opencode, then run /models and pick a LunaRoute model."
|
|
474
|
+
]
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/setup/adapters/pi.ts
|
|
479
|
+
function buildPiModelEntry(m) {
|
|
480
|
+
const reasoning = m.capabilities?.reasoning === true;
|
|
481
|
+
const entry = {
|
|
482
|
+
id: m.id,
|
|
483
|
+
name: m.display_name ?? m.id,
|
|
484
|
+
reasoning,
|
|
485
|
+
input: m.capabilities?.vision ? ["text", "image"] : ["text"],
|
|
486
|
+
contextWindow: m.context_window_tokens ?? 0,
|
|
487
|
+
maxTokens: m.max_output_tokens ?? 0
|
|
488
|
+
};
|
|
489
|
+
if (!reasoning) return entry;
|
|
490
|
+
const pi = m.client_compat?.pi;
|
|
491
|
+
if (!pi) return entry;
|
|
492
|
+
if (pi.thinkingLevelMap && typeof pi.thinkingLevelMap === "object") {
|
|
493
|
+
entry.thinkingLevelMap = pi.thinkingLevelMap;
|
|
494
|
+
}
|
|
495
|
+
const compat = {};
|
|
496
|
+
for (const [k, v] of Object.entries(pi)) {
|
|
497
|
+
if (k !== "thinkingLevelMap") compat[k] = v;
|
|
498
|
+
}
|
|
499
|
+
if (Object.keys(compat).length > 0) entry.compat = compat;
|
|
500
|
+
return entry;
|
|
501
|
+
}
|
|
502
|
+
function buildPlan2(ctx) {
|
|
503
|
+
const block = {
|
|
504
|
+
baseUrl: `${ctx.routingUrl}/v1`,
|
|
505
|
+
api: "openai-completions",
|
|
506
|
+
// Real key; rides the native Authorization: Bearer header.
|
|
507
|
+
apiKey: `$${ctx.keyEnvVar}`,
|
|
508
|
+
models: ctx.models.map(buildPiModelEntry)
|
|
509
|
+
};
|
|
510
|
+
return {
|
|
511
|
+
fileWrites: [
|
|
512
|
+
{
|
|
513
|
+
kind: "json",
|
|
514
|
+
path: piModelsPath(),
|
|
515
|
+
merge: (existing) => {
|
|
516
|
+
const obj = existing ?? {};
|
|
517
|
+
const existingProviders = typeof obj.providers === "object" && obj.providers !== null ? obj.providers : {};
|
|
518
|
+
const providers = { ...existingProviders, lunaroute: block };
|
|
519
|
+
return { ...obj, providers };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
],
|
|
523
|
+
exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
|
|
524
|
+
notes: [
|
|
525
|
+
"\npi: open /model to pick a LunaRoute model (models.json hot-reloads).",
|
|
526
|
+
"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`."
|
|
527
|
+
]
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/setup/adapters/claudeCode.ts
|
|
532
|
+
function buildPlan3(ctx) {
|
|
533
|
+
const firstModel = ctx.models[0]?.id ?? "<model>";
|
|
534
|
+
return {
|
|
535
|
+
fileWrites: [],
|
|
536
|
+
exports: [
|
|
537
|
+
{ name: ctx.keyEnvVar, value: "__KEY__" },
|
|
538
|
+
// Point at the routing root; Claude Code appends /v1/messages itself.
|
|
539
|
+
{ name: "ANTHROPIC_BASE_URL", value: ctx.routingUrl },
|
|
540
|
+
// Claude Code sends ANTHROPIC_API_KEY as the native x-api-key header. The
|
|
541
|
+
// routing edge recognizes the lr_ prefix, authenticates it, and strips the
|
|
542
|
+
// header before forwarding upstream — so the key never leaks and never
|
|
543
|
+
// sits in the URL path.
|
|
544
|
+
{ name: "ANTHROPIC_API_KEY", value: `$${ctx.keyEnvVar}` },
|
|
545
|
+
{ name: "ANTHROPIC_MODEL", value: firstModel }
|
|
546
|
+
],
|
|
547
|
+
notes: [
|
|
548
|
+
"\nClaude Code: add the exports above to your shell profile, then restart Claude Code.",
|
|
549
|
+
`Change ANTHROPIC_MODEL to any of: ${ctx.models.map((m) => m.id).join(", ")}`,
|
|
550
|
+
"(Auth travels in the native x-api-key header via ANTHROPIC_API_KEY.)",
|
|
551
|
+
"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.",
|
|
552
|
+
"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.",
|
|
553
|
+
"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:",
|
|
554
|
+
` 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"}]}'`
|
|
555
|
+
]
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/setup/adapters/copilotCli.ts
|
|
560
|
+
function buildPlan4(ctx) {
|
|
561
|
+
const firstModel = ctx.models[0]?.id ?? "<model>";
|
|
562
|
+
return {
|
|
563
|
+
fileWrites: [],
|
|
564
|
+
exports: [
|
|
565
|
+
{ name: ctx.keyEnvVar, value: "__KEY__" },
|
|
566
|
+
// OpenAI-compatible provider; Copilot CLI appends /chat/completions itself.
|
|
567
|
+
{ name: "COPILOT_PROVIDER_TYPE", value: "openai" },
|
|
568
|
+
{ name: "COPILOT_PROVIDER_BASE_URL", value: `${ctx.routingUrl}/v1` },
|
|
569
|
+
// Copilot sends this as Authorization: Bearer. The lr_ prefix tells the
|
|
570
|
+
// routing service it is a LunaRoute key (authenticated at the edge, never
|
|
571
|
+
// forwarded upstream), so the key stays out of the URL path.
|
|
572
|
+
{ name: "COPILOT_PROVIDER_API_KEY", value: `$${ctx.keyEnvVar}` },
|
|
573
|
+
{ name: "COPILOT_MODEL", value: firstModel }
|
|
574
|
+
],
|
|
575
|
+
notes: [
|
|
576
|
+
"\nGitHub Copilot CLI: add the exports above to your shell profile, then restart Copilot CLI.",
|
|
577
|
+
`Change COPILOT_MODEL to any of: ${ctx.models.map((m) => m.id).join(", ")}`,
|
|
578
|
+
"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).",
|
|
579
|
+
`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.`,
|
|
580
|
+
`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"}]}'`
|
|
581
|
+
]
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// src/setup/adapters/generic.ts
|
|
586
|
+
function buildPlan5(ctx) {
|
|
587
|
+
return {
|
|
588
|
+
fileWrites: [],
|
|
589
|
+
exports: [{ name: ctx.keyEnvVar, value: "__KEY__" }],
|
|
590
|
+
notes: [
|
|
591
|
+
"\nGeneric OpenAI-compatible setup:",
|
|
592
|
+
` Base URL: ${ctx.routingUrl}/v1`,
|
|
593
|
+
` Auth: header Authorization: Bearer $${ctx.keyEnvVar}`,
|
|
594
|
+
` Auth (alt): path ${ctx.routingUrl}/a/$${ctx.keyEnvVar}/v1`,
|
|
595
|
+
` Sample model id: ${ctx.models[0]?.id ?? "<model>"}`,
|
|
596
|
+
` All models: ${ctx.models.map((m) => m.id).join(", ")}`
|
|
597
|
+
]
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/commands/setup.ts
|
|
602
|
+
var ADAPTERS = {
|
|
603
|
+
opencode: buildPlan,
|
|
604
|
+
pi: buildPlan2,
|
|
605
|
+
"claude-code": buildPlan3,
|
|
606
|
+
"copilot-cli": buildPlan4,
|
|
607
|
+
generic: buildPlan5
|
|
608
|
+
};
|
|
609
|
+
var PI_INSTALL_PACKAGES = ["npm:@lunaroute/pi-extension", "npm:pi-mcp-adapter"];
|
|
610
|
+
async function runSetup(harness, opts, deps = {
|
|
611
|
+
confirm,
|
|
612
|
+
spawn: (command, args) => spawn(command, args, { stdio: "inherit" })
|
|
613
|
+
}) {
|
|
614
|
+
if (harness === "pi" && opts.extension && opts.models) {
|
|
615
|
+
console.error("Choose one of --extension or --models.");
|
|
616
|
+
return 1;
|
|
617
|
+
}
|
|
618
|
+
const adapter = ADAPTERS[harness];
|
|
619
|
+
if (!adapter) {
|
|
620
|
+
console.error(`Unknown harness "${harness}". Choose one of: ${Object.keys(ADAPTERS).join(", ")}.`);
|
|
621
|
+
return 1;
|
|
622
|
+
}
|
|
623
|
+
const creds = loadProfile(opts.profile);
|
|
624
|
+
if (!creds || !creds.routing_key) {
|
|
625
|
+
console.error('Not logged in. Run "lunaroute login" first.');
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
628
|
+
const routingUrl = opts.routingUrl || creds.routing_url;
|
|
629
|
+
if (!routingUrl) {
|
|
630
|
+
console.error("No routing URL in profile; pass --routing-url.");
|
|
631
|
+
return 1;
|
|
632
|
+
}
|
|
633
|
+
if (harness === "pi" && !opts.print) {
|
|
634
|
+
return runPiSetup(opts, deps);
|
|
635
|
+
}
|
|
636
|
+
let models;
|
|
637
|
+
try {
|
|
638
|
+
models = await fetchModels(routingUrl);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
|
|
641
|
+
return 1;
|
|
642
|
+
}
|
|
643
|
+
const ctx = {
|
|
644
|
+
routingUrl,
|
|
645
|
+
orgId: creds.org_id,
|
|
646
|
+
models,
|
|
647
|
+
keyEnvVar: "LUNAROUTE_API_KEY"
|
|
648
|
+
};
|
|
649
|
+
try {
|
|
650
|
+
const summary = await applyPlan(adapter(ctx), { print: opts.print, key: creds.routing_key });
|
|
651
|
+
if (!opts.print && summary.written.length > 0) {
|
|
652
|
+
console.log(`
|
|
653
|
+
Wrote: ${summary.written.join(", ")}`);
|
|
654
|
+
if (summary.backedUp.length > 0) console.log(` Backups: ${summary.backedUp.join(", ")}`);
|
|
655
|
+
}
|
|
656
|
+
return 0;
|
|
657
|
+
} catch (err) {
|
|
658
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
659
|
+
return 1;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async function runPiSetup(opts, deps) {
|
|
663
|
+
try {
|
|
664
|
+
if (opts.extension) {
|
|
665
|
+
const ok = await deps.confirm(
|
|
666
|
+
"Install the LunaRoute Pi extension + MCP adapter via 'pi install'?",
|
|
667
|
+
{ yes: opts.yes }
|
|
668
|
+
);
|
|
669
|
+
if (!ok) {
|
|
670
|
+
console.log("Skipped. Re-run with --yes, or install manually:");
|
|
671
|
+
console.log(` pi install ${PI_INSTALL_PACKAGES.join(" && pi install ")}`);
|
|
672
|
+
return 0;
|
|
673
|
+
}
|
|
674
|
+
return installPiExtension(deps.spawn);
|
|
675
|
+
}
|
|
676
|
+
if (opts.models) {
|
|
677
|
+
return applyPiModels(opts, await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", { yes: opts.yes }));
|
|
678
|
+
}
|
|
679
|
+
if (await deps.confirm(
|
|
680
|
+
"Install the LunaRoute Pi extension + MCP adapter via 'pi install' (recommended \u2014 auto-registers models)?",
|
|
681
|
+
{ yes: opts.yes }
|
|
682
|
+
)) {
|
|
683
|
+
return installPiExtension(deps.spawn);
|
|
684
|
+
}
|
|
685
|
+
return applyPiModels(
|
|
686
|
+
opts,
|
|
687
|
+
await deps.confirm("Merge LunaRoute provider into ~/.pi/agent/models.json (backup kept, other providers preserved)?", {
|
|
688
|
+
yes: opts.yes
|
|
689
|
+
})
|
|
690
|
+
);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
if (err instanceof NonInteractiveTerminalError) {
|
|
693
|
+
console.error(
|
|
694
|
+
"No interactive terminal available. Re-run with --yes to accept prompts, or --print to preview without writing."
|
|
695
|
+
);
|
|
696
|
+
return 1;
|
|
697
|
+
}
|
|
698
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async function installPiExtension(spawn3) {
|
|
703
|
+
for (const pkg of PI_INSTALL_PACKAGES) {
|
|
704
|
+
const code = await new Promise((resolve) => {
|
|
705
|
+
const child = spawn3("pi", ["install", pkg]);
|
|
706
|
+
child.on("error", (err) => {
|
|
707
|
+
const e = err;
|
|
708
|
+
if (e?.code === "ENOENT") {
|
|
709
|
+
console.error(`Error: "pi" not found on PATH. Install pi first: https://pi.dev (exit 127)`);
|
|
710
|
+
resolve(127);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
714
|
+
resolve(1);
|
|
715
|
+
});
|
|
716
|
+
child.on("exit", (code2) => {
|
|
717
|
+
resolve(typeof code2 === "number" ? code2 : 1);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
if (code !== 0) {
|
|
721
|
+
const done = PI_INSTALL_PACKAGES.slice(0, PI_INSTALL_PACKAGES.indexOf(pkg));
|
|
722
|
+
console.error(
|
|
723
|
+
`'pi install ${pkg}' failed (exit ${code}). Installed so far: ${done.length ? done.join(", ") : "nothing"}. Re-run 'lunaroute setup pi --extension' to retry.`
|
|
724
|
+
);
|
|
725
|
+
return code || 1;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
console.log("\nInstalled. Next steps inside pi:");
|
|
729
|
+
console.log(" 1. /login lunaroute \u2014 browser login issues and stores an lr_ key.");
|
|
730
|
+
console.log(" 2. /model \u2014 pick a lunaroute/* model (models auto-sync from /v1/models).");
|
|
731
|
+
return 0;
|
|
732
|
+
}
|
|
733
|
+
async function applyPiModels(opts, write) {
|
|
734
|
+
const creds = loadProfile(opts.profile);
|
|
735
|
+
let models;
|
|
736
|
+
try {
|
|
737
|
+
models = await fetchModels(opts.routingUrl || creds.routing_url || "");
|
|
738
|
+
} catch (err) {
|
|
739
|
+
console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
const plan = buildPlan2({
|
|
743
|
+
routingUrl: opts.routingUrl || creds.routing_url,
|
|
744
|
+
orgId: creds.org_id,
|
|
745
|
+
models,
|
|
746
|
+
keyEnvVar: "LUNAROUTE_API_KEY"
|
|
747
|
+
});
|
|
748
|
+
try {
|
|
749
|
+
const summary = await applyPlan(plan, { print: !write, key: creds.routing_key });
|
|
750
|
+
if (write && summary.written.length > 0) {
|
|
751
|
+
console.log(`
|
|
752
|
+
Wrote: ${summary.written.join(", ")}`);
|
|
753
|
+
if (summary.backedUp.length > 0) console.log(` Backups: ${summary.backedUp.join(", ")}`);
|
|
754
|
+
}
|
|
755
|
+
return 0;
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
758
|
+
return 1;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/commands/models.ts
|
|
763
|
+
async function runModels(profile, opts) {
|
|
764
|
+
const s = resolveSettings(profile);
|
|
765
|
+
if (!s.routing_key) {
|
|
766
|
+
console.error('Not logged in. Run "lunaroute login" first.');
|
|
767
|
+
return 1;
|
|
768
|
+
}
|
|
769
|
+
let ids;
|
|
770
|
+
try {
|
|
771
|
+
ids = await getModels({ apiUrl: s.api_url, apiKey: s.routing_key });
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
774
|
+
return 1;
|
|
775
|
+
}
|
|
776
|
+
if (opts.json) {
|
|
777
|
+
console.log(JSON.stringify(ids, null, 2));
|
|
778
|
+
} else {
|
|
779
|
+
for (const id of ids) console.log(id);
|
|
780
|
+
}
|
|
781
|
+
return 0;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/render.ts
|
|
785
|
+
function renderTable(headers, rows) {
|
|
786
|
+
const all = [headers, ...rows];
|
|
787
|
+
const widths = headers.map(
|
|
788
|
+
(_, col) => Math.max(...all.map((r) => (r[col] ?? "").length))
|
|
789
|
+
);
|
|
790
|
+
const fmt = (r) => r.map((cell, col) => col === r.length - 1 ? cell : (cell ?? "").padEnd(widths[col])).join(" ").replace(/\s+$/, "");
|
|
791
|
+
return [headers, ...rows].map(fmt).join("\n");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/commands/pricing.ts
|
|
795
|
+
async function runPricing(profile, opts) {
|
|
796
|
+
const s = resolveSettings(profile);
|
|
797
|
+
if (!s.routing_key) {
|
|
798
|
+
console.error('Not logged in. Run "lunaroute login" first.');
|
|
799
|
+
return 1;
|
|
800
|
+
}
|
|
801
|
+
let rows;
|
|
802
|
+
try {
|
|
803
|
+
rows = await getPricing({ apiUrl: s.api_url, apiKey: s.routing_key }, opts.model);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
806
|
+
return 1;
|
|
807
|
+
}
|
|
808
|
+
if (opts.json) {
|
|
809
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
812
|
+
const cell = (n) => n === void 0 ? "\u2014" : String(n);
|
|
813
|
+
const table = renderTable(
|
|
814
|
+
["MODEL", "INPUT (cr/M)", "OUTPUT (cr/M)", "CACHED (cr/M)"],
|
|
815
|
+
rows.map(
|
|
816
|
+
(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"]
|
|
817
|
+
)
|
|
818
|
+
);
|
|
819
|
+
console.log(table);
|
|
820
|
+
return 0;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/commands/usage.ts
|
|
824
|
+
var tok = (n) => n === null || n === void 0 ? "0" : String(n);
|
|
825
|
+
async function runUsage(profile, opts) {
|
|
826
|
+
const s = resolveSettings(profile);
|
|
827
|
+
if (!s.routing_key) {
|
|
828
|
+
console.error('Not logged in. Run "lunaroute login" first.');
|
|
829
|
+
return 1;
|
|
830
|
+
}
|
|
831
|
+
let usage;
|
|
832
|
+
try {
|
|
833
|
+
usage = await getUsage({ apiUrl: s.api_url, apiKey: s.routing_key }, opts.limit);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
836
|
+
return 1;
|
|
837
|
+
}
|
|
838
|
+
if (opts.json) {
|
|
839
|
+
console.log(JSON.stringify(usage, null, 2));
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
const w = usage.wallet;
|
|
843
|
+
console.log(
|
|
844
|
+
`Wallet: available ${w.available_credits} (balance ${w.balance_credits}, reserved ${w.reserved_credits})`
|
|
845
|
+
);
|
|
846
|
+
console.log("");
|
|
847
|
+
const table = renderTable(
|
|
848
|
+
["TIME", "MODEL", "IN", "OUT", "CACHED", "\u0394CREDITS", "BALANCE"],
|
|
849
|
+
usage.entries.map((e) => [
|
|
850
|
+
e.created_at,
|
|
851
|
+
e.model || "\u2014",
|
|
852
|
+
tok(e.input_tokens),
|
|
853
|
+
tok(e.output_tokens),
|
|
854
|
+
tok(e.cached_input_tokens),
|
|
855
|
+
String(e.credit_delta),
|
|
856
|
+
String(e.balance_after_credits)
|
|
857
|
+
])
|
|
858
|
+
);
|
|
859
|
+
console.log(table);
|
|
860
|
+
return 0;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/projectId.ts
|
|
864
|
+
import { execSync as execSync2 } from "child_process";
|
|
865
|
+
function normalizeRemote(url) {
|
|
866
|
+
let s = url.trim();
|
|
867
|
+
if (s.includes("://")) {
|
|
868
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
869
|
+
s = s.replace(/^[^/@]+@/, "");
|
|
870
|
+
s = s.replace(/^([^/:]+):\d+/, "$1");
|
|
871
|
+
} else if (/^[^/]+:/.test(s)) {
|
|
872
|
+
s = s.replace(/^[^@/]+@/, "");
|
|
873
|
+
s = s.replace(":", "/");
|
|
874
|
+
}
|
|
875
|
+
s = s.replace(/\.git$/, "");
|
|
876
|
+
s = s.toLowerCase();
|
|
877
|
+
if (s.length > 128) s = s.slice(0, 128);
|
|
878
|
+
return s;
|
|
879
|
+
}
|
|
880
|
+
function resolveProjectId() {
|
|
881
|
+
const env = process.env.LUNAROUTE_PROJECT_ID?.trim();
|
|
882
|
+
if (env) return env;
|
|
883
|
+
try {
|
|
884
|
+
const url = execSync2("git config --get remote.origin.url", {
|
|
885
|
+
cwd: process.cwd(),
|
|
886
|
+
encoding: "utf8",
|
|
887
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
888
|
+
}).trim();
|
|
889
|
+
if (!url) return null;
|
|
890
|
+
return normalizeRemote(url);
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/memoryClient.ts
|
|
897
|
+
async function memoryPost(ctx, path, body) {
|
|
898
|
+
const res = await fetch(`${ctx.routingUrl}${path}`, {
|
|
899
|
+
method: "POST",
|
|
900
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.routingKey}` },
|
|
901
|
+
body: JSON.stringify(body)
|
|
902
|
+
});
|
|
903
|
+
if (!res.ok) {
|
|
904
|
+
let detail = `HTTP ${res.status}`;
|
|
905
|
+
try {
|
|
906
|
+
const b = await res.json();
|
|
907
|
+
const code = b?.error?.code;
|
|
908
|
+
const message = b?.error?.message;
|
|
909
|
+
if (code && message) detail = `${code}: ${message}`;
|
|
910
|
+
else if (message) detail = message;
|
|
911
|
+
else if (code) detail = code;
|
|
912
|
+
} catch {
|
|
913
|
+
}
|
|
914
|
+
if (res.status === 404 && path.endsWith("/read")) detail = "exchange not found";
|
|
915
|
+
throw new Error(`memory request failed: ${detail}`);
|
|
916
|
+
}
|
|
917
|
+
return await res.json();
|
|
918
|
+
}
|
|
919
|
+
async function searchMemory(ctx, p) {
|
|
920
|
+
const body = { project_id: p.projectId };
|
|
921
|
+
if (p.query !== void 0) body.query = p.query;
|
|
922
|
+
if (p.concepts !== void 0) body.concepts = p.concepts;
|
|
923
|
+
if (p.mode !== void 0) body.mode = p.mode;
|
|
924
|
+
if (p.limit !== void 0) body.limit = p.limit;
|
|
925
|
+
if (p.after !== void 0) body.after = p.after;
|
|
926
|
+
if (p.before !== void 0) body.before = p.before;
|
|
927
|
+
return memoryPost(ctx, "/v1/memory/search", body);
|
|
928
|
+
}
|
|
929
|
+
async function readMemory(ctx, p) {
|
|
930
|
+
const body = { project_id: p.projectId, id: p.id };
|
|
931
|
+
if (p.startLine !== void 0) body.start_line = p.startLine;
|
|
932
|
+
if (p.endLine !== void 0) body.end_line = p.endLine;
|
|
933
|
+
return memoryPost(ctx, "/v1/memory/read", body);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/commands/memory.ts
|
|
937
|
+
var NOT_LOGGED_IN = 'Not logged in. Run "lunaroute login" first.';
|
|
938
|
+
var NO_PROJECT = "No project context: run inside a git repo with an origin remote, or set LUNAROUTE_PROJECT_ID.";
|
|
939
|
+
function contextFor(profile) {
|
|
940
|
+
const s = resolveSettings(profile);
|
|
941
|
+
if (!s.routing_key) {
|
|
942
|
+
console.error(NOT_LOGGED_IN);
|
|
943
|
+
return 1;
|
|
944
|
+
}
|
|
945
|
+
const projectId = resolveProjectId();
|
|
946
|
+
if (!projectId) {
|
|
947
|
+
console.error(NO_PROJECT);
|
|
948
|
+
return 1;
|
|
949
|
+
}
|
|
950
|
+
return { ctx: { routingUrl: s.routing_url, routingKey: s.routing_key }, projectId };
|
|
951
|
+
}
|
|
952
|
+
async function runMemorySearch(profile, opts) {
|
|
953
|
+
const c = contextFor(profile);
|
|
954
|
+
if (typeof c === "number") return c;
|
|
955
|
+
const concepts = opts.concepts ? opts.concepts.split(",").map((x) => x.trim()).filter(Boolean) : void 0;
|
|
956
|
+
const hasQuery = !!opts.query && opts.query.trim() !== "";
|
|
957
|
+
const hasConcepts = !!concepts && concepts.length > 0;
|
|
958
|
+
if (hasQuery === hasConcepts) {
|
|
959
|
+
console.error("Provide exactly one of a query argument or --concepts.");
|
|
960
|
+
return 1;
|
|
961
|
+
}
|
|
962
|
+
let result;
|
|
963
|
+
try {
|
|
964
|
+
result = await searchMemory(c.ctx, {
|
|
965
|
+
projectId: c.projectId,
|
|
966
|
+
query: hasQuery ? opts.query : void 0,
|
|
967
|
+
concepts: hasConcepts ? concepts : void 0,
|
|
968
|
+
mode: opts.mode,
|
|
969
|
+
limit: opts.limit,
|
|
970
|
+
after: opts.after,
|
|
971
|
+
before: opts.before
|
|
972
|
+
});
|
|
973
|
+
} catch (err) {
|
|
974
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
975
|
+
return 1;
|
|
976
|
+
}
|
|
977
|
+
if (opts.json) {
|
|
978
|
+
console.log(JSON.stringify(result, null, 2));
|
|
979
|
+
return 0;
|
|
980
|
+
}
|
|
981
|
+
if (result.results.length === 0) {
|
|
982
|
+
console.log("No results.");
|
|
983
|
+
return 0;
|
|
984
|
+
}
|
|
985
|
+
for (const h of result.results) {
|
|
986
|
+
const snippet = h.user_turn.replace(/\s+/g, " ").slice(0, 100);
|
|
987
|
+
console.log(`${h.exchange_id} score=${h.score.toFixed(3)} ${h.timestamp} ${h.model}`);
|
|
988
|
+
console.log(` ${snippet}`);
|
|
989
|
+
}
|
|
990
|
+
return 0;
|
|
991
|
+
}
|
|
992
|
+
async function runMemoryRead(profile, id, opts) {
|
|
993
|
+
const c = contextFor(profile);
|
|
994
|
+
if (typeof c === "number") return c;
|
|
995
|
+
let result;
|
|
996
|
+
try {
|
|
997
|
+
result = await readMemory(c.ctx, { projectId: c.projectId, id, startLine: opts.start, endLine: opts.end });
|
|
998
|
+
} catch (err) {
|
|
999
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1000
|
+
return 1;
|
|
1001
|
+
}
|
|
1002
|
+
if (opts.json) {
|
|
1003
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1004
|
+
return 0;
|
|
1005
|
+
}
|
|
1006
|
+
console.log(result.text);
|
|
1007
|
+
return 0;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/mcp.ts
|
|
1011
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1012
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1013
|
+
import { z } from "zod";
|
|
1014
|
+
var NO_PROJECT2 = "No project context: run the MCP server inside a git repo with an origin remote, or set LUNAROUTE_PROJECT_ID.";
|
|
1015
|
+
function toolError(text) {
|
|
1016
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
1017
|
+
}
|
|
1018
|
+
function toolOk(data) {
|
|
1019
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
1020
|
+
}
|
|
1021
|
+
async function handleSearch(deps, args) {
|
|
1022
|
+
const projectId = deps.resolveProjectId();
|
|
1023
|
+
if (!projectId) return toolError(NO_PROJECT2);
|
|
1024
|
+
const concepts = args.concepts?.map((c) => c.trim()).filter(Boolean);
|
|
1025
|
+
const hasQuery = !!args.query && args.query.trim() !== "";
|
|
1026
|
+
const hasConcepts = !!concepts && concepts.length > 0;
|
|
1027
|
+
if (hasQuery === hasConcepts) return toolError("Provide exactly one of query or concepts.");
|
|
1028
|
+
try {
|
|
1029
|
+
const data = await deps.search({
|
|
1030
|
+
projectId,
|
|
1031
|
+
query: hasQuery ? args.query : void 0,
|
|
1032
|
+
concepts: hasConcepts ? concepts : void 0,
|
|
1033
|
+
mode: args.mode,
|
|
1034
|
+
limit: args.limit,
|
|
1035
|
+
after: args.after,
|
|
1036
|
+
before: args.before
|
|
1037
|
+
});
|
|
1038
|
+
return toolOk(data);
|
|
1039
|
+
} catch (err) {
|
|
1040
|
+
return toolError(err instanceof Error ? err.message : String(err));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
async function handleRead(deps, args) {
|
|
1044
|
+
const projectId = deps.resolveProjectId();
|
|
1045
|
+
if (!projectId) return toolError(NO_PROJECT2);
|
|
1046
|
+
try {
|
|
1047
|
+
const data = await deps.read({ projectId, id: args.id, startLine: args.startLine, endLine: args.endLine });
|
|
1048
|
+
return toolOk(data);
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
return toolError(err instanceof Error ? err.message : String(err));
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function runMcp(profile) {
|
|
1054
|
+
const s = resolveSettings(profile);
|
|
1055
|
+
if (!s.routing_key) {
|
|
1056
|
+
console.error('Not logged in. Run "lunaroute login" first.');
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
1059
|
+
const ctx = { routingUrl: s.routing_url, routingKey: s.routing_key };
|
|
1060
|
+
const deps = {
|
|
1061
|
+
search: (p) => searchMemory(ctx, p),
|
|
1062
|
+
read: (p) => readMemory(ctx, p),
|
|
1063
|
+
resolveProjectId
|
|
1064
|
+
};
|
|
1065
|
+
const server = new McpServer({ name: "lunaroute-memory", version: "0.1.0" });
|
|
1066
|
+
server.registerTool(
|
|
1067
|
+
"search",
|
|
1068
|
+
{
|
|
1069
|
+
title: "Search LunaRoute memory",
|
|
1070
|
+
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).",
|
|
1071
|
+
inputSchema: {
|
|
1072
|
+
query: z.string().optional(),
|
|
1073
|
+
concepts: z.array(z.string()).optional(),
|
|
1074
|
+
mode: z.enum(["vector", "text", "both"]).optional(),
|
|
1075
|
+
limit: z.number().int().positive().optional(),
|
|
1076
|
+
after: z.string().optional(),
|
|
1077
|
+
before: z.string().optional()
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
async (args) => handleSearch(deps, args)
|
|
1081
|
+
);
|
|
1082
|
+
server.registerTool(
|
|
1083
|
+
"read",
|
|
1084
|
+
{
|
|
1085
|
+
title: "Read a LunaRoute memory exchange",
|
|
1086
|
+
description: "Read a full past conversation by id, with optional line pagination.",
|
|
1087
|
+
inputSchema: {
|
|
1088
|
+
id: z.string(),
|
|
1089
|
+
startLine: z.number().int().optional(),
|
|
1090
|
+
endLine: z.number().int().optional()
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
async (args) => handleRead(deps, args)
|
|
1094
|
+
);
|
|
1095
|
+
const transport = new StdioServerTransport();
|
|
1096
|
+
await server.connect(transport);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// src/skill/content.ts
|
|
1100
|
+
var SKILL_MD = `---
|
|
1101
|
+
name: lunaroute-memory
|
|
1102
|
+
description: >-
|
|
1103
|
+
Search and read this project's cross-session LunaRoute memory. Use BEFORE
|
|
1104
|
+
starting a task to recover prior decisions, solutions, and gotchas from past
|
|
1105
|
+
conversations, and whenever you need context that isn't in the current files.
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1108
|
+
# LunaRoute Memory
|
|
1109
|
+
|
|
1110
|
+
This project has cross-session memory captured by LunaRoute from past coding
|
|
1111
|
+
sessions. Two tools are available from the \`lunaroute-memory\` MCP server:
|
|
1112
|
+
|
|
1113
|
+
- **search** \u2014 find relevant past exchanges for this project. Pass a natural
|
|
1114
|
+
\`query\`, or \`concepts\` (AND-matched) for precise lookups. Returns ranked
|
|
1115
|
+
hits, each with an \`exchange_id\`, a snippet, the model, and a timestamp.
|
|
1116
|
+
- **read** \u2014 fetch the full text of one exchange by \`id\` (optionally a line
|
|
1117
|
+
range) once a search hit looks relevant.
|
|
1118
|
+
|
|
1119
|
+
## When to use
|
|
1120
|
+
|
|
1121
|
+
- **Before starting any non-trivial task**, search for prior work on the same
|
|
1122
|
+
area to avoid re-deriving decisions or repeating past mistakes.
|
|
1123
|
+
- When you hit an unfamiliar pattern, error, or design choice, search for it.
|
|
1124
|
+
- After a promising search hit, **read** the full exchange for the detail and
|
|
1125
|
+
rationale the snippet omits.
|
|
1126
|
+
|
|
1127
|
+
Memory is scoped to the current project automatically. An empty search result
|
|
1128
|
+
is not an error \u2014 just proceed normally.
|
|
1129
|
+
`;
|
|
1130
|
+
|
|
1131
|
+
// src/skill/plan.ts
|
|
1132
|
+
function buildSkillPlan(opts) {
|
|
1133
|
+
const args = ["-y", "@lunaroute/cli", "mcp"];
|
|
1134
|
+
if (opts.profile !== "default") args.push("--profile", opts.profile);
|
|
1135
|
+
const entry = { type: "stdio", command: "npx", args };
|
|
1136
|
+
const mergeMcp = (existing) => {
|
|
1137
|
+
const obj = existing ?? {};
|
|
1138
|
+
const servers = typeof obj.mcpServers === "object" && obj.mcpServers !== null ? obj.mcpServers : {};
|
|
1139
|
+
return { ...obj, mcpServers: { ...servers, [LUNAROUTE_SKILL_NAME]: entry } };
|
|
1140
|
+
};
|
|
1141
|
+
const skillPath = opts.project ? claudeProjectSkillPath(opts.root) : claudeUserSkillPath();
|
|
1142
|
+
const mcpPath = opts.project ? claudeProjectMcpPath(opts.root) : claudeUserConfigPath();
|
|
1143
|
+
return {
|
|
1144
|
+
fileWrites: [
|
|
1145
|
+
{ kind: "text", path: skillPath, content: SKILL_MD, fileMode: 420, dirMode: 493 },
|
|
1146
|
+
{ kind: "json", path: mcpPath, merge: mergeMcp, fileMode: 384 }
|
|
1147
|
+
],
|
|
1148
|
+
exports: [],
|
|
1149
|
+
notes: [
|
|
1150
|
+
"\nLunaRoute memory installed for Claude Code. Restart Claude Code to pick it up.",
|
|
1151
|
+
'If you have not yet, run "lunaroute login" \u2014 the memory server authenticates with your stored key at runtime.',
|
|
1152
|
+
'Verify by asking Claude to "search project memory", or run /lunaroute-memory.'
|
|
1153
|
+
]
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// src/commands/skillInstall.ts
|
|
1158
|
+
async function runSkillInstall(profile, opts) {
|
|
1159
|
+
const s = resolveSettings(profile);
|
|
1160
|
+
if (!s.routing_key) {
|
|
1161
|
+
console.error(
|
|
1162
|
+
'Warning: not logged in \u2014 the memory server will not authenticate until you run "lunaroute login".'
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
let root;
|
|
1166
|
+
if (opts.project) {
|
|
1167
|
+
const r = gitRepoRoot();
|
|
1168
|
+
if (!r) {
|
|
1169
|
+
console.error(
|
|
1170
|
+
"--project requires a git repository (no work tree found). Omit --project to install at the user level."
|
|
1171
|
+
);
|
|
1172
|
+
return 1;
|
|
1173
|
+
}
|
|
1174
|
+
root = r;
|
|
1175
|
+
}
|
|
1176
|
+
const plan = buildSkillPlan({ project: opts.project, profile, root });
|
|
1177
|
+
try {
|
|
1178
|
+
const summary = await applyPlan(plan, { print: opts.print, key: "" });
|
|
1179
|
+
if (!opts.print) {
|
|
1180
|
+
for (const f of summary.written) console.log(`wrote ${f}`);
|
|
1181
|
+
for (const b of summary.backedUp) console.log(`backed up ${b}`);
|
|
1182
|
+
}
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1185
|
+
return 1;
|
|
1186
|
+
}
|
|
1187
|
+
return 0;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// src/commands/run.ts
|
|
1191
|
+
import { spawn as spawn2 } from "child_process";
|
|
1192
|
+
|
|
1193
|
+
// src/run/adapters/claudeCode.ts
|
|
1194
|
+
function buildRunSpec(ctx) {
|
|
1195
|
+
return {
|
|
1196
|
+
command: "claude",
|
|
1197
|
+
args: [],
|
|
1198
|
+
env: {
|
|
1199
|
+
ANTHROPIC_BASE_URL: ctx.routingUrl,
|
|
1200
|
+
ANTHROPIC_API_KEY: ctx.apiKey,
|
|
1201
|
+
ANTHROPIC_MODEL: ctx.model,
|
|
1202
|
+
// Pins the Haiku/background model so Claude Code's background tasks
|
|
1203
|
+
// (titles, summaries, compaction) route through LunaRoute instead of
|
|
1204
|
+
// 404'ing against Anthropic's hardcoded Haiku id. Mirrors the Connect tab
|
|
1205
|
+
// (kata f59a). Same default as ANTHROPIC_MODEL — catalog has no
|
|
1206
|
+
// size/tier field, so users tune it; the point is a valid LunaRoute model.
|
|
1207
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: ctx.model,
|
|
1208
|
+
// Populates the /model picker from GET /v1/models at startup. LunaRoute
|
|
1209
|
+
// already serves the Anthropic-native shape (spike-verified, kata nkey).
|
|
1210
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// src/run/adapters/codex.ts
|
|
1216
|
+
function buildRunSpec2(ctx) {
|
|
1217
|
+
return {
|
|
1218
|
+
command: "codex",
|
|
1219
|
+
args: ["--model", ctx.model],
|
|
1220
|
+
env: {
|
|
1221
|
+
OPENAI_BASE_URL: `${ctx.routingUrl}/v1`,
|
|
1222
|
+
OPENAI_API_KEY: ctx.apiKey
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/commands/run.ts
|
|
1228
|
+
var ADAPTERS2 = {
|
|
1229
|
+
claude: buildRunSpec,
|
|
1230
|
+
"claude-code": buildRunSpec,
|
|
1231
|
+
codex: buildRunSpec2
|
|
1232
|
+
};
|
|
1233
|
+
var INSTALL_HINT = {
|
|
1234
|
+
claude: "Install Claude Code: https://docs.anthropic.com/claude-code/install",
|
|
1235
|
+
"claude-code": "Install Claude Code: https://docs.anthropic.com/claude-code/install",
|
|
1236
|
+
codex: "Install Codex: https://github.com/openai/codex#install"
|
|
1237
|
+
};
|
|
1238
|
+
var realDeps = {
|
|
1239
|
+
spawn: (command, args, env) => spawn2(command, args, { env, stdio: "inherit" }),
|
|
1240
|
+
fetchModels
|
|
1241
|
+
};
|
|
1242
|
+
async function runRun(harness, opts, deps = realDeps) {
|
|
1243
|
+
const adapter = ADAPTERS2[harness];
|
|
1244
|
+
if (!adapter) {
|
|
1245
|
+
console.error(`Unknown harness "${harness}". Choose one of: ${Object.keys(ADAPTERS2).join(", ")}.`);
|
|
1246
|
+
return 1;
|
|
1247
|
+
}
|
|
1248
|
+
const creds = loadProfile(opts.profile);
|
|
1249
|
+
if (!creds || !creds.routing_key) {
|
|
1250
|
+
console.error('Not logged in. Run "lunaroute login" first.');
|
|
1251
|
+
return 1;
|
|
1252
|
+
}
|
|
1253
|
+
let model = opts.model;
|
|
1254
|
+
if (!model) {
|
|
1255
|
+
let models;
|
|
1256
|
+
try {
|
|
1257
|
+
models = await deps.fetchModels(creds.routing_url);
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
console.error(`Could not fetch the model catalog: ${err instanceof Error ? err.message : err}`);
|
|
1260
|
+
return 1;
|
|
1261
|
+
}
|
|
1262
|
+
model = models[0]?.id;
|
|
1263
|
+
if (!model) {
|
|
1264
|
+
console.error("No models available from the catalog. Pass --model to pick one.");
|
|
1265
|
+
return 1;
|
|
1266
|
+
}
|
|
1267
|
+
console.error(`# using model ${model} (pass --model to change)`);
|
|
1268
|
+
}
|
|
1269
|
+
const spec = adapter({
|
|
1270
|
+
routingUrl: creds.routing_url,
|
|
1271
|
+
apiKey: creds.routing_key,
|
|
1272
|
+
model
|
|
1273
|
+
});
|
|
1274
|
+
const args = spec.args.concat(opts.passthrough ?? []);
|
|
1275
|
+
const env = { ...process.env, ...spec.env };
|
|
1276
|
+
const child = deps.spawn(spec.command, args, env);
|
|
1277
|
+
return new Promise((resolve) => {
|
|
1278
|
+
child.on("error", (err) => {
|
|
1279
|
+
const e = err;
|
|
1280
|
+
if (e?.code === "ENOENT") {
|
|
1281
|
+
console.error(
|
|
1282
|
+
`Error: "${spec.command}" not found on PATH. ${INSTALL_HINT[harness]} (exit 127)`
|
|
1283
|
+
);
|
|
1284
|
+
resolve(127);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1288
|
+
resolve(1);
|
|
1289
|
+
});
|
|
1290
|
+
child.on("exit", (code) => {
|
|
1291
|
+
resolve(typeof code === "number" ? code : 1);
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/index.ts
|
|
1297
|
+
var program = new Command();
|
|
1298
|
+
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");
|
|
1299
|
+
program.command("login").description("Authorize this device via the browser and store a routing key.").action(async () => {
|
|
1300
|
+
await login(program.opts().profile);
|
|
1301
|
+
});
|
|
1302
|
+
program.command("whoami").description("Show the signed-in user and organization.").action(() => {
|
|
1303
|
+
whoami(program.opts().profile);
|
|
1304
|
+
});
|
|
1305
|
+
program.command("logout").description("Remove stored credentials for the active profile.").action(() => {
|
|
1306
|
+
logout(program.opts().profile);
|
|
1307
|
+
});
|
|
1308
|
+
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) => {
|
|
1309
|
+
const code = await runSetup(harness, {
|
|
1310
|
+
profile: program.opts().profile,
|
|
1311
|
+
print: opts.print,
|
|
1312
|
+
routingUrl: opts.routingUrl,
|
|
1313
|
+
yes: opts.yes,
|
|
1314
|
+
extension: opts.extension,
|
|
1315
|
+
models: opts.models
|
|
1316
|
+
});
|
|
1317
|
+
if (code !== 0) process.exit(code);
|
|
1318
|
+
});
|
|
1319
|
+
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) => {
|
|
1320
|
+
const raw = process.argv;
|
|
1321
|
+
const ddIdx = raw.indexOf("--");
|
|
1322
|
+
const runIdx = raw.indexOf("run");
|
|
1323
|
+
const passthrough = ddIdx > runIdx && ddIdx !== -1 ? raw.slice(ddIdx + 1) : [];
|
|
1324
|
+
const code = await runRun(
|
|
1325
|
+
harness,
|
|
1326
|
+
{ profile: program.opts().profile, model: opts.model, passthrough }
|
|
1327
|
+
);
|
|
1328
|
+
if (code !== 0) process.exit(code);
|
|
1329
|
+
});
|
|
1330
|
+
program.command("models").description("List available LunaRoute models.").option("--json", "output machine-readable JSON", false).action(async (opts) => {
|
|
1331
|
+
const code = await runModels(program.opts().profile, { json: opts.json });
|
|
1332
|
+
if (code !== 0) process.exit(code);
|
|
1333
|
+
});
|
|
1334
|
+
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) => {
|
|
1335
|
+
const code = await runPricing(program.opts().profile, { json: opts.json, model: opts.model });
|
|
1336
|
+
if (code !== 0) process.exit(code);
|
|
1337
|
+
});
|
|
1338
|
+
program.command("usage").description("Show wallet balance and recent usage.").option("--limit <n>", "number of ledger entries (default 20)", (v) => {
|
|
1339
|
+
const n = parseInt(v, 10);
|
|
1340
|
+
if (Number.isNaN(n) || n < 1) {
|
|
1341
|
+
throw new InvalidArgumentError("--limit must be a positive integer");
|
|
1342
|
+
}
|
|
1343
|
+
return n;
|
|
1344
|
+
}).option("--json", "output machine-readable JSON", false).action(async (opts) => {
|
|
1345
|
+
const code = await runUsage(program.opts().profile, { json: opts.json, limit: opts.limit });
|
|
1346
|
+
if (code !== 0) process.exit(code);
|
|
1347
|
+
});
|
|
1348
|
+
program.command("mcp").description("Run a stdio MCP server exposing LunaRoute memory search/read tools.").action(async () => {
|
|
1349
|
+
await runMcp(program.opts().profile);
|
|
1350
|
+
});
|
|
1351
|
+
var memory = program.command("memory").description("Query LunaRoute memory for the current project.");
|
|
1352
|
+
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) => {
|
|
1353
|
+
const n = parseInt(v, 10);
|
|
1354
|
+
if (Number.isNaN(n) || n < 1) throw new InvalidArgumentError("--limit must be a positive integer");
|
|
1355
|
+
return n;
|
|
1356
|
+
}).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) => {
|
|
1357
|
+
const code = await runMemorySearch(program.opts().profile, { query, ...opts });
|
|
1358
|
+
if (code !== 0) process.exit(code);
|
|
1359
|
+
});
|
|
1360
|
+
memory.command("read <id>").description("Read a full past conversation by id.").option("--start <n>", "start line (1-based)", (v) => {
|
|
1361
|
+
const n = parseInt(v, 10);
|
|
1362
|
+
if (Number.isNaN(n) || n < 1) throw new InvalidArgumentError("--start must be a positive integer");
|
|
1363
|
+
return n;
|
|
1364
|
+
}).option("--end <n>", "end line (1-based)", (v) => {
|
|
1365
|
+
const n = parseInt(v, 10);
|
|
1366
|
+
if (Number.isNaN(n) || n < 1) throw new InvalidArgumentError("--end must be a positive integer");
|
|
1367
|
+
return n;
|
|
1368
|
+
}).option("--json", "output machine-readable JSON", false).action(async (id, opts) => {
|
|
1369
|
+
const code = await runMemoryRead(program.opts().profile, id, opts);
|
|
1370
|
+
if (code !== 0) process.exit(code);
|
|
1371
|
+
});
|
|
1372
|
+
var skill = program.command("skill").description("Install LunaRoute memory into a coding harness.");
|
|
1373
|
+
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) => {
|
|
1374
|
+
const code = await runSkillInstall(program.opts().profile, opts);
|
|
1375
|
+
if (code !== 0) process.exit(code);
|
|
1376
|
+
});
|
|
1377
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
1378
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1379
|
+
process.exit(1);
|
|
1380
|
+
});
|