routstrd 0.4.4 → 0.4.6

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/src/cli.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  type RoutstrdConfig,
29
29
  } from "./utils/config";
30
30
  import { COCO_LOGS_DIR, logger } from "./utils/logger";
31
- import { setupIntegration, runIntegrationsForClients } from "./integrations";
31
+ import { setupIntegration, runIntegrationsForClients, type IntegrationKey } from "./integrations";
32
32
  import {
33
33
  assertLegacyCocodNotRunning,
34
34
  claimLegacyCocodPidFile,
@@ -215,7 +215,7 @@ async function requireLocalDaemon(): Promise<void> {
215
215
  }
216
216
  }
217
217
 
218
- async function initDaemon(): Promise<void> {
218
+ async function initDaemon(integrationKey?: IntegrationKey): Promise<void> {
219
219
  console.log("Initializing routstrd...");
220
220
 
221
221
  // Create config directory (0700, correcting existing installs too)
@@ -273,7 +273,7 @@ async function initDaemon(): Promise<void> {
273
273
 
274
274
  await startDaemon({ port: String(config.port || 8008), host: config.host || undefined });
275
275
 
276
- await setupIntegration(config);
276
+ await setupIntegration(config, integrationKey);
277
277
 
278
278
  console.log("\nInitialization complete!");
279
279
  console.log(
@@ -574,20 +574,68 @@ program
574
574
  .description(
575
575
  "Initialize routstrd (creates config directory and initializes wallet)",
576
576
  )
577
- .action(async () => {
578
- await requireLocalDaemon();
579
- try {
580
- await initDaemon();
581
- } catch (error) {
582
- // An expected, user-actionable refusal — print the structured message
583
- // without Bun's unhandled-rejection source snippet and stack trace.
584
- if (error instanceof WalletMigrationConflictError) {
585
- console.error(error.message);
577
+ .option("--opencode", "Set up OpenCode integration (non-interactive)")
578
+ .option("--openclaw", "Set up OpenClaw integration (non-interactive)")
579
+ .option("--pi-agent", "Set up Pi Agent integration (non-interactive)")
580
+ .option("--claude-code", "Set up Claude Code integration (non-interactive)")
581
+ .option("--hermes", "Set up Hermes integration (non-interactive)")
582
+ .option("--skip-integration", "Skip integration setup")
583
+ .action(
584
+ async (options: {
585
+ opencode?: boolean;
586
+ openclaw?: boolean;
587
+ piAgent?: boolean;
588
+ claudeCode?: boolean;
589
+ hermes?: boolean;
590
+ skipIntegration?: boolean;
591
+ }) => {
592
+ await requireLocalDaemon();
593
+
594
+ const integrationFlags: Record<string, boolean | undefined> = {
595
+ opencode: options.opencode,
596
+ openclaw: options.openclaw,
597
+ "pi-agent": options.piAgent,
598
+ "claude-code": options.claudeCode,
599
+ hermes: options.hermes,
600
+ };
601
+ const selectedIntegrations = Object.keys(integrationFlags).filter(
602
+ (key) => integrationFlags[key],
603
+ );
604
+
605
+ if (selectedIntegrations.length > 1) {
606
+ console.error(
607
+ "Error: use only one integration flag, or use 'routstrd clients add' for multiple clients.",
608
+ );
586
609
  process.exit(1);
587
610
  }
588
- throw error;
589
- }
590
- });
611
+
612
+ if (options.skipIntegration && selectedIntegrations.length > 0) {
613
+ console.error(
614
+ "Error: --skip-integration cannot be combined with an integration flag.",
615
+ );
616
+ process.exit(1);
617
+ }
618
+
619
+ let integrationKey: IntegrationKey | undefined;
620
+ if (options.skipIntegration) {
621
+ integrationKey = "skip";
622
+ } else if (selectedIntegrations.length === 1) {
623
+ integrationKey = selectedIntegrations[0];
624
+ }
625
+
626
+ try {
627
+ await initDaemon(integrationKey);
628
+ } catch (error) {
629
+ // An expected, user-actionable refusal — print the structured message
630
+ // without Bun's unhandled-rejection source snippet and stack trace.
631
+ if (error instanceof WalletMigrationConflictError) {
632
+ console.error(error.message);
633
+ process.exit(1);
634
+ }
635
+ throw error;
636
+ }
637
+ },
638
+ );
591
639
 
592
640
  // Start - start the background daemon
593
641
  program
@@ -2268,11 +2316,15 @@ function getLogFileForDate(logsDir: string, date: Date = new Date()): string {
2268
2316
  return `${logsDir}/${year}-${month}-${day}.log`;
2269
2317
  }
2270
2318
 
2271
- function readLastLines(file: string, lines: number): string {
2319
+ function readAllLines(file: string): string[] {
2272
2320
  const content = readFileSync(file, "utf8");
2273
2321
  const allLines = content.replace(/\r\n/g, "\n").split("\n");
2274
2322
  if (allLines.at(-1) === "") allLines.pop();
2275
- return allLines.slice(-lines).join("\n");
2323
+ return allLines;
2324
+ }
2325
+
2326
+ function readLastLines(file: string, lines: number): string {
2327
+ return readAllLines(file).slice(-lines).join("\n");
2276
2328
  }
2277
2329
 
2278
2330
  async function followLogFile(file: string, lines: number): Promise<void> {
@@ -2302,51 +2354,239 @@ async function followLogFile(file: string, lines: number): Promise<void> {
2302
2354
  }
2303
2355
  }
2304
2356
 
2357
+ type StructuredLogLine = {
2358
+ timestamp?: string;
2359
+ level?: string;
2360
+ requestId?: string;
2361
+ modelId?: string;
2362
+ isRouting: boolean;
2363
+ };
2364
+
2365
+ export function parseStructuredLogLine(line: string): StructuredLogLine {
2366
+ const out: StructuredLogLine = { isRouting: false };
2367
+
2368
+ const head = line.match(/^\[([^\]]+)\]\s+\[([^\]]+)\]/);
2369
+ if (head) {
2370
+ out.timestamp = head[1];
2371
+ out.level = head[2];
2372
+ }
2373
+
2374
+ // Request ids are hex (no colons). Stop at the first ':' so legacy
2375
+ // colon-joined child tags like `[req:2c30d5ed:RoutstrClient]` still yield
2376
+ // the bare request id.
2377
+ const req = line.match(/\[req:([^\]\:]+)/);
2378
+ if (req) {
2379
+ out.requestId = req[1]!.trim();
2380
+ }
2381
+
2382
+ // Prefer an explicit `[model:...]` tag; fall back to the modelId embedded
2383
+ // in the SDK's `generic request pricing input {"modelId":"..."}` line.
2384
+ const model = line.match(/\[model:([^\]\:]+)\]/);
2385
+ if (model) {
2386
+ out.modelId = model[1]!.trim();
2387
+ } else {
2388
+ const jsonModel = line.match(/"modelId"\s*:\s*"([^"]+)"/);
2389
+ if (jsonModel) {
2390
+ out.modelId = jsonModel[1];
2391
+ }
2392
+ }
2393
+
2394
+ out.isRouting = !!out.requestId && line.includes("Routing request with path");
2395
+ return out;
2396
+ }
2397
+
2398
+ type RequestSummary = {
2399
+ requestId: string;
2400
+ modelId: string;
2401
+ timestamp?: string;
2402
+ };
2403
+
2404
+ /**
2405
+ * Scan log lines newest-first and collect one record per request, keyed off
2406
+ * the "Routing request with path" line (the canonical per-request entry).
2407
+ * The model is enriched in a second pass from any line belonging to the
2408
+ * request (the SDK pricing line embeds modelId even in legacy logs).
2409
+ */
2410
+ export function collectRecentRequestsFromLines(
2411
+ lines: string[],
2412
+ limit: number,
2413
+ ): RequestSummary[] {
2414
+ const result: RequestSummary[] = [];
2415
+ const seen = new Set<string>();
2416
+
2417
+ for (let i = lines.length - 1; i >= 0; i--) {
2418
+ const parsed = parseStructuredLogLine(lines[i]!);
2419
+ if (!parsed.isRouting || !parsed.requestId) continue;
2420
+ if (seen.has(parsed.requestId)) continue;
2421
+ seen.add(parsed.requestId);
2422
+
2423
+ result.push({
2424
+ requestId: parsed.requestId,
2425
+ modelId: parsed.modelId ?? "unknown",
2426
+ timestamp: parsed.timestamp,
2427
+ });
2428
+
2429
+ if (result.length >= limit) break;
2430
+ }
2431
+
2432
+ // Second pass: fill in the model for requests whose routing line predates
2433
+ // the `[model:...]` tag but which logged `{"modelId":"..."}` later on.
2434
+ const byRequestId = new Map(result.map((r) => [r.requestId, r]));
2435
+ for (const line of lines) {
2436
+ const parsed = parseStructuredLogLine(line);
2437
+ if (!parsed.requestId || !parsed.modelId) continue;
2438
+ const record = byRequestId.get(parsed.requestId);
2439
+ if (record && record.modelId === "unknown") {
2440
+ record.modelId = parsed.modelId;
2441
+ }
2442
+ }
2443
+
2444
+ return result;
2445
+ }
2446
+
2447
+ function collectRecentRequests(
2448
+ files: string[],
2449
+ limit: number,
2450
+ ): RequestSummary[] {
2451
+ const result: RequestSummary[] = [];
2452
+ const seen = new Set<string>();
2453
+
2454
+ // Files are passed oldest-first (yesterday, today); scan newest-first.
2455
+ for (const file of [...files].reverse()) {
2456
+ if (!existsSync(file)) continue;
2457
+ const remaining = limit - result.length;
2458
+ if (remaining <= 0) break;
2459
+
2460
+ for (const request of collectRecentRequestsFromLines(
2461
+ readAllLines(file),
2462
+ remaining,
2463
+ )) {
2464
+ if (seen.has(request.requestId)) continue;
2465
+ seen.add(request.requestId);
2466
+ result.push(request);
2467
+ if (result.length >= limit) return result;
2468
+ }
2469
+ }
2470
+
2471
+ return result;
2472
+ }
2473
+
2474
+ function filterLinesByRequestId(
2475
+ files: string[],
2476
+ requestId: string,
2477
+ ): string[] {
2478
+ const matches: string[] = [];
2479
+ for (const file of files) {
2480
+ if (!existsSync(file)) continue;
2481
+ for (const line of readAllLines(file)) {
2482
+ if (parseStructuredLogLine(line).requestId === requestId) {
2483
+ matches.push(line);
2484
+ }
2485
+ }
2486
+ }
2487
+ return matches;
2488
+ }
2489
+
2490
+ function parseLinesOption(value: string): number {
2491
+ const parsed = Number.parseInt(value, 10);
2492
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 50;
2493
+ }
2494
+
2495
+
2305
2496
  program
2306
2497
  .command("logs")
2307
2498
  .description("View daemon logs")
2308
2499
  .option("-f, --follow", "Follow log output", false)
2309
2500
  .option("-c, --coco", "Show Cashu wallet-engine (coco) logs instead of daemon logs", false)
2310
2501
  .option("-n, --lines <number>", "Number of lines to show", "50")
2311
- .action(async (options: { follow: boolean; lines: string; coco: boolean }) => {
2312
- await requireLocalDaemon();
2313
- const logsDir = options.coco ? COCO_LOGS_DIR : LOGS_DIR;
2314
- const todayFile = getLogFileForDate(logsDir);
2315
- const yesterday = new Date();
2316
- yesterday.setDate(yesterday.getDate() - 1);
2317
- const yesterdayFile = getLogFileForDate(logsDir, yesterday);
2318
-
2319
- if (!existsSync(todayFile) && !existsSync(yesterdayFile)) {
2320
- console.log("No log files found. Daemon may not have started yet.");
2321
- console.log(`Logs directory: ${logsDir}`);
2322
- process.exit(1);
2323
- }
2502
+ .option("-r, --recent", "List recent request ids with their model", false)
2503
+ .option("-i, --request-id <id>", "Only show log lines for a specific request id")
2504
+ .action(
2505
+ async (options: {
2506
+ follow: boolean;
2507
+ lines: string;
2508
+ coco: boolean;
2509
+ recent: boolean;
2510
+ requestId?: string;
2511
+ }) => {
2512
+ await requireLocalDaemon();
2324
2513
 
2325
- const lines = parseInt(options.lines, 10);
2514
+ if (options.recent && options.requestId) {
2515
+ console.error(
2516
+ "Cannot combine --recent (-r) with --request-id (-i).",
2517
+ );
2518
+ process.exit(1);
2519
+ }
2326
2520
 
2327
- const logFiles = [yesterdayFile, todayFile].filter((file, index, files) => {
2328
- return existsSync(file) && files.indexOf(file) === index;
2329
- });
2521
+ const logsDir = options.coco ? COCO_LOGS_DIR : LOGS_DIR;
2522
+ const todayFile = getLogFileForDate(logsDir);
2523
+ const yesterday = new Date();
2524
+ yesterday.setDate(yesterday.getDate() - 1);
2525
+ const yesterdayFile = getLogFileForDate(logsDir, yesterday);
2330
2526
 
2331
- if (options.follow) {
2332
- if (existsSync(todayFile)) {
2333
- await followLogFile(todayFile, lines);
2334
- } else {
2335
- console.log("No log file for today to follow.");
2527
+ if (!existsSync(todayFile) && !existsSync(yesterdayFile)) {
2528
+ console.log("No log files found. Daemon may not have started yet.");
2529
+ console.log(`Logs directory: ${logsDir}`);
2530
+ process.exit(1);
2336
2531
  }
2337
- return;
2338
- }
2339
2532
 
2340
- for (const file of logFiles) {
2341
- if (logFiles.length > 1) {
2342
- console.log(`==> ${file} <==`);
2533
+ const lines = parseLinesOption(options.lines);
2534
+
2535
+ const logFiles = [yesterdayFile, todayFile].filter(
2536
+ (file, index, files) =>
2537
+ existsSync(file) && files.indexOf(file) === index,
2538
+ );
2539
+
2540
+ if (options.recent) {
2541
+ const requests = collectRecentRequests(logFiles, lines);
2542
+ if (requests.length === 0) {
2543
+ console.log("No requests found in recent logs.");
2544
+ return;
2545
+ }
2546
+
2547
+ console.log("Recent requests (newest first):");
2548
+ for (const request of requests) {
2549
+ const when = request.timestamp ? ` ${request.timestamp}` : "";
2550
+ console.log(` ${request.requestId} ${request.modelId}${when}`);
2551
+ }
2552
+ return;
2343
2553
  }
2344
- const output = readLastLines(file, lines);
2345
- if (output) {
2346
- console.log(output);
2554
+
2555
+ if (options.requestId) {
2556
+ const matches = filterLinesByRequestId(logFiles, options.requestId);
2557
+ if (matches.length === 0) {
2558
+ console.log(
2559
+ `No log lines found for request '${options.requestId}'.`,
2560
+ );
2561
+ return;
2562
+ }
2563
+
2564
+ for (const line of matches) {
2565
+ console.log(line);
2566
+ }
2567
+ return;
2347
2568
  }
2348
- }
2349
- });
2569
+
2570
+ if (options.follow) {
2571
+ if (existsSync(todayFile)) {
2572
+ await followLogFile(todayFile, lines);
2573
+ } else {
2574
+ console.log("No log file for today to follow.");
2575
+ }
2576
+ return;
2577
+ }
2578
+
2579
+ for (const file of logFiles) {
2580
+ if (logFiles.length > 1) {
2581
+ console.log(`==> ${file} <==`);
2582
+ }
2583
+ const output = readLastLines(file, lines);
2584
+ if (output) {
2585
+ console.log(output);
2586
+ }
2587
+ }
2588
+ },
2589
+ );
2350
2590
 
2351
2591
  export function cli(args: string[]) {
2352
2592
  program.parse(args);