Files
routstrd/src/cli.ts
T

3126 lines
96 KiB
TypeScript

import { program } from "commander";
import { startDaemon } from "./start-daemon";
import { ensureDirsSync, saveDaemonConfig } from "./daemon/config-store";
import {
handleDaemonCommand,
callDaemon,
callAuth,
ensureDaemonRunning,
isDaemonRunning,
loadConfig,
getDaemonBaseUrl,
getUserNpub,
type CommandResponse,
} from "./utils/daemon-client";
import {
formatCooldowns,
type CooldownsOutput,
} from "./utils/cooldowns";
import { waitForDaemonToExit } from "./utils/daemon-stop";
import {
listClientsAction,
deleteClientAction,
addClientAction,
refreshModelsAndClientsAction,
setAutomaticRefreshAction,
} from "./utils/clients";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
import { execSync } from "child_process";
import { dirname, join } from "path";
import {
CONFIG_DIR,
DB_PATH,
CONFIG_FILE,
DEFAULT_CONFIG,
LOGS_DIR,
type RoutstrdConfig,
} from "./utils/config";
import { COCO_LOGS_DIR, logger } from "./utils/logger";
import { setupIntegration, type IntegrationKey } from "./integrations";
import {
assertLegacyCocodNotRunning,
claimLegacyCocodPidFile,
stopLegacyCocod,
} from "./daemon/wallet/coco-client";
import { migrateLegacyWallet } from "./daemon/wallet/migration";
import {
diagnoseWallets,
renderWalletDoctor,
summarizeWalletDirectory,
WalletMigrationConflictError,
} from "./daemon/wallet/diagnostics";
import {
legacyCocodDir,
legacyCocodPidPath,
legacyCocodSocketPath,
walletDir as defaultWalletDir,
walletPidPath,
} from "./daemon/wallet/paths";
import * as QRCode from "qrcode";
import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98";
import {
HISTORY_ENTRY_TYPES,
historyStatus,
isHistoryEntryType,
} from "./utils/history";
import { generateSecretKey, nip19 } from "nostr-tools";
import { generateMnemonic } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
import {
compareVersions,
getGlobalPackageVersion,
getLatestNpmVersion,
} from "./utils/update-checker.ts";
import { isStandaloneExecutable, pm2DaemonArgs } from "./runtime";
import { VERSION } from "./version";
import {
getLatestStandaloneRelease,
installStandaloneRelease,
} from "./utils/standalone-update";
type RoutstrModel = {
id: string;
name?: string;
description?: string;
context_length?: number;
};
type UsageEntry = {
id: string;
timestamp: number;
modelId: string;
baseUrl: string;
requestId: string;
cost: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
client?: string;
};
function parsePositiveIntOrExit(value: string, fieldName: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
console.error(`Invalid ${fieldName}: ${value}`);
process.exit(1);
}
return parsed;
}
function isPositiveIntegerString(value: string): boolean {
return /^[1-9]\d*$/.test(value.trim());
}
async function printLightningInvoice(invoice: string): Promise<void> {
const paymentUri = `lightning:${invoice}`;
const qr = await QRCode.toString(paymentUri, {
type: "terminal",
small: true,
});
console.log(`${qr}\nInvoice:\n${invoice}`);
}
/** Block until the daemon has minted the paid invoice, then show the credit. */
async function waitForMintQuote(operationId: string): Promise<void> {
console.log(
"\nWaiting for payment... (Ctrl+C stops waiting; the daemon keeps checking)",
);
for (;;) {
const result = await callDaemon(
`/wallet/receive/bolt11/${encodeURIComponent(operationId)}`,
);
const quote = result.output as
| { state?: string; amount?: number; error?: string }
| undefined;
if (quote?.state === "finalized" && quote.error) {
// The mint issued the quote but the wallet could not restore the proofs.
console.error(`Payment reached the mint but could not be credited: ${quote.error}`);
process.exit(1);
}
if (quote?.state === "finalized") {
const balance = await callDaemon("/wallet/balance");
const balances =
(balance.output as { balances?: Record<string, number> } | undefined)
?.balances ?? {};
const total = Object.values(balances).reduce((sum, sats) => sum + sats, 0);
console.log(`Received ${quote.amount} sat. Balance: ${total} sat`);
return;
}
if (quote?.state === "failed") {
console.error(`Invoice failed: ${quote.error ?? "unknown reason"}`);
process.exit(1);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
/**
* Warn that value-moving wallet operations will block until startup recovery
* finishes. Reads stay available, but the daemon gates send/receive behind the
* recovery gate, so without this notice the CLI would appear to hang silently
* after a restart.
*/
async function warnIfWalletRecovering(): Promise<void> {
try {
await ensureDaemonRunning();
const result = await callDaemon("/wallet/status");
const output = result.output as
| { state?: string; recovery?: { phase?: string } }
| undefined;
if (output?.state !== "RECOVERING") return;
const phase = output.recovery?.phase;
console.error(
"Wallet is recovering from a previous run (this only happens after a restart).",
);
if (phase && phase !== "done") {
console.error(`Recovery phase: ${phase}.`);
}
console.error(
"Please wait — send and receive resume as soon as recovery completes.",
);
} catch {
// Status is best-effort; never block the real command on it.
}
}
export function initializeWallet(walletDir = defaultWalletDir()): void {
const walletConfig = join(walletDir, "config.json");
// The wallet directory and config contain the plaintext seed phrase. Correct
// permissions on existing installations as well as newly created ones.
mkdirSync(walletDir, { recursive: true, mode: 0o700 });
chmodSync(walletDir, 0o700);
if (existsSync(walletConfig)) {
chmodSync(walletConfig, 0o600);
console.log("Wallet already initialized.");
return;
}
const mnemonic = generateMnemonic(wordlist);
const config = {
version: 1,
mnemonic,
encrypted: false,
createdAt: new Date().toISOString(),
};
writeFileSync(walletConfig, JSON.stringify(config, null, 2), {
mode: 0o600,
flag: "wx",
});
console.log("Initialized. Mnemonic:", mnemonic);
console.log("IMPORTANT: Write down this mnemonic and keep it safe!");
}
type PidFileDeps = {
readFile(path: string): string;
isProcessRunning(pid: number): boolean;
};
export function getLivePidFileOwner(
path: string,
deps: PidFileDeps = {
readFile: (pidPath) => readFileSync(pidPath, "utf8"),
isProcessRunning: (pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
},
},
): number | null {
try {
const contents = deps.readFile(path).trim();
if (!/^\d+$/.test(contents)) return null;
const pid = Number.parseInt(contents, 10);
return pid > 0 && deps.isProcessRunning(pid) ? pid : null;
} catch {
return null;
}
}
/**
* Restart the routstrd daemon after an update so the new binary takes
* effect immediately. Failures are collected and reported but never
* roll back the update itself.
*
* Note: cocod is no longer a separate process — the wallet now runs
* in-process via coco-core, so there is nothing else to restart.
*/
async function restartDaemonsAfterUpdate(): Promise<void> {
const config = await loadConfig();
const isRemote = !!config.daemonUrl;
const failures: string[] = [];
// --- routstrd daemon ---
if (isRemote) {
console.log("\nUsing remote daemon — skipping routstrd daemon restart.");
} else {
try {
const wasRunning = await isDaemonRunning();
if (!wasRunning) {
console.log("\nroutstrd daemon was not running — skipping restart.");
} else {
const pidFilePath = walletPidPath();
const ownerPid = getLivePidFileOwner(pidFilePath);
if (ownerPid === null) {
throw new Error(
`Refusing to stop the process at ${getDaemonBaseUrl(config)} because ` +
`no live routstrd owner was found in ${pidFilePath}`,
);
}
console.log(`\nRestarting routstrd daemon (PID: ${ownerPid})...`);
await callDaemon("/stop", { method: "POST" });
// Wait for the old daemon to fully exit: it keeps serving ongoing
// requests before it releases the wallet lock and the new daemon can
// safely claim it.
await waitForDaemonToExit({ pidFilePath });
console.log("routstrd daemon stopped.");
await stopLegacyCocod();
console.log("Starting routstrd daemon...");
await startDaemon({
port: String(config.port || 8008),
host: config.host || undefined,
provider: config.provider || undefined,
});
console.log("routstrd daemon restarted.");
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
failures.push(`routstrd daemon: ${msg}`);
}
}
// --- report ---
if (failures.length > 0) {
console.error("\n⚠ Failed to restart daemon:");
for (const f of failures) {
console.error(` - ${f}`);
}
console.error(
"The update was applied but may not take effect until the daemon is manually restarted.",
);
process.exit(1);
}
console.log("\n✓ Daemon restarted successfully.");
}
async function requireLocalDaemon(): Promise<void> {
const config = await loadConfig();
if (config.daemonUrl) {
console.error(
`This command is not available when using a remote daemon (${config.daemonUrl}).`,
);
process.exit(1);
}
}
async function initDaemon(integrationKey?: IntegrationKey): Promise<void> {
console.log("Initializing routstrd...");
// Create config directory (0700, correcting existing installs too)
if (!existsSync(CONFIG_DIR)) {
console.log(`Created config directory: ${CONFIG_DIR}`);
}
ensureDirsSync();
// Create initial config (0600, atomic write)
if (!existsSync(CONFIG_FILE)) {
const config: RoutstrdConfig = { ...DEFAULT_CONFIG };
saveDaemonConfig(config);
console.log(`Created config file: ${CONFIG_FILE}`);
}
const config = await loadConfig();
if (!config.nsec) {
const secretKey = generateSecretKey();
const nsec = nip19.nsecEncode(secretKey);
const npub = npubFromSecretKey(secretKey);
config.nsec = nsec;
saveDaemonConfig(config);
console.log("\nA new Nostr identity has been generated for authentication.");
console.log(`Your npub: ${npub}`);
console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`);
}
console.log(`Database will be stored at: ${DB_PATH}`);
await stopLegacyCocod();
const migration = await migrateLegacyWallet({
assertLegacyStopped: () =>
assertLegacyCocodNotRunning({
socketPath: legacyCocodSocketPath(),
}),
acquireLegacyLock: () => {
mkdirSync(dirname(legacyCocodPidPath()), {
recursive: true,
mode: 0o700,
});
return claimLegacyCocodPidFile({
pidFilePath: legacyCocodPidPath(),
});
},
});
if (migration.status === "migrated") {
console.log(`Migrated wallet from ${migration.from} to ${migration.to}.`);
for (const warning of migration.cleanupWarnings) console.warn(warning);
}
initializeWallet();
await startDaemon({ port: String(config.port || 8008), host: config.host || undefined });
await setupIntegration(config, integrationKey);
console.log("\nInitialization complete!");
console.log(
"\n use 'routstrd receive <cashu-token>' or 'routstrd receive 2100' to top up your local wallet using Lightning!",
);
console.log(
"\n full wallet commands still work too, e.g. 'routstrd wallet receive cashu <token>' and 'routstrd wallet receive bolt11 2100'.",
);
console.log(
"\nTo ensure routstrd persists across system restarts, run: 'routstrd service install'",
);
}
program
.name("routstrd")
.description("Routstr daemon - Manage routstr processes")
.version(VERSION, "--version", "output the version number");
program
.command("update")
.description("Update routstrd to the latest version")
.action(async () => {
if (isStandaloneExecutable()) {
const release = await getLatestStandaloneRelease();
if ((compareVersions(VERSION, release.version) ?? -1) >= 0) {
console.log(`routstrd is already up to date (v${VERSION}).`);
return;
}
console.log(`Updating routstrd from v${VERSION} to v${release.version}...`);
await installStandaloneRelease(release);
console.log("routstrd updated successfully.\n");
await restartDaemonsAfterUpdate();
return;
}
const packages = [{ name: "routstrd", label: "routstrd" }];
let updatedAny = false;
for (const { name, label } of packages) {
const installed = await getGlobalPackageVersion(name);
const latest = await getLatestNpmVersion(name);
// Only skip when we're confident the installed version is current.
// If we can't determine either version we fall through to installing.
if (
installed &&
latest &&
(compareVersions(installed, latest) ?? -1) >= 0
) {
console.log(`${label} is already up to date (v${installed}).`);
continue;
}
const fromPart = installed ? ` from v${installed}` : "";
const toPart = latest ? ` to v${latest}` : "";
console.log(`Updating ${label}${fromPart}${toPart}...`);
const proc = Bun.spawn(["bun", "install", "-g", name], {
stdout: "inherit",
stderr: "inherit",
});
const code = await proc.exited;
if (code !== 0) {
console.error(`Failed to update ${label}.`);
process.exit(1);
}
console.log(`${label} updated successfully.\n`);
updatedAny = true;
}
if (updatedAny) {
console.log("All requested updates have been applied!");
// Restart daemons so the new binaries take effect immediately.
await restartDaemonsAfterUpdate();
} else {
console.log("\nAll packages are already up to date — nothing to do.");
}
});
program
.command("refund")
.description("Refund pending tokens and API keys to a specified mint")
.option(
"-m, --mint-url <mintUrl>",
"Mint URL to refund to (defaults to active wallet mint)",
)
.option("-y, --yes", "Skip confirmation prompt", false)
.option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false)
.action(async (options: { mintUrl?: string; yes: boolean; xcashu: boolean }) => {
await ensureDaemonRunning();
let mintUrl = options.mintUrl;
if (!mintUrl) {
const balanceResult = await callDaemon("/balance");
if (balanceResult.error) {
console.log(balanceResult.error);
process.exit(1);
}
const output = balanceResult.output as
| {
balances?: Record<string, number>;
activeMint?: string;
}
| undefined;
mintUrl =
output?.activeMint ??
(output?.balances ? Object.keys(output.balances)[0] : undefined);
if (!mintUrl) {
console.log("No mint URLs found in wallet balance");
process.exit(1);
}
console.log(`Using mint URL: ${mintUrl}`);
}
try {
if (options.xcashu) {
// xcashu path: only refund xcashu tokens
const result = await callDaemon("/refund/xcashu", {
method: "POST",
body: { mintUrl },
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
message: string;
results: Array<{ baseUrl: string; token: string; success: boolean; error?: string }>;
}
| undefined;
if (output) {
console.log(output.message);
console.log("\nResults:");
for (const r of output.results) {
const status = r.success ? "success" : `failed: ${r.error || "unknown"}`;
console.log(` - ${r.baseUrl}: ${status}`);
}
}
return;
}
const result = await callDaemon("/refund", {
method: "POST",
body: { mintUrl },
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
message: string;
pendingTokens: number;
apiKeys: number;
results: Array<{ baseUrl: string; success: boolean }>;
}
| undefined;
if (output) {
console.log(output.message);
console.log(`\nPending tokens: ${output.pendingTokens}`);
console.log(`API keys: ${output.apiKeys}`);
console.log("\nResults:");
for (const r of output.results) {
console.log(` - ${r.baseUrl}: ${r.success ? "success" : "failed"}`);
}
}
} catch (error) {
const message = (error as Error).message;
if (
message?.includes("fetch failed") ||
message?.includes("Connection refused")
) {
console.error("Daemon is not running");
process.exit(1);
}
console.error(message);
process.exit(1);
}
});
// Remote - show or configure a remote daemon URL
program
.command("remote [url]")
.description(
"Show the configured remote daemon, or configure one. With no URL, prints the current remote node; pass a URL to set one up.",
)
.option("--auth-url <authUrl>", "URL of the auth proxy for management commands (npubs, clients, usage)")
.action(async (url: string | undefined, options: { authUrl?: string }) => {
const config = await loadConfig();
// No URL provided -> show the current remote node, or tell the user to set one up.
if (!url) {
if (config.daemonUrl) {
console.log(`Remote daemon URL: ${config.daemonUrl}`);
if (config.authUrl) {
console.log(`Auth proxy URL: ${config.authUrl}`);
}
const npub = getUserNpub(config);
if (npub) {
console.log(`Nostr identity: ${npub}`);
}
return;
}
console.error("No remote node is set up.");
console.error(
"Pass a URL to set one up, e.g. 'routstrd remote https://<host>:<port>'.",
);
process.exit(1);
}
try {
new URL(url);
} catch {
console.error(`Invalid URL: ${url}`);
process.exit(1);
}
if (options.authUrl) {
try {
new URL(options.authUrl);
} catch {
console.error(`Invalid auth URL: ${options.authUrl}`);
process.exit(1);
}
}
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
}
const updates: Partial<RoutstrdConfig> = { daemonUrl: url };
if (options.authUrl) {
updates.authUrl = options.authUrl;
}
let generatedNpub: string | undefined;
if (!config.nsec) {
const secretKey = generateSecretKey();
const nsec = nip19.nsecEncode(secretKey);
const npub = npubFromSecretKey(secretKey);
updates.nsec = nsec;
generatedNpub = npub;
}
const updatedConfig: RoutstrdConfig = {
...config,
...updates,
};
saveDaemonConfig(updatedConfig);
console.log(`Remote daemon URL set to: ${url}`);
if (options.authUrl) {
console.log(`Auth proxy URL set to: ${options.authUrl}`);
}
if (generatedNpub) {
console.log(
`\nA new Nostr identity has been generated for remote authentication.`,
);
console.log(`Your npub: ${generatedNpub}`);
console.log(
`You can view it in the config file at: ${CONFIG_FILE}`,
);
}
});
// Local - switch back to local daemon mode (removes daemonUrl/authUrl)
program
.command("local")
.description("Switch back to local daemon mode (removes remote daemon URL)")
.action(async () => {
const config = await loadConfig();
if (!config.daemonUrl) {
console.log("Already in local mode — no remote daemon URL is configured.");
return;
}
const previousDaemonUrl = config.daemonUrl;
const previousAuthUrl = config.authUrl;
const { daemonUrl: _daemonUrl, authUrl: _authUrl, ...rest } = config;
const updatedConfig: RoutstrdConfig = rest;
saveDaemonConfig(updatedConfig);
console.log("Switched back to local daemon mode.");
if (previousDaemonUrl) {
console.log(` Removed remote daemon URL: ${previousDaemonUrl}`);
}
if (previousAuthUrl) {
console.log(` Removed auth proxy URL: ${previousAuthUrl}`);
}
console.log(
`\nUse 'routstrd onboard' for first time using local mode or 'routstrd start' to start the local daemon if you already are using it.`,
);
console.log(`You can view the config file at: ${CONFIG_FILE}`);
});
// Onboard - initialize the daemon
program
.command("onboard")
.description(
"Initialize routstrd (creates config directory and initializes wallet)",
)
.option("--opencode", "Set up OpenCode integration (non-interactive)")
.option("--openclaw", "Set up OpenClaw integration (non-interactive)")
.option("--pi-agent", "Set up Pi Agent integration (non-interactive)")
.option("--claude-code", "Set up Claude Code integration (non-interactive)")
.option("--hermes", "Set up Hermes integration (non-interactive)")
.option("--skip-integration", "Skip integration setup")
.action(
async (options: {
opencode?: boolean;
openclaw?: boolean;
piAgent?: boolean;
claudeCode?: boolean;
hermes?: boolean;
skipIntegration?: boolean;
}) => {
await requireLocalDaemon();
const integrationFlags: Record<string, boolean | undefined> = {
opencode: options.opencode,
openclaw: options.openclaw,
"pi-agent": options.piAgent,
"claude-code": options.claudeCode,
hermes: options.hermes,
};
const selectedIntegrations = Object.keys(integrationFlags).filter(
(key) => integrationFlags[key],
);
if (selectedIntegrations.length > 1) {
console.error(
"Error: use only one integration flag, or use 'routstrd clients add' for multiple clients.",
);
process.exit(1);
}
if (options.skipIntegration && selectedIntegrations.length > 0) {
console.error(
"Error: --skip-integration cannot be combined with an integration flag.",
);
process.exit(1);
}
let integrationKey: IntegrationKey | undefined;
if (options.skipIntegration) {
integrationKey = "skip";
} else if (selectedIntegrations.length === 1) {
integrationKey = selectedIntegrations[0];
}
try {
await initDaemon(integrationKey);
} catch (error) {
// An expected, user-actionable refusal — print the structured message
// without Bun's unhandled-rejection source snippet and stack trace.
if (error instanceof WalletMigrationConflictError) {
console.error(error.message);
process.exit(1);
}
throw error;
}
},
);
program
.command("daemon")
.description("Run the daemon in the foreground")
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Bind address")
.option("-p, --provider <provider>", "Default provider to use")
.action(async (options: { port?: string; host?: string; provider?: string }) => {
await requireLocalDaemon();
const argv = ["routstrd", "daemon"];
if (options.port) argv.push("--port", options.port);
if (options.host) argv.push("--host", options.host);
if (options.provider) argv.push("--provider", options.provider);
const { runDaemon } = await import("./daemon/index");
await runDaemon(argv);
});
// Start - start the background daemon
program
.command("start")
.description("Start the background daemon")
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Bind address (default: 127.0.0.1)")
.option("-p, --provider <provider>", "Default provider to use")
.action(async (options: { port?: string; host?: string; provider?: string }) => {
await requireLocalDaemon();
const config = await loadConfig();
await stopLegacyCocod();
try {
await startDaemon({
port: options.port || String(config.port || 8008),
host: options.host || config.host || undefined,
provider: options.provider,
});
} catch (error) {
// startDaemon embeds the failed daemon's own output in the message
// (including the wallet-conflict report), so print it without a stack.
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Status - check daemon status
program
.command("status")
.description("Check daemon and wallet status")
.action(async () => {
const running = await isDaemonRunning();
if (!running) {
console.log("Daemon is not running");
process.exit(1);
}
const result = await callDaemon("/status");
if (result.error) {
console.log(result.error);
process.exit(1);
}
if (result.output !== undefined) {
if (typeof result.output === "string") {
console.log(result.output);
} else {
try {
const formatted = JSON.stringify(result.output, null, 2);
console.log(formatted ?? String(result.output));
} catch {
console.log(String(result.output));
}
}
}
});
// Explicit provider Lightning operations. Payment is made externally; no automatic NWC spending.
const lightningPayments = program.command("payments-lightning").description("Fund provider keys or refund them over Lightning");
for (const purpose of ["create", "topup"] as const) {
lightningPayments.command(`${purpose} <baseUrl> <amountSats>`)
.description("Create a provider Lightning invoice (pay the returned BOLT11 externally)")
.action(async (baseUrl: string, amountSats: string) => {
await handleDaemonCommand(`/payments/lightning/${purpose}`, { method: "POST", body: { baseUrl, amountSats: Number(amountSats) } });
});
}
lightningPayments.command("status <baseUrl> <invoiceId>").description("Check settlement and store/refresh the paid provider key")
.action(async (baseUrl: string, invoiceId: string) => {
await handleDaemonCommand("/payments/lightning/status", { method: "POST", body: { baseUrl, invoiceId } });
});
lightningPayments.command("recover <baseUrl> <bolt11>").description("Recover a paid invoice and store/refresh its provider key")
.action(async (baseUrl: string, bolt11: string) => {
await handleDaemonCommand("/payments/lightning/recover", { method: "POST", body: { baseUrl, bolt11 } });
});
lightningPayments.command("invoices").description("List saved provider invoices (contains sensitive recovery credentials)")
.action(async () => { await handleDaemonCommand("/payments/lightning/invoices", { method: "POST", body: {} }); });
lightningPayments.command("refund <baseUrl> <lightningAddress>").description("Refund all available provider balance to a Lightning address; retain key for recovery")
.action(async (baseUrl: string, lightningAddress: string) => {
await handleDaemonCommand("/payments/lightning/refund", { method: "POST", body: { baseUrl, lightningAddress } });
});
// Balance - get wallet and API key balances
program
.command("balance")
.description("Get wallet and API key balances")
.option("--api-keys", "List all stored API keys (baseUrl + key + balance)", false)
.option(
"--delete-api-keys <baseUrl>",
"Delete the API key stored for the given provider base URL (refunds balance first)",
)
.option(
"--mint-url <url>",
"Mint URL to refund the deleted API key balance to (defaults to active wallet mint)",
)
.action(async (options: { apiKeys: boolean; deleteApiKeys?: string; mintUrl?: string }) => {
await ensureDaemonRunning();
if (options.deleteApiKeys) {
const baseUrl = options.deleteApiKeys;
const queryParts = [`baseUrl=${encodeURIComponent(baseUrl)}`];
if (options.mintUrl) {
queryParts.push(`mintUrl=${encodeURIComponent(options.mintUrl)}`);
}
const result = await callDaemon(
`/keys/api/delete?${queryParts.join("&")}`,
{ method: "DELETE" },
);
if (result.error) {
console.log(result.error);
process.exit(1);
}
const out = result.output as
| {
baseUrl?: string;
removed?: boolean;
refunded?: boolean;
refundedAmount?: number;
refundMessage?: string;
message?: string;
}
| undefined;
if (out) {
console.log(out.message ?? `Removed API key for ${baseUrl}`);
if (out.refunded && out.refundedAmount !== undefined) {
console.log(` Refunded: ${out.refundedAmount} sats`);
} else if (out.refundMessage) {
console.log(` Refund: ${out.refundMessage}`);
}
}
return;
}
if (options.apiKeys) {
const result = await callDaemon("/keys/api");
if (result.error) {
console.log(result.error);
process.exit(1);
}
const data = result.output as
| {
apiKeys: Array<{
baseUrl: string;
key: string;
balance: number;
lastUsed: number | null;
}>;
count: number;
total: number;
unit: string;
}
| undefined;
console.log("=== API Keys ===\n");
if (!data || data.apiKeys.length === 0) {
console.log(" No API keys stored.");
return;
}
for (const k of data.apiKeys) {
const lastUsed = k.lastUsed
? new Date(k.lastUsed).toISOString()
: "never";
console.log(` ${k.baseUrl}`);
console.log(` key: ${k.key}`);
console.log(` balance: ${k.balance} ${data.unit}`);
console.log(` lastUsed: ${lastUsed}`);
console.log("");
}
console.log(` Total: ${data.apiKeys.length} key(s), ${data.total} ${data.unit}`);
return;
}
const [walletResult, keysResult] = await Promise.all([
callDaemon("/balance"),
callDaemon("/keys/balance"),
]);
console.log("Checking full system balance...\n");
console.log("=== Wallet Balance ===");
let totalWallet = 0;
if (
walletResult.output &&
typeof walletResult.output === "object" &&
"balances" in walletResult.output
) {
const balances = (
walletResult.output as { balances: Record<string, number> }
).balances;
for (const [mintUrl, balance] of Object.entries(balances)) {
console.log(` ${mintUrl}: ${balance} sats`);
totalWallet += balance;
}
console.log(` Total: ${totalWallet} sats`);
} else if (walletResult.error) {
console.error("Wallet error:", walletResult.error);
}
console.log("\n=== API Keys ===");
let totalApiKeys = 0;
if (
keysResult.output &&
typeof keysResult.output === "object" &&
"keys" in keysResult.output
) {
const keys = (
keysResult.output as {
keys: Array<{ id: string; name: string; balance: number }>;
}
).keys;
const apiKeyEntries = keys.filter((k) => k.id.startsWith("apikey:"));
for (const key of apiKeyEntries) {
const name = key.name.replace("API Key: ", "");
console.log(` ${name}: ${key.balance} sats`);
totalApiKeys += key.balance;
}
if (apiKeyEntries.length === 0) {
console.log(" No API keys found");
} else {
console.log(` Total: ${totalApiKeys} sats`);
}
} else if (keysResult.error) {
console.error("Keys error:", keysResult.error);
}
console.log("\n=== Summary ===");
console.log(
` Wallet: ${totalWallet} sats | API Keys: ${totalApiKeys} sats`,
);
console.log(` Grand Total: ${totalWallet + totalApiKeys} sats`);
});
// Ping
program
.command("ping")
.description("Test connection to the daemon")
.action(async () => {
await handleDaemonCommand("/ping");
});
// Refresh - refresh models and integrations
program
.command("refresh")
.description("Refresh routstr21 models and client integrations")
.action(async () => {
await ensureDaemonRunning();
await refreshModelsAndClientsAction();
});
// Models - list routstr21 models
program
.command("models")
.description("List available routstr21 models")
.option("-r, --refresh", "Force refresh routstr21 models from Nostr", false)
.option("-m, --model <id>", "Show providers for a specific model")
.action(async (options: { refresh: boolean; model?: string }) => {
await ensureDaemonRunning();
if (options.model) {
// Show providers for specific model
const result = await callDaemon(
`/models/${encodeURIComponent(options.model)}/providers`,
);
if (result.error) {
console.log(result.error);
process.exit(1);
}
const modelData = result.output as
| {
id: string;
name?: string;
description?: string;
context_length?: number;
providers: Array<{
baseUrl: string;
disabled?: boolean;
pricing: {
prompt: number;
completion: number;
request: number;
max_cost: number;
};
}>;
}
| undefined;
if (!modelData) {
console.log("Model not found");
process.exit(1);
}
console.log(`\n${modelData.name || modelData.id}`);
if (modelData.description) {
console.log(` ${modelData.description}`);
}
if (modelData.context_length) {
console.log(
` Context: ${modelData.context_length.toLocaleString()} tokens`,
);
}
console.log(`\n Providers (${modelData.providers.length}):`);
for (const provider of modelData.providers) {
console.log(
`\n ${provider.baseUrl}${provider.disabled ? " [Disabled]" : ""}`,
);
console.log(
` Prompt: ${(provider.pricing.prompt * 1000000).toFixed(2)} sats/M tokens`,
);
console.log(
` Completion: ${(provider.pricing.completion * 1000000).toFixed(2)} sats/M tokens`,
);
console.log(
` Request: ${provider.pricing.request.toFixed(2)} sats`,
);
console.log(
` Max cost: ${provider.pricing.max_cost.toFixed(2)} sats`,
);
}
console.log("");
return;
}
// List all models with interactive selection
const result = await callDaemon(
options.refresh ? "/models?refresh=true" : "/models",
);
if (result.error) {
console.log(result.error);
process.exit(1);
}
if (
result.output &&
typeof result.output === "object" &&
"models" in result.output
) {
const models = (result.output as { models: RoutstrModel[] }).models;
if (models.length === 0) {
console.log("No routstr21 models found");
} else {
console.log(`\nFound ${models.length} routstr21 models:`);
console.log(
"(Use 'routstrd models -m <model_id>' to see providers and pricing)\n",
);
models.forEach((model, i) => {
const details = [
model.name && model.name !== model.id ? model.name : null,
model.context_length ? `${model.context_length} ctx` : null,
]
.filter(Boolean)
.join(" - ");
console.log(
` ${String(i + 1).padStart(2)}. ${model.id}${details ? ` (${details})` : ""}`,
);
});
console.log("");
}
}
});
program
.command("usage")
.description("Show recent usage logs and total sats cost")
.option("-n, --limit <number>", "Number of recent usage entries", "10")
.action(async (options: { limit: string }) => {
await ensureDaemonRunning();
const requested = Number.parseInt(options.limit, 10);
const limit =
Number.isFinite(requested) && requested > 0
? Math.min(requested, 1000)
: 10;
const result = await callAuth(`/usage?limit=${limit}`);
if (result.error) {
console.log(result.error);
process.exit(1);
}
// The daemon returns { output: UsageEntry[] } where output is the array directly
const entries = (result.output as UsageEntry[] | undefined) || [];
// Calculate totals from entries
const totalEntries = entries.length;
const totalSatsCost = entries.reduce(
(sum, e) => sum + (e.satsCost || 0),
0,
);
const recentSatsCost = totalSatsCost; // For now, recent = total since we don't have time window
console.log(`Usage entries: showing ${entries.length} of ${totalEntries}`);
console.log(`Total sats cost (all time): ${totalSatsCost.toFixed(3)} sats`);
console.log(`Sats cost (shown entries): ${recentSatsCost.toFixed(3)} sats`);
if (entries.length === 0) {
console.log("No usage entries yet.");
return;
}
console.log("");
entries.forEach((entry, index) => {
const time = new Date(entry.timestamp).toISOString();
const provider = entry.baseUrl || "unknown";
const reqId = entry.requestId || "unknown";
const client = entry.client ? ` | client: ${entry.client}` : "";
console.log(
`${index + 1}. ${time} | ${entry.modelId} | ${provider} | ${entry.satsCost.toFixed(3)} sats${client}`,
);
console.log(
` tokens p/c/t: ${entry.promptTokens}/${entry.completionTokens}/${entry.totalTokens} | request: ${reqId}`,
);
});
});
// Providers - list and manage providers
const providersCmd = program
.command("providers")
.description("List and manage providers");
// Strict index parsing: parseInt accepts garbage like "3abc" (-> 3) or
// "0x5" (-> 0), which would silently act on the wrong provider.
const parseProviderIndices = (raw: string[]): number[] => {
const valid: number[] = [];
const invalid: string[] = [];
for (const s of raw) {
if (/^\d+$/.test(s)) {
valid.push(parseInt(s, 10));
} else {
invalid.push(s);
}
}
if (invalid.length > 0) {
console.log(`Ignoring invalid index argument(s): ${invalid.join(", ")}`);
}
return valid;
};
providersCmd
.command("list")
.description("List all providers with their enabled/disabled status")
.option("--refresh", "Force re-fetch all Nostr events and refresh models from all enabled providers", false)
.action(async (options: { refresh: boolean }) => {
await ensureDaemonRunning();
const query = options.refresh ? "/providers?refresh=true" : "/providers";
const result = await callDaemon(query);
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
providers: Array<{
index: number;
baseUrl: string;
disabled: boolean;
manuallyDisabled?: boolean;
manuallyEnabled?: boolean;
}>;
disabledCount: number;
totalCount: number;
}
| undefined;
if (!output?.providers) {
console.log("No providers found.");
return;
}
console.log(
`Providers (${output.totalCount} total, ${output.disabledCount} disabled):\n`,
);
for (const provider of output.providers) {
const status = provider.manuallyDisabled
? "DISABLED (manual) "
: provider.disabled
? "DISABLED (nostr review) "
: provider.manuallyEnabled
? "enabled (manual override)"
: "enabled ";
console.log(` [${provider.index}] ${status} ${provider.baseUrl}`);
}
});
providersCmd
.command("disable <indices...>")
.description(
"Disable providers by their indices (e.g., routstrd providers disable 0 2 5)",
)
.action(async (indices: string[]) => {
await ensureDaemonRunning();
const indexNums = parseProviderIndices(indices);
if (indexNums.length === 0) {
console.log("No valid indices provided.");
process.exit(1);
}
const result = await callDaemon("/providers/disable", {
method: "POST",
body: { indices: indexNums },
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| { message: string; disabled: string[] }
| undefined;
if (output) {
console.log(output.message);
for (const url of output.disabled) {
console.log(` - ${url}`);
}
}
});
providersCmd
.command("enable <indices...>")
.description(
"Enable providers by their indices (e.g., routstrd providers enable 0 2 5)",
)
.action(async (indices: string[]) => {
await ensureDaemonRunning();
const indexNums = parseProviderIndices(indices);
if (indexNums.length === 0) {
console.log("No valid indices provided.");
process.exit(1);
}
const result = await callDaemon("/providers/enable", {
method: "POST",
body: { indices: indexNums },
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| { message: string; enabled: string[] }
| undefined;
if (output) {
console.log(output.message);
for (const url of output.enabled) {
console.log(` - ${url}`);
}
}
});
providersCmd
.command("nostr-sync <indices...>")
.description(
"Remove manual enable/disable overrides and return providers to their Nostr review-based state (e.g., routstrd providers nostr-sync 0 2 5)",
)
.action(async (indices: string[]) => {
await ensureDaemonRunning();
const indexNums = parseProviderIndices(indices);
if (indexNums.length === 0) {
console.log("No valid indices provided.");
process.exit(1);
}
const result = await callDaemon("/providers/nostr-sync", {
method: "POST",
body: { indices: indexNums },
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
message: string;
providers: Array<{ baseUrl: string; disabled: boolean }>;
skipped?: unknown[];
}
| undefined;
if (output) {
console.log(output.message);
for (const provider of output.providers) {
const status = provider.disabled
? "DISABLED (nostr review)"
: "enabled";
console.log(` - ${provider.baseUrl} -> ${status}`);
}
if (output.skipped && output.skipped.length > 0) {
console.log(`Skipped invalid indices: ${output.skipped.join(", ")}`);
}
}
});
providersCmd
.command("reviews")
.description(
"Show all known providers with their stored review events and event IDs",
)
.action(async () => {
await ensureDaemonRunning();
const result = await callDaemon("/providers/reviews");
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
providers: Array<{
index: number;
baseUrl: string;
disabled: boolean;
nodePubkeys: string[];
reviewCount: number;
reviews: Array<{
eventId: string;
createdAt: number;
authorPubkey: string | null;
nodePubkey: string | null;
label: string;
isLgtm: boolean;
tags: string[][];
}>;
}>;
unmatchedReviews: Array<{
eventId: string;
createdAt: number;
authorPubkey: string | null;
nodePubkey: string | null;
label: string;
isLgtm: boolean;
tags: string[][];
}>;
totalCount: number;
reviewEventCount: number;
}
| undefined;
if (!output) {
console.log("No provider review data available.");
return;
}
console.log(
`Providers (${output.totalCount} total, ${output.reviewEventCount} stored review events):\n`,
);
for (const provider of output.providers) {
const status = provider.disabled ? "DISABLED" : "enabled ";
console.log(
` [${provider.index}] ${status} ${provider.baseUrl} (${provider.reviewCount} review${provider.reviewCount === 1 ? "" : "s"})`,
);
if (provider.nodePubkeys.length > 0) {
console.log(` node pubkeys: ${provider.nodePubkeys.join(", ")}`);
}
for (const review of provider.reviews) {
const label = review.label || "review";
const created = new Date(review.createdAt * 1000).toISOString();
console.log(` - ${label} eventId: ${review.eventId}`);
console.log(` node: ${review.nodePubkey ?? "(none)"}`);
console.log(` author: ${review.authorPubkey ?? "(none)"}`);
console.log(` at: ${created}`);
}
}
if (output.unmatchedReviews.length > 0) {
console.log(
`\n Unmatched review events (${output.unmatchedReviews.length} — node pubkey not in any known provider):`,
);
for (const review of output.unmatchedReviews) {
const created = new Date(review.createdAt * 1000).toISOString();
console.log(` - eventId: ${review.eventId}`);
console.log(` node: ${review.nodePubkey ?? "(none)"}`);
console.log(` at: ${created}`);
}
}
});
// Cooldowns - providers/models the router is currently skipping
program
.command("cooldowns")
.description(
"List providers and models currently on cooldown (temporarily skipped by the router)",
)
.option("--json", "Print the raw daemon response as JSON", false)
.action(async (options: { json: boolean }) => {
await ensureDaemonRunning();
let result: CommandResponse;
try {
result = await callDaemon("/cooldowns");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A daemon built before this command existed has no /cooldowns route:
// it either 404s or falls through to the "POST only" catch-all.
if (message.includes("404") || message === "Only POST is supported.") {
console.error(
"The running daemon has no /cooldowns endpoint, so it cannot report cooldowns. " +
"Restart it on a newer build (routstrd service restart) and try again.",
);
process.exit(1);
}
throw error;
}
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as CooldownsOutput | undefined;
if (options.json) {
console.log(JSON.stringify(output ?? {}, null, 2));
return;
}
if (!output) {
console.log("No cooldown data returned by the daemon.");
return;
}
console.log(formatCooldowns(output));
});
// Clients - list and manage clients
const clientsCmd = program
.command("clients")
.description("List and manage clients")
.option(
"--manual-refresh",
"Refresh routstr21 models and all client integrations now",
false,
)
.option(
"--disable-automatic-refresh",
"Disable the daemon's scheduled refresh job",
false,
)
.option(
"--enable-automatic-refresh",
"Re-enable the daemon's scheduled refresh job",
false,
)
.action(
async (options: {
manualRefresh: boolean;
disableAutomaticRefresh: boolean;
enableAutomaticRefresh: boolean;
}) => {
if (options.disableAutomaticRefresh && options.enableAutomaticRefresh) {
console.error(
"error: --disable-automatic-refresh and --enable-automatic-refresh are mutually exclusive.",
);
process.exit(1);
}
if (
!options.manualRefresh &&
!options.disableAutomaticRefresh &&
!options.enableAutomaticRefresh
) {
clientsCmd.help({ error: true });
return;
}
if (options.manualRefresh) {
await ensureDaemonRunning();
await refreshModelsAndClientsAction();
}
if (options.disableAutomaticRefresh || options.enableAutomaticRefresh) {
await setAutomaticRefreshAction(options.enableAutomaticRefresh);
}
},
);
clientsCmd
.command("list")
.description("List all clients")
.action(async () => {
await listClientsAction();
});
clientsCmd
.command("delete <id>")
.description("Delete a client by its ID")
.action(async (id: string) => {
await deleteClientAction(id);
});
clientsCmd
.command("add")
.description("Add a new client or set up client integrations")
.option("-n, --name <name>", "Client name")
.option("--opencode", "Set up OpenCode integration")
.option("--openclaw", "Set up OpenClaw integration")
.option("--pi-agent", "Set up Pi Agent integration")
.option("--claude-code", "Set up Claude Code integration")
.option("--hermes", "Set up Hermes integration")
.action(
async (options: {
name?: string;
opencode?: boolean;
openclaw?: boolean;
piAgent?: boolean;
claudeCode?: boolean;
hermes?: boolean;
}) => {
await addClientAction(options);
},
);
// Npubs - manage npubs (admin and user roles)
const npubsCmd = program
.command("npubs")
.description("Manage npubs on the daemon (admin or user roles)");
type NpubEntry = {
npub: string;
name: string | null;
role: string;
};
/** Normalize a user-supplied npub display name: trim whitespace and cap at 64 chars. */
function parseNpubName(raw: string | undefined): { name?: string | null; error?: string } {
if (raw === undefined) return {};
const trimmed = raw.trim();
if ([...trimmed].length > 64) {
return { error: "Invalid name. Maximum length is 64 characters." };
}
return { name: trimmed === "" ? null : trimmed };
}
npubsCmd
.command("list")
.description("List configured npubs with their roles and names")
.action(async () => {
await ensureDaemonRunning();
const config = await loadConfig();
const userNpub = getUserNpub(config);
const result = await callAuth("/npubs");
if (result.error) {
console.log(result.error);
process.exit(1);
}
// Handle both wrapped { output: { npubs } } and direct { npubs } response formats
const data = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs
? result.output
: result;
const npubs = (data as { npubs?: NpubEntry[] } | undefined)?.npubs ?? [];
if (npubs.length === 0) {
console.log("No npubs configured. Run 'routstrd npubs register' to register yourself as the first admin.");
return;
}
console.log(`Npubs (${npubs.length}):`);
let found = false;
for (const entry of npubs) {
const marker = entry.npub === userNpub ? " → you" : "";
if (entry.npub === userNpub) found = true;
const name = entry.name ? ` "${entry.name}"` : "";
console.log(`- ${entry.npub} [${entry.role}]${name}${marker}`);
}
if (userNpub && !found) {
console.log("");
console.log(
"Your npub is not in the npub list. Ask an admin to add your npub:",
);
console.log(` ${userNpub}`);
}
});
npubsCmd
.command("register")
.description("Register yourself as the first admin (only when no admins exist)")
.option("-n, --name <name>", "Display name for this npub (optional)")
.action(async (options: { name?: string }) => {
await ensureDaemonRunning();
const config = await loadConfig();
const userNpub = getUserNpub(config);
if (!userNpub) {
console.error(
"No Nostr identity configured. Run 'routstrd remote <url>' to set one up.",
);
process.exit(1);
}
const result = await callAuth("/npubs");
if (result.error) {
console.log(result.error);
process.exit(1);
}
const data = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs
? result.output
: result;
const npubs = (data as { npubs?: NpubEntry[] } | undefined)?.npubs ?? [];
if (npubs.length > 0) {
console.log(`Admin npubs already configured (${npubs.length}). Ask your admin to add your npub. \n Your npub: ${userNpub}`);
return;
}
const normalized = normalizeNostrPubkey(userNpub);
if (!normalized) {
console.error("Failed to normalize user npub.");
process.exit(1);
}
const name = parseNpubName(options.name);
if (name.error) {
console.error(name.error);
process.exit(1);
}
const body: Record<string, string | null> = { npub: npubFromPubkey(normalized) };
if (name.name !== undefined) body.name = name.name;
const addResult = await callAuth("/npubs", {
method: "POST",
body,
});
if (addResult.error) {
console.log(addResult.error);
process.exit(1);
}
const output = addResult.output as
| { npub?: string; name?: string | null; added?: boolean; error?: string }
| undefined;
if (output?.npub) {
const nameSuffix = output.name ? ` ("${output.name}")` : "";
console.log(`Successfully registered as first admin npub: ${output.npub}${nameSuffix}`);
} else {
console.log(`Successfully registered as first admin npub: ${userNpub}`);
}
});
npubsCmd
.command("add <npub>")
.description("Add a npub (hex pubkey or npub1...). Defaults to 'user' role unless --role is specified.")
.option("-r, --role <role>", "Role for the npub: 'admin' or 'user' (default: 'user')", "user")
.option("-n, --name <name>", "Display name for the npub (optional)")
.action(async (npubArg: string, options: { role: string; name?: string }) => {
await ensureDaemonRunning();
const normalized = normalizeNostrPubkey(npubArg);
if (!normalized) {
console.error("Invalid npub value. Use npub1... or 64-char hex pubkey.");
process.exit(1);
}
if (options.role !== "admin" && options.role !== "user") {
console.error("Invalid role. Expected 'admin' or 'user'.");
process.exit(1);
}
const name = parseNpubName(options.name);
if (name.error) {
console.error(name.error);
process.exit(1);
}
const body: Record<string, string | null> = {
npub: npubFromPubkey(normalized),
role: options.role,
};
if (name.name !== undefined) body.name = name.name;
const result = await callAuth("/npubs", {
method: "POST",
body,
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| { npub?: string; name?: string | null; role?: string; added?: boolean; error?: string }
| undefined;
if (output?.npub) {
const nameSuffix = output.name ? ` ("${output.name}")` : "";
console.log(
`${output.added ? "Added" : "Already configured"} npub: ${output.npub} [${output.role ?? "user"}]${nameSuffix}`,
);
}
});
npubsCmd
.command("update <npub>")
.description("Update the role and/or name of an existing npub (requires admin)")
.option("-r, --role <role>", "New role: 'admin' or 'user'")
.option("-n, --name <name>", "New display name (empty string clears it)")
.action(async (npubArg: string, options: { role?: string; name?: string }) => {
await ensureDaemonRunning();
const normalized = normalizeNostrPubkey(npubArg);
if (!normalized) {
console.error("Invalid npub value. Use npub1... or 64-char hex pubkey.");
process.exit(1);
}
if (options.role !== undefined && options.role !== "admin" && options.role !== "user") {
console.error("Invalid role. Expected 'admin' or 'user'.");
process.exit(1);
}
if (options.role === undefined && options.name === undefined) {
console.error("Provide '--role' and/or '--name' to update.");
process.exit(1);
}
const name = parseNpubName(options.name);
if (name.error) {
console.error(name.error);
process.exit(1);
}
const body: Record<string, string | null> = {
npub: npubFromPubkey(normalized),
};
if (options.role !== undefined) body.role = options.role;
if (name.name !== undefined) body.name = name.name;
const result = await callAuth("/npubs", {
method: "PATCH",
body,
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
// PATCH /npubs returns { npub, pubkey, name, role } at the top level, not wrapped in { output }
const data = (result.output ?? result) as
| { npub?: string; pubkey?: string; name?: string | null; role?: string; error?: string }
| undefined;
if (data?.npub) {
const parts: string[] = [];
if (data.role) parts.push(`role='${data.role}'`);
parts.push(`name=${data.name ? `"${data.name}"` : "(none)"}`);
console.log(`Updated npub ${data.npub} (${parts.join(", ")}).`);
} else {
console.log("Npub not found or update failed.");
}
});
npubsCmd
.command("delete <npub>")
.description("Delete an npub (hex pubkey or npub1...)")
.action(async (npubArg: string) => {
await ensureDaemonRunning();
const normalized = normalizeNostrPubkey(npubArg);
if (!normalized) {
console.error("Invalid npub value. Use npub1... or 64-char hex pubkey.");
process.exit(1);
}
const result = await callAuth(
`/npubs/${encodeURIComponent(npubFromPubkey(normalized))}`,
{
method: "DELETE",
},
);
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| { removed?: boolean; error?: string }
| undefined;
console.log(
output?.removed
? "Removed admin npub."
: "Admin npub was not configured.",
);
});
program
.command("history")
.description("Show wallet transaction history")
.option("-n, --limit <number>", "Number of entries to show", "50")
.option("--offset <number>", "Number of entries to skip", "0")
.option(
"-t, --type <type...>",
"Filter by transaction type (send, receive, mint, melt). Repeatable or comma-separated",
)
.option("-i, --id <id>", "Show a single transaction by its ID")
.option("-v, --verbose", "Show full details instead of the summary line")
.option("--json", "Output raw JSON with token objects (no encoding)")
.action(async (options: {
limit: string;
offset: string;
type?: string[];
id?: string;
verbose: boolean;
json: boolean;
}) => {
await ensureDaemonRunning();
const limit = Math.min(parseInt(options.limit, 10) || 50, 1000);
const offset = parseInt(options.offset, 10) || 0;
// Support both repeated flags (-t send -t mint) and comma-separated
// values (-t send,mint).
const requestedTypes = (options.type ?? [])
.flatMap((value) => value.split(","))
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0);
const unknownTypes = requestedTypes.filter(
(value) => !isHistoryEntryType(value),
);
if (unknownTypes.length > 0) {
console.error(
`Unknown transaction type: ${unknownTypes.join(", ")}. Valid types: ${HISTORY_ENTRY_TYPES.join(", ")}.`,
);
process.exit(1);
}
const query = new URLSearchParams({
offset: String(offset),
limit: String(limit),
});
if (requestedTypes.length > 0) query.set("type", requestedTypes.join(","));
if (options.id) query.set("id", options.id);
const result = await callDaemon(`/wallet/history?${query.toString()}`);
if (result.error) {
console.log(result.error);
process.exit(1);
}
const data = result.output as
| { entries?: Record<string, unknown>[]; offset?: number; limit?: number }
| undefined;
const entries = data?.entries || [];
if (entries.length === 0) {
if (options.id) {
console.log(`No transaction found with ID ${options.id}.`);
} else if (requestedTypes.length > 0) {
console.log(
`No ${requestedTypes.join(", ")} transactions found.`,
);
} else {
console.log("No transaction history yet.");
}
return;
}
if (options.json) {
const jsonOutput = entries.map((e: Record<string, unknown>) => {
const entry = { ...e };
delete entry.encodedToken;
return entry;
});
console.log(JSON.stringify(jsonOutput, null, 2));
return;
}
if (options.verbose) {
for (const entry of entries) {
const e = entry as Record<string, unknown>;
const display: Record<string, unknown> = {};
for (const key of Object.keys(e).sort()) {
display[key] = e[key];
}
if (e.encodedToken && e.token) {
display.token = e.encodedToken;
delete display.encodedToken;
}
console.log(JSON.stringify(display, null, 2));
}
return;
}
const idCol = "ID";
const timeCol = "Date/Time";
const typeCol = "Type";
const statusCol = "Status";
const mintCol = "Mint";
const amtCol = "Amount";
const rows = entries.map((entry: Record<string, unknown>) => {
const id = String(entry.id ?? "");
const time = new Date(Number(entry.createdAt)).toISOString().replace("T", " ").slice(0, 19);
const type = String(entry.type ?? "").toUpperCase();
const status = historyStatus(String(entry.type ?? ""), String(entry.state ?? ""));
const mint = String(entry.mintUrl ?? "");
const unit = String(entry.unit ?? "sat");
const amount = `${entry.amount} ${unit}`;
return { id, time, type, status, mint, amount };
});
const widths = {
id: Math.max(idCol.length, ...rows.map((r) => r.id.length)),
time: Math.max(timeCol.length, ...rows.map((r) => r.time.length)),
type: Math.max(typeCol.length, ...rows.map((r) => r.type.length)),
status: Math.max(statusCol.length, ...rows.map((r) => r.status.length)),
mint: Math.max(mintCol.length, ...rows.map((r) => r.mint.length)),
amount: Math.max(amtCol.length, ...rows.map((r) => r.amount.length)),
};
const pad = (s: string, w: number) => s.padEnd(w);
const sep = Object.values(widths).map((w) => "-".repeat(w)).join(" | ");
// A single transaction lookup prints just its summary line.
if (!options.id) {
console.log(
`${pad(idCol, widths.id)} | ${pad(timeCol, widths.time)} | ${pad(typeCol, widths.type)} | ${pad(statusCol, widths.status)} | ${pad(mintCol, widths.mint)} | ${pad(amtCol, widths.amount)}`,
);
console.log(sep);
}
for (const row of rows) {
console.log(
`${pad(row.id, widths.id)} | ${pad(row.time, widths.time)} | ${pad(row.type, widths.type)} | ${pad(row.status, widths.status)} | ${pad(row.mint, widths.mint)} | ${pad(row.amount, widths.amount)}`,
);
}
});
// Monitor - interactive TUI
program
.command("monitor")
.description("Open interactive TUI for usage monitoring (htop-like)")
.action(async () => {
const { runUsageTui } = await import("./tui/usage/index.ts");
await runUsageTui();
});
program
.command("top")
.description("Open interactive TUI for usage monitoring (alias for monitor)")
.action(async () => {
const { runUsageTui } = await import("./tui/usage/index.ts");
await runUsageTui();
});
program
.command("send <target>")
.description(
"Shortcut: numbers send Cashu, non-numbers pay a Lightning invoice",
)
.option("--mint-url <url>", "Mint URL to use")
.action(async (target: string, options: { mintUrl?: string }) => {
await warnIfWalletRecovering();
if (isPositiveIntegerString(target)) {
await handleDaemonCommand("/wallet/send/cashu", {
method: "POST",
body: {
amount: parsePositiveIntOrExit(target, "amount"),
mintUrl: options.mintUrl,
},
});
return;
}
await handleDaemonCommand("/wallet/send/bolt11", {
method: "POST",
body: {
invoice: target,
mintUrl: options.mintUrl,
},
});
});
program
.command("receive <value>")
.description(
"Shortcut: numbers create a Lightning invoice, non-numbers receive a Cashu token",
)
.option("--mint-url <url>", "Mint URL to use for bolt11 receive")
.action(async (value: string, options: { mintUrl?: string }) => {
await warnIfWalletRecovering();
if (isPositiveIntegerString(value)) {
try {
await ensureDaemonRunning();
const result = await callDaemon("/wallet/receive/bolt11", {
method: "POST",
body: {
amount: parsePositiveIntOrExit(value, "amount"),
mintUrl: options.mintUrl,
},
});
const output = result.output as
| { invoice?: string; operationId?: string; amount?: number; mintUrl?: string }
| undefined;
if (typeof output?.invoice === "string" && output.invoice) {
await printLightningInvoice(output.invoice);
if (typeof output.operationId === "string") {
await waitForMintQuote(output.operationId);
}
return;
}
if (result.output !== undefined) {
console.log(JSON.stringify(result.output, null, 2));
}
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
return;
}
await handleDaemonCommand("/wallet/receive/cashu", {
method: "POST",
body: { token: value },
});
});
const walletCmd = program.command("wallet").description("Wallet operations");
walletCmd
.command("status")
.description("Check wallet status")
.action(async () => {
await handleDaemonCommand("/wallet/status");
});
walletCmd
.command("doctor")
.description("Diagnose conflicting wallets (current routstrd wallet vs legacy cocod)")
.action(async () => {
const target = summarizeWalletDirectory(defaultWalletDir(), "canonical");
const source = summarizeWalletDirectory(legacyCocodDir(), "legacy");
console.log(renderWalletDoctor(target, source));
if (diagnoseWallets(target, source).conflict) process.exit(1);
});
walletCmd
.command("unlock <passphrase>")
.description("Unlock the wallet")
.action(async (passphrase: string) => {
await handleDaemonCommand("/wallet/unlock", {
method: "POST",
body: { passphrase },
});
});
walletCmd
.command("balance")
.description("Get wallet balance")
.action(async () => {
await handleDaemonCommand("/wallet/balance");
});
walletCmd
.command("cleanup")
.description("Clear stuck pending/in-flight wallet operations")
.option("--mint-url <url>", "Only clean up operations for this mint URL")
.option(
"--min-age <hours>",
"Minimum age for reclaiming sends/cancelling melts, in hours (default: 168, one week; expired mint quotes are checked with their mint)",
"168",
)
.option("--dry-run", "Report what would be cleaned without applying changes", false)
.option(
"--force",
"Fail expired mint quotes without confirming UNPAID with the mint (may strand paid quotes)",
false,
)
.option("-y, --yes", "Skip confirmation prompt", false)
.action(
async (options: {
mintUrl?: string;
minAge: string;
dryRun: boolean;
force: boolean;
yes: boolean;
}) => {
const minAgeHours = Number.parseFloat(options.minAge);
if (!Number.isFinite(minAgeHours) || minAgeHours < 0) {
console.error(`Invalid --min-age value: ${options.minAge}`);
process.exit(1);
}
if (!options.dryRun && !options.yes) {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(
options.force
? "WARNING: --force fails expired mint quotes WITHOUT checking the mint and may strand paid sats. It also reclaims old pending sends and cancels prepared melts. Continue? [y/N] "
: "This will fail expired mint quotes confirmed unpaid, reclaim old pending sends, and cancel prepared melts. Continue? [y/N] ",
(value: string) => {
rl.close();
resolve(value.trim().toLowerCase());
},
);
});
if (answer !== "y" && answer !== "yes") {
console.log("Aborted.");
return;
}
}
try {
await ensureDaemonRunning();
const result = await callDaemon("/wallet/cleanup", {
method: "POST",
body: {
mintUrl: options.mintUrl,
minAgeMs: Math.round(minAgeHours * 60 * 60 * 1000),
dryRun: options.dryRun === true,
force: options.force === true,
},
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
dryRun?: boolean;
failedMintQuotes?: number;
mintQuoteCandidates?: number;
leftForRecovery?: number;
reclaimedSends?: number;
cancelledMelts?: number;
skipped?: number;
errors?: Array<{ operationId: string; error: string }>;
}
| undefined;
if (output) {
const prefix = output.dryRun ? "Would clean up:" : "Cleaned up:";
console.log(prefix);
if (output.dryRun) {
console.log(
` Expired mint quote candidates (not checked with mint): ${output.mintQuoteCandidates ?? 0}`,
);
} else {
console.log(` Expired mint quotes failed: ${output.failedMintQuotes ?? 0}`);
}
console.log(
` Expired quotes kept for recovery (paid/issued/unverified): ${output.leftForRecovery ?? 0}`,
);
console.log(` Pending sends reclaimed: ${output.reclaimedSends ?? 0}`);
console.log(
` Prepared melts cancelled: ${output.cancelledMelts ?? 0}`,
);
console.log(
` Skipped (still recent or already terminal): ${output.skipped ?? 0}`,
);
if (output.errors && output.errors.length > 0) {
console.log("\nErrors:");
for (const e of output.errors) {
console.log(` - ${e.operationId}: ${e.error}`);
}
}
}
} catch (error) {
const message = (error as Error).message;
if (
message?.includes("fetch failed") ||
message?.includes("Connection refused")
) {
console.error("Daemon is not running");
process.exit(1);
}
console.error(message);
process.exit(1);
}
},
);
walletCmd
.command("recover")
.description(
"Retry mint quotes using their stored outputs (does not replace rejected outputs)",
)
.option(
"--op <id>",
"Recover this operation id (repeatable; find IDs with routstrd history --json)",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.option(
"--include-failed",
"Also re-open operations coco already gave up on (requires --op)",
false,
)
.option("-y, --yes", "Skip confirmation prompt", false)
.action(
async (options: {
op: string[];
includeFailed: boolean;
yes: boolean;
}) => {
const operationIds = options.op ?? [];
if (options.includeFailed && operationIds.length === 0) {
console.error(
"--include-failed can only target operations named with --op",
);
process.exit(1);
}
if (!options.yes) {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt =
operationIds.length > 0
? `Recover ${operationIds.length} mint quote operation(s)? [y/N] `
: "Check every pending mint quote with its mint and claim any paid sats? [y/N] ";
const answer = await new Promise<string>((resolve) => {
rl.question(prompt, (value: string) => {
rl.close();
resolve(value.trim().toLowerCase());
});
});
if (answer !== "y" && answer !== "yes") {
console.log("Aborted.");
return;
}
}
try {
await ensureDaemonRunning();
const result = await callDaemon("/wallet/recover", {
method: "POST",
body: {
operationIds: operationIds.length > 0 ? operationIds : undefined,
includeFailed: options.includeFailed === true,
},
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
checked?: number;
recovered?: number;
waiting?: number;
terminal?: number;
reopened?: number;
retryable?: number;
busy?: number;
errors?: Array<{ operationId: string; error: string }>;
}
| undefined;
if (output) {
console.log("Mint quote recovery:");
console.log(` Checked with mint: ${output.checked ?? 0}`);
console.log(
` Recovered (paid sats claimed): ${output.recovered ?? 0}`,
);
console.log(` Still unpaid: ${output.waiting ?? 0}`);
console.log(` No longer issuable: ${output.terminal ?? 0}`);
console.log(
` Re-opened failed operations: ${output.reopened ?? 0}`,
);
console.log(` Left for a later run: ${output.retryable ?? 0}`);
console.log(
` Skipped (recovery still running): ${output.busy ?? 0}`,
);
if (output.errors && output.errors.length > 0) {
console.log("\nErrors:");
for (const e of output.errors) {
console.log(` - ${e.operationId}: ${e.error}`);
}
}
}
} catch (error) {
const message = (error as Error).message;
if (
message?.includes("fetch failed") ||
message?.includes("Connection refused")
) {
console.error("Daemon is not running");
process.exit(1);
}
console.error(message);
process.exit(1);
}
},
);
const walletReceiveCmd = walletCmd
.command("receive")
.description("Wallet receive operations");
walletReceiveCmd
.command("cashu <token>")
.description("Receive a Cashu token")
.action(async (token: string) => {
await warnIfWalletRecovering();
await handleDaemonCommand("/wallet/receive/cashu", {
method: "POST",
body: { token },
});
});
walletReceiveCmd
.command("bolt11 <amount>")
.description("Create a Lightning invoice")
.option("--mint-url <url>", "Mint URL to use")
.action(async (amount: string, options: { mintUrl?: string }) => {
await warnIfWalletRecovering();
try {
await ensureDaemonRunning();
const result = await callDaemon("/wallet/receive/bolt11", {
method: "POST",
body: {
amount: parsePositiveIntOrExit(amount, "amount"),
mintUrl: options.mintUrl,
},
});
const output = result.output as
| { invoice?: string; amount?: number; mintUrl?: string }
| undefined;
if (typeof output?.invoice === "string" && output.invoice) {
await printLightningInvoice(output.invoice);
return;
}
if (result.output !== undefined) {
console.log(JSON.stringify(result.output, null, 2));
}
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
});
const walletSendCmd = walletCmd
.command("send")
.description("Wallet send operations");
walletSendCmd
.command("cashu <amount>")
.description("Create a Cashu token to send")
.option("--mint-url <url>", "Mint URL to use")
.action(async (amount: string, options: { mintUrl?: string }) => {
await warnIfWalletRecovering();
await handleDaemonCommand("/wallet/send/cashu", {
method: "POST",
body: {
amount: parsePositiveIntOrExit(amount, "amount"),
mintUrl: options.mintUrl,
},
});
});
walletSendCmd
.command("bolt11 <invoice>")
.description("Pay a Lightning invoice")
.option("--mint-url <url>", "Mint URL to use")
.action(async (invoice: string, options: { mintUrl?: string }) => {
await warnIfWalletRecovering();
await handleDaemonCommand("/wallet/send/bolt11", {
method: "POST",
body: {
invoice,
mintUrl: options.mintUrl,
},
});
});
const walletMintsCmd = walletCmd
.command("mints")
.description("Wallet mint operations");
walletMintsCmd
.command("list")
.description("List configured wallet mints")
.action(async () => {
await handleDaemonCommand("/wallet/mints");
});
walletMintsCmd
.command("add <url>")
.description("Add a wallet mint")
.action(async (url: string) => {
await handleDaemonCommand("/wallet/mints", {
method: "POST",
body: { url },
});
});
walletMintsCmd
.command("info <url>")
.description("Get wallet mint info")
.action(async (url: string) => {
await handleDaemonCommand("/wallet/mints/info", {
method: "POST",
body: { url },
});
});
walletMintsCmd
.command("set-default <url>")
.description("Set the default mint for wallet operations")
.action(async (url: string) => {
await handleDaemonCommand("/wallet/mints/default", {
method: "POST",
body: { url },
});
});
const walletNpcCmd = walletCmd
.command("npc")
.description("NPC (npubx.cash) Lightning address operations");
walletNpcCmd
.command("address")
.description("Show this wallet's NPC Lightning address")
.action(async () => {
await handleDaemonCommand("/wallet/npc/address");
});
walletNpcCmd
.command("username <name>")
.description(
"Claim an NPC username (pays the claim fee from the wallet with --confirm)",
)
.option("--confirm", "Confirm payment of the username claim fee")
.action(async (name: string, options: { confirm?: boolean }) => {
await handleDaemonCommand("/wallet/npc/username", {
method: "POST",
body: { username: name, confirm: options.confirm === true },
});
});
walletNpcCmd
.command("sync")
.description("Manually sync paid NPC quotes into the wallet")
.action(async () => {
await handleDaemonCommand("/wallet/npc/sync", { method: "POST" });
});
// ── NWC (Nostr Wallet Connect) commands ─────────────────────────
const nwcCmd = program
.command("nwc")
.description("Manage NWC (Nostr Wallet Connect) integration");
nwcCmd
.command("connect")
.description("Connect to a Lightning wallet via NWC")
.argument("[connection-string]", "NWC connection string (nostr+walletconnect://...)")
.action(async (connectionString?: string) => {
if (!connectionString) {
// Interactive mode: prompt for connection string
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
connectionString = await new Promise<string>((resolve) => {
rl.question("Paste your NWC connection string: ", (answer: string) => {
rl.close();
resolve(answer.trim());
});
});
}
// Quick validation: must be nostr+walletconnect:// with a 64-char hex pubkey
if (!/^nostr\+walletconnect:\/\/[0-9a-fA-F]{64}\?relay=/.test(connectionString)) {
console.error("Invalid NWC connection string: expected nostr+walletconnect://<64-char-hex>?relay=...");
process.exit(1);
}
await handleDaemonCommand("/nwc/connect", {
method: "POST",
body: { connectionString },
});
});
nwcCmd
.command("disconnect")
.description("Disconnect from NWC wallet")
.action(async () => {
await handleDaemonCommand("/nwc/disconnect", {
method: "POST",
});
});
nwcCmd
.command("status")
.description("Show NWC connection status and wallet info")
.action(async () => {
await handleDaemonCommand("/nwc/status");
});
nwcCmd
.command("fund <amount>")
.description("Manually fund the Cashu wallet from the connected NWC wallet")
.action(async (amount: string) => {
const parsedAmount = parsePositiveIntOrExit(amount, "amount");
await handleDaemonCommand("/nwc/fund", {
method: "POST",
body: { amount: parsedAmount },
});
});
const autoRefillCmd = nwcCmd
.command("auto-refill")
.description("Manage automatic wallet refill from NWC");
autoRefillCmd
.command("on")
.description("Enable auto-refill")
.option(
"--threshold <sats>",
"Refill when Cashu balance drops below this many sats",
"500",
)
.option("--amount <sats>", "Refill this many sats at a time", "1000")
.option(
"--cooldown <seconds>",
"Minimum time between refills in seconds",
"300",
)
.action(async (options: { threshold: string; amount: string; cooldown: string }) => {
const threshold = parsePositiveIntOrExit(options.threshold, "threshold");
const amount = parsePositiveIntOrExit(options.amount, "amount");
const cooldownSec = parsePositiveIntOrExit(options.cooldown, "cooldown");
await handleDaemonCommand("/nwc/auto-refill", {
method: "POST",
body: {
enabled: true,
threshold,
amount,
cooldownMs: cooldownSec * 1000,
},
});
});
autoRefillCmd
.command("off")
.description("Disable auto-refill")
.action(async () => {
await handleDaemonCommand("/nwc/auto-refill", {
method: "POST",
body: { enabled: false },
});
});
// Stop
program
.command("stop")
.description("Stop the background daemon")
.action(async () => {
if (!(await isDaemonRunning())) {
console.log("Daemon was not running.");
return;
}
await callDaemon("/stop", { method: "POST" });
// The daemon exits only after ongoing requests finish; wait for it so
// the wallet lock is actually free when this command returns.
try {
await waitForDaemonToExit({ pidFilePath: walletPidPath() });
} catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
console.log("Daemon stopped.");
});
// Service - PM2 management
const serviceCmd = program
.command("service")
.description("Manage routstrd as a system service using PM2");
serviceCmd
.command("install")
.description("Install and start routstrd using PM2 for persistence")
.action(async () => {
await requireLocalDaemon();
// 1. Check if PM2 is installed
try {
execSync("pm2 -v", { stdio: "ignore" });
} catch (e) {
if (isStandaloneExecutable()) {
console.error(
"PM2 is optional and is not bundled with routstrd. Install PM2 separately before using 'routstrd service install'.",
);
process.exit(1);
}
console.log("PM2 not found. Installing PM2 globally with bun...");
try {
execSync("bun install -g pm2", { stdio: "inherit" });
} catch (err) {
console.error(
"Failed to install PM2. Please install it manually: bun install -g pm2",
);
process.exit(1);
}
}
console.log("Starting routstrd via PM2...");
try {
await stopLegacyCocod();
const proc = Bun.spawn(["pm2", ...pm2DaemonArgs()], {
stdout: "inherit",
stderr: "inherit",
});
if ((await proc.exited) !== 0) throw new Error("PM2 exited with an error");
console.log("\n✅ routstrd is now managed by PM2.");
console.log("\nTo ensure it starts on system reboot, run:");
console.log(" pm2 startup");
console.log(" pm2 save");
console.log("\nTo view logs:");
console.log(" pm2 logs routstrd");
} catch (err) {
console.error("Failed to start routstrd via PM2.");
process.exit(1);
}
});
serviceCmd
.command("uninstall")
.description("Stop and remove routstrd from PM2")
.action(() => {
try {
execSync("pm2 delete routstrd", { stdio: "inherit" });
console.log("✅ routstrd service removed from PM2.");
} catch (e) {
console.error(
"Failed to remove service. It might not be running in PM2.",
);
}
});
serviceCmd
.command("logs")
.description("View PM2 logs for routstrd")
.action(() => {
try {
execSync("pm2 logs routstrd", { stdio: "inherit" });
} catch (e) {
// Ignored
}
});
// Restart
program
.command("restart")
.description("Restart the background daemon")
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Bind address (default: 127.0.0.1)")
.option("-p, --provider <provider>", "Default provider to use")
.action(async (options: { port?: string; host?: string; provider?: string }) => {
await requireLocalDaemon();
const config = await loadConfig();
const wasRunning = await isDaemonRunning();
if (wasRunning) {
console.log("Stopping daemon...");
await callDaemon("/stop", { method: "POST" });
// Wait for the old daemon to fully exit so the wallet lock is free
// before a new daemon is spawned.
try {
await waitForDaemonToExit({ pidFilePath: walletPidPath() });
} catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
console.log("Daemon stopped.");
} else {
console.log("Daemon was not running.");
}
await stopLegacyCocod();
console.log("Starting daemon...");
await startDaemon({
port: options.port || String(config.port || 8008),
host: options.host || config.host || undefined,
provider: options.provider,
});
console.log("Daemon restarted.");
});
// Mode
program
.command("mode")
.description("Set the client mode (lazyrefund/apikeys or xcashu)")
.action(async () => {
await requireLocalDaemon();
const config = await loadConfig();
const currentMode = config.mode || "apikeys";
console.log("Select client mode:");
console.log(
" 1) lazyrefund/apikeys - Pseudonymous accounts are kept with the Routstr nodes and are refunded after 5 mins if not used.",
);
console.log(
" 2) xcashu (coming soon) - Balances are never kept with the nodes, all balances are refunded in response.",
);
console.log(`\nCurrent mode: ${currentMode}`);
const modes: Array<"apikeys" | "xcashu"> = ["apikeys", "xcashu"];
const selectedIndex = await new Promise<number>((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question("\nEnter choice (1-2): ", (answer: string) => {
rl.close();
const num = parseInt(answer, 10);
resolve(Number.isFinite(num) && num >= 1 && num <= 2 ? num - 1 : 0);
});
});
const selectedMode = modes[selectedIndex];
if (selectedMode === "xcashu") {
console.log(
"\nxcashu mode is coming soon! Only lazyrefund/apikeys is available at this time.",
);
return;
}
if (selectedMode === currentMode) {
console.log(`Mode is already set to '${selectedMode}'. No changes made.`);
return;
}
// Update config
const updatedConfig: RoutstrdConfig = {
...config,
mode: selectedMode,
};
saveDaemonConfig(updatedConfig);
console.log(`Mode set to '${selectedMode}'. Restarting daemon...`);
// Restart daemon
const wasRunning = await isDaemonRunning();
if (wasRunning) {
console.log("Stopping daemon...");
await callDaemon("/stop", { method: "POST" });
// Wait for the old daemon to fully exit so the wallet lock is free
// before a new daemon is spawned.
try {
await waitForDaemonToExit({ pidFilePath: walletPidPath() });
} catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
console.log("Daemon stopped.");
}
console.log("Starting daemon...");
await startDaemon({
port: String(config.port || 8008),
host: config.host || undefined,
provider: config.provider || undefined,
});
console.log(`Daemon restarted with mode '${selectedMode}'.`);
});
// Logs
function getLogFileForDate(logsDir: string, date: Date = new Date()): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${logsDir}/${year}-${month}-${day}.log`;
}
function readAllLines(file: string): string[] {
const content = readFileSync(file, "utf8");
const allLines = content.replace(/\r\n/g, "\n").split("\n");
if (allLines.at(-1) === "") allLines.pop();
return allLines;
}
function readLastLines(file: string, lines: number): string {
return readAllLines(file).slice(-lines).join("\n");
}
async function followLogFile(file: string, lines: number): Promise<void> {
const initial = readLastLines(file, lines);
if (initial) {
console.log(initial);
}
let position = statSync(file).size;
while (true) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (!existsSync(file)) {
continue;
}
const size = statSync(file).size;
if (size < position) {
position = 0;
}
if (size === position) {
continue;
}
const text = await Bun.file(file).slice(position, size).text();
process.stdout.write(text);
position = size;
}
}
type StructuredLogLine = {
timestamp?: string;
level?: string;
requestId?: string;
modelId?: string;
isRouting: boolean;
};
export function parseStructuredLogLine(line: string): StructuredLogLine {
const out: StructuredLogLine = { isRouting: false };
const head = line.match(/^\[([^\]]+)\]\s+\[([^\]]+)\]/);
if (head) {
out.timestamp = head[1];
out.level = head[2];
}
// Request ids are hex (no colons). Stop at the first ':' so legacy
// colon-joined child tags like `[req:2c30d5ed:RoutstrClient]` still yield
// the bare request id.
const req = line.match(/\[req:([^\]\:]+)/);
if (req) {
out.requestId = req[1]!.trim();
}
// Prefer an explicit `[model:...]` tag; fall back to the modelId embedded
// in the SDK's `generic request pricing input {"modelId":"..."}` line.
const model = line.match(/\[model:([^\]\:]+)\]/);
if (model) {
out.modelId = model[1]!.trim();
} else {
const jsonModel = line.match(/"modelId"\s*:\s*"([^"]+)"/);
if (jsonModel) {
out.modelId = jsonModel[1];
}
}
out.isRouting = !!out.requestId && line.includes("Routing request with path");
return out;
}
type RequestSummary = {
requestId: string;
modelId: string;
timestamp?: string;
};
/**
* Scan log lines newest-first and collect one record per request, keyed off
* the "Routing request with path" line (the canonical per-request entry).
* The model is enriched in a second pass from any line belonging to the
* request (the SDK pricing line embeds modelId even in legacy logs).
*/
export function collectRecentRequestsFromLines(
lines: string[],
limit: number,
): RequestSummary[] {
const result: RequestSummary[] = [];
const seen = new Set<string>();
for (let i = lines.length - 1; i >= 0; i--) {
const parsed = parseStructuredLogLine(lines[i]!);
if (!parsed.isRouting || !parsed.requestId) continue;
if (seen.has(parsed.requestId)) continue;
seen.add(parsed.requestId);
result.push({
requestId: parsed.requestId,
modelId: parsed.modelId ?? "unknown",
timestamp: parsed.timestamp,
});
if (result.length >= limit) break;
}
// Second pass: fill in the model for requests whose routing line predates
// the `[model:...]` tag but which logged `{"modelId":"..."}` later on.
const byRequestId = new Map(result.map((r) => [r.requestId, r]));
for (const line of lines) {
const parsed = parseStructuredLogLine(line);
if (!parsed.requestId || !parsed.modelId) continue;
const record = byRequestId.get(parsed.requestId);
if (record && record.modelId === "unknown") {
record.modelId = parsed.modelId;
}
}
return result;
}
function collectRecentRequests(
files: string[],
limit: number,
): RequestSummary[] {
const result: RequestSummary[] = [];
const seen = new Set<string>();
// Files are passed oldest-first (yesterday, today); scan newest-first.
for (const file of [...files].reverse()) {
if (!existsSync(file)) continue;
const remaining = limit - result.length;
if (remaining <= 0) break;
for (const request of collectRecentRequestsFromLines(
readAllLines(file),
remaining,
)) {
if (seen.has(request.requestId)) continue;
seen.add(request.requestId);
result.push(request);
if (result.length >= limit) return result;
}
}
return result;
}
function filterLinesByRequestId(
files: string[],
requestId: string,
): string[] {
const matches: string[] = [];
for (const file of files) {
if (!existsSync(file)) continue;
for (const line of readAllLines(file)) {
if (parseStructuredLogLine(line).requestId === requestId) {
matches.push(line);
}
}
}
return matches;
}
function parseLinesOption(value: string): number {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 50;
}
program
.command("logs")
.description("View daemon logs")
.option("-f, --follow", "Follow log output", false)
.option("-c, --coco", "Show Cashu wallet-engine (coco) logs instead of daemon logs", false)
.option("-n, --lines <number>", "Number of lines to show", "50")
.option("-r, --recent", "List recent request ids with their model", false)
.option("-i, --request-id <id>", "Only show log lines for a specific request id")
.action(
async (options: {
follow: boolean;
lines: string;
coco: boolean;
recent: boolean;
requestId?: string;
}) => {
await requireLocalDaemon();
if (options.recent && options.requestId) {
console.error(
"Cannot combine --recent (-r) with --request-id (-i).",
);
process.exit(1);
}
const logsDir = options.coco ? COCO_LOGS_DIR : LOGS_DIR;
const todayFile = getLogFileForDate(logsDir);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayFile = getLogFileForDate(logsDir, yesterday);
if (!existsSync(todayFile) && !existsSync(yesterdayFile)) {
console.log("No log files found. Daemon may not have started yet.");
console.log(`Logs directory: ${logsDir}`);
process.exit(1);
}
const lines = parseLinesOption(options.lines);
const logFiles = [yesterdayFile, todayFile].filter(
(file, index, files) =>
existsSync(file) && files.indexOf(file) === index,
);
if (options.recent) {
const requests = collectRecentRequests(logFiles, lines);
if (requests.length === 0) {
console.log("No requests found in recent logs.");
return;
}
console.log("Recent requests (newest first):");
for (const request of requests) {
const when = request.timestamp ? ` ${request.timestamp}` : "";
console.log(` ${request.requestId} ${request.modelId}${when}`);
}
return;
}
if (options.requestId) {
const matches = filterLinesByRequestId(logFiles, options.requestId);
if (matches.length === 0) {
console.log(
`No log lines found for request '${options.requestId}'.`,
);
return;
}
for (const line of matches) {
console.log(line);
}
return;
}
if (options.follow) {
if (existsSync(todayFile)) {
await followLogFile(todayFile, lines);
} else {
console.log("No log file for today to follow.");
}
return;
}
for (const file of logFiles) {
if (logFiles.length > 1) {
console.log(`==> ${file} <==`);
}
const output = readLastLines(file, lines);
if (output) {
console.log(output);
}
}
},
);
export function cli(args: string[]) {
return program.parseAsync(args);
}