diff --git a/src/cli.ts b/src/cli.ts index 87c445d..1cc353d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,11 +8,13 @@ import { loadConfig, getDaemonBaseUrl, getNpubSuffix, - addDaemonClient, - ensureDaemonClient, getUserNpub, } from "./utils/daemon-client"; -import { getClientsList } from "./utils/clients"; +import { + listClientsAction, + deleteClientAction, + addClientAction, +} from "./utils/clients"; import { existsSync, mkdirSync } from "fs"; import { execSync } from "child_process"; import { @@ -24,11 +26,8 @@ import { type RoutstrdConfig, } from "./utils/config"; import { logger } from "./utils/logger"; -import { - setupIntegration, - CLIENT_CONFIGS, - CLIENT_INTEGRATIONS, -} from "./integrations"; +import { setupIntegration, runIntegrationsForClients } from "./integrations"; +import { getClientsList } from "./utils/clients"; import * as QRCode from "qrcode"; import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98"; import { generateSecretKey, nip19 } from "nostr-tools"; @@ -487,6 +486,34 @@ program await handleDaemonCommand("/ping"); }); +// Refresh - refresh models and integrations +program + .command("refresh") + .description("Refresh routstr21 models and client integrations") + .action(async () => { + await ensureDaemonRunning(); + const config = await loadConfig(); + + // Refresh models via daemon API + console.log("Refreshing routstr21 models..."); + const result = await callDaemon("/v1/models?refresh=true"); + if (result.error) { + console.log(`Model refresh failed: ${result.error}`); + process.exit(1); + } + console.log("Models refreshed."); + + // Refresh integrations for all clients + const clients = await getClientsList(); + if (clients.length > 0) { + console.log(`Refreshing ${clients.length} client integration(s)...`); + await runIntegrationsForClients(clients, config); + console.log("Client integrations refreshed."); + } else { + console.log("No clients to refresh."); + } + }); + // Models - list routstr21 models program .command("models") @@ -776,90 +803,14 @@ clientsCmd .command("list") .description("List all clients") .action(async () => { - await ensureDaemonRunning(); - - const config = await loadConfig(); - const suffix = getNpubSuffix(config); - - const entries = await getClientsList(); - - let clients = entries.map((c) => ({ - id: c.clientId, - name: c.name, - apiKey: c.apiKey, - createdAt: c.createdAt, - lastUsed: c.lastUsed, - })); - - if (suffix) { - const suffixStr = `_${suffix}`; - clients = clients.filter( - (c) => c.name.endsWith(suffixStr) || c.id.endsWith(suffixStr), - ); - clients = clients.map((c) => ({ - ...c, - name: c.name.endsWith(suffixStr) - ? c.name.slice(0, -suffixStr.length) - : c.name, - id: c.id.endsWith(suffixStr) ? c.id.slice(0, -suffixStr.length) : c.id, - })); - } - - if (clients.length === 0) { - console.log("No clients found."); - return; - } - - console.log(`Clients (${clients.length} total):\n`); - for (const client of clients) { - const createdAt = new Date(client.createdAt).toISOString(); - const lastUsed = client.lastUsed - ? new Date(client.lastUsed).toISOString() - : "never"; - console.log(` ${client.id}`); - console.log(` Name: ${client.name}`); - console.log(` API Key: ${client.apiKey}`); - console.log(` Created: ${createdAt}`); - console.log(""); - } + await listClientsAction(); }); clientsCmd .command("delete ") .description("Delete a client by its ID") .action(async (id: string) => { - await ensureDaemonRunning(); - - const config = await loadConfig(); - const suffix = getNpubSuffix(config); - let resolvedId = id; - if (suffix) { - const suffixStr = `_${suffix}`; - if (!id.endsWith(suffixStr)) { - resolvedId = `${id}${suffixStr}`; - } - } - - const result = await callDaemon("/clients/delete", { - method: "POST", - body: { id: resolvedId }, - }); - - if (result.error) { - console.log(result.error); - process.exit(1); - } - - const output = result.output as - | { - message: string; - id: string; - } - | undefined; - - if (output) { - console.log(output.message); - } + await deleteClientAction(id); }); clientsCmd @@ -878,73 +829,7 @@ clientsCmd piAgent?: boolean; claudeCode?: boolean; }) => { - await ensureDaemonRunning(); - const config = await loadConfig(); - - const integrationKeys: string[] = []; - if (options.opencode) integrationKeys.push("opencode"); - if (options.openclaw) integrationKeys.push("openclaw"); - if (options.piAgent) integrationKeys.push("pi-agent"); - if (options.claudeCode) integrationKeys.push("claude-code"); - - if (integrationKeys.length > 0) { - for (const key of integrationKeys) { - const integrationFn = CLIENT_INTEGRATIONS[key]; - const integrationConfig = CLIENT_CONFIGS[key]; - if (!integrationFn || !integrationConfig) continue; - - try { - const { client, created } = await ensureDaemonClient( - integrationConfig.name, - integrationConfig.clientId, - ); - if (created) { - logger.log(`Created new API key for ${integrationConfig.name}`); - } else { - logger.log(`Using existing API key for ${integrationConfig.name}`); - } - await integrationFn(config, client.apiKey, integrationConfig); - - console.log(`\n ${integrationConfig.name}:`); - console.log(` Client ID: ${client.id}`); - console.log(` API Key: ${client.apiKey}`); - } catch (error) { - logger.error( - `Failed to set up ${integrationConfig.name} integration:`, - error, - ); - continue; - } - } - - console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`); - return; - } - - if (!options.name) { - console.error( - "error: required option '-n, --name ' not specified", - ); - process.exit(1); - } - - const suffix = getNpubSuffix(config); - const resolvedName = suffix ? `${options.name} ${suffix}` : options.name; - - try { - const { message, client } = await addDaemonClient(resolvedName); - - if (message) { - console.log(message); - } - console.log(`\n ID: ${client.id}`); - console.log(` Name: ${client.name}`); - console.log(` API Key: ${client.apiKey}`); - console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`); - } catch (error) { - console.log((error as Error).message); - process.exit(1); - } + await addClientAction(options); }, ); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 2a033a2..9b6de7a 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -20,8 +20,7 @@ import { createWalletAdapter } from "./wallet"; import { createCocodClient } from "./wallet/cocod-client"; import { createModelService } from "./models"; import { createDaemonRequestHandler } from "./http"; -import { runIntegrationsForClients } from "../integrations"; -import { getClientsFromStore } from "../utils/clients"; +import { refreshModelsAndIntegrations } from "../integrations"; import { RoutstrClient } from "@routstr/sdk"; async function main(): Promise { @@ -37,7 +36,8 @@ async function main(): Promise { saveDaemonConfig(updatedConfig); const sqliteDriver = await createBunSqliteDriver(DB_PATH); - const { store } = await createSdkStore({ driver: sqliteDriver }); + const { store, hydrate } = createSdkStore({ driver: sqliteDriver }); + await hydrate; const { Database } = await import("bun:sqlite"); const usageTrackingDriver = createBunSqliteUsageTrackingDriver({ dbPath: DB_PATH, @@ -105,16 +105,7 @@ async function main(): Promise { refreshInterval = setInterval(async () => { logger.log("Running scheduled model refresh..."); try { - await getRoutstr21Models(true); - logger.log("Scheduled model refresh completed successfully."); - - // Refresh integrations for all registered clients - const clientIds = getClientsFromStore(store); - if (clientIds.length > 0) { - logger.log(`Refreshing ${clientIds.length} client integration(s)...`); - await runIntegrationsForClients(clientIds, updatedConfig); - logger.log("Client integrations refreshed."); - } + await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled"); } catch (error) { logger.error("Scheduled model refresh failed:", error); } @@ -207,28 +198,18 @@ async function main(): Promise { // Start the recurring model refresh job after initial bootstrap void ensureProvidersBootstrapped() - .then(() => { + .then(async () => { startModelRefreshJob(); - startRefundJob(); + startRefundJob(); // Run an immediate refresh to populate models right away logger.log("Running initial model refresh..."); - return getRoutstr21Models(true); - }) - .then(async () => { - logger.log("Initial model refresh completed."); - // Refresh integrations for all registered clients after initial bootstrap - const clientIds = getClientsFromStore(store); - if (clientIds.length > 0) { - logger.log(`Refreshing ${clientIds.length} client integration(s)...`); - await runIntegrationsForClients(clientIds, updatedConfig); - logger.log("Client integrations refreshed."); - } + await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial"); }) .catch((error) => { logger.error("Initial model refresh failed:", error); // Still start the jobs even if initial refresh fails startModelRefreshJob(); - startRefundJob(); + startRefundJob(); }); }); } diff --git a/src/integrations/index.ts b/src/integrations/index.ts index fecfc2d..c70cf35 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -1,17 +1,38 @@ import type { RoutstrdConfig } from "../utils/config"; import { logger } from "../utils/logger"; import { - ensureDaemonClient, + addDaemonClient, type DaemonClient, -} from "../utils/daemon-client"; +} from "../utils/clients"; +import { getClientsList } from "../utils/clients"; import { installOpencodeIntegration } from "./opencode"; import { installOpenClawIntegration } from "./openclaw"; import { installPiIntegration } from "./pi"; import { installClaudeCodeIntegration } from "./claudecode"; import type { IntegrationConfig } from "./registry"; -import { CLIENT_CONFIGS } from "./registry"; +import { CLIENT_CONFIGS, runIntegrationsForClients } from "./registry"; export { CLIENT_INTEGRATIONS, CLIENT_CONFIGS, runIntegrationsForClients } from "./registry"; +/** + * Refresh routstr21 models and then run integrations for all registered clients. + * Used both on initial daemon startup and in the recurring scheduled job. + */ +export async function refreshModelsAndIntegrations( + getRoutstr21Models: (force?: boolean) => Promise, + config: RoutstrdConfig, + label: string = "Scheduled", +): Promise { + await getRoutstr21Models(true); + logger.log(`${label} model refresh completed successfully.`); + + const clientIds = await getClientsList(); + if (clientIds.length > 0) { + logger.log(`Refreshing ${clientIds.length} client integration(s)...`); + await runIntegrationsForClients(clientIds, config); + logger.log("Client integrations refreshed."); + } +} + function ask(question: string): Promise { process.stdout.write(question); @@ -69,7 +90,7 @@ export async function setupIntegration( } const integrationConfig = CLIENT_CONFIGS[key]!; - const { client, created } = await ensureDaemonClient( + const { client, created } = await addDaemonClient( integrationConfig.name, integrationConfig.clientId, ); diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index 0a2a763..8aade1f 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -88,7 +88,7 @@ export async function fetchUsage(limit = 10000): Promise { const entries = result.output as UsageTrackingEntry[] | undefined; const entriesArray = Array.isArray(entries) ? entries : []; const suffix = getNpubSuffix(await loadConfig()); - const suffixStr = suffix ? `_${suffix}` : null; + const suffixStr = suffix ? `-${suffix}` : null; const visibleEntries = suffixStr ? entriesArray .filter((entry) => entry.client?.endsWith(suffixStr)) diff --git a/src/utils/clients.ts b/src/utils/clients.ts index 0e9f248..1216dc4 100644 --- a/src/utils/clients.ts +++ b/src/utils/clients.ts @@ -1,4 +1,45 @@ -import { callDaemon } from "./daemon-client"; +import { + callDaemon, + loadConfig, + getDaemonBaseUrl, + ensureDaemonRunning, +} from "./daemon-client"; +import { + parseSecretKey, + npubFromSecretKey, +} from "./nip98"; +import { type RoutstrdConfig } from "./config"; +import { logger } from "./logger"; +import { CLIENT_INTEGRATIONS, CLIENT_CONFIGS } from "../integrations/registry"; + +export function getNpubSuffix(config: RoutstrdConfig): string | null { + if (!config.daemonUrl || !config.nsec) return null; + try { + const secretKey = parseSecretKey(config.nsec); + const npub = npubFromSecretKey(secretKey); + return npub.slice(-7); + } catch { + return null; + } +} + +/** + * Add suffix to a client ID. + */ +export function addSuffixToId(id: string, suffix: string): string { + return `${id}-${suffix}`; +} + +/** + * Remove suffix from a client ID if present. + */ +export function removeSuffixFromId(id: string, suffix: string): string { + const suffixStr = `-${suffix}`; + if (id.endsWith(suffixStr)) { + return id.slice(0, -suffixStr.length); + } + return id; +} export interface ClientEntry { clientId: string; @@ -8,6 +49,14 @@ export interface ClientEntry { lastUsed?: number | null; } +export interface DaemonClient { + id: string; + name: string; + apiKey: string; + createdAt: number; + lastUsed?: number | null; +} + /** * Read the clients list directly from the SDK store. * Use this when running inside the daemon (local mode). @@ -37,6 +86,7 @@ export function getClientsFromStore(store: { getState(): any }): ClientEntry[] { * Use this when running remotely (CLI in remote mode). */ export async function getClientsList(): Promise { + const config = await loadConfig(); const result = await callDaemon("/clients"); const clients = ( result.output as @@ -56,11 +106,199 @@ export async function getClientsList(): Promise { return []; } - return clients.map((c) => ({ - clientId: c.id, + const suffix = config.daemonUrl ? getNpubSuffix(config) : null; + + return clients + .filter((c) => !suffix || c.id.endsWith(`-${suffix}`)) + .map((c) => ({ + clientId: suffix ? removeSuffixFromId(c.id, suffix) : c.id, + name: c.name, + apiKey: c.apiKey, + createdAt: c.createdAt, + lastUsed: c.lastUsed, + })); +} + +export async function addDaemonClient( + name: string, + clientId?: string, +): Promise<{ message?: string; client: DaemonClient; created: boolean }> { + const existingClients = await getClientsList(); + const existing = clientId + ? existingClients.find((c) => c.clientId === clientId) + : existingClients.find((c) => c.name === name); + + if (existing) { + const client: DaemonClient = { + id: existing.clientId, + name: existing.name, + apiKey: existing.apiKey, + createdAt: existing.createdAt, + lastUsed: existing.lastUsed, + }; + return { client, created: false }; + } + + // Derive id from name by replacing spaces with hyphens + const derivedId = name.replace(/\s+/g, "-").toLowerCase(); + + const result = await callDaemon("/clients/add", { + method: "POST", + body: { name, id: derivedId }, + }); + + + const output = result.output as + | { message?: string; client?: DaemonClient } + | undefined; + + if (!output?.client?.apiKey) { + throw new Error(`Daemon did not return an API key for ${name}.`); + } + + return { message: output.message, client: output.client, created: true }; +} + +export async function listClientsAction(): Promise { + await ensureDaemonRunning(); + + const entries = await getClientsList(); + + const clients = entries.map((c) => ({ + id: c.clientId, name: c.name, apiKey: c.apiKey, createdAt: c.createdAt, lastUsed: c.lastUsed, })); + + if (clients.length === 0) { + console.log("No clients found."); + return; + } + + console.log(`Clients (${clients.length} total):\n`); + for (const client of clients) { + const createdAt = new Date(client.createdAt).toISOString(); + const lastUsed = client.lastUsed + ? new Date(client.lastUsed).toISOString() + : "never"; + console.log(` ${client.id}`); + console.log(` Name: ${client.name}`); + console.log(` API Key: ${client.apiKey}`); + console.log(` Created: ${createdAt}`); + console.log(""); + } +} + +export async function deleteClientAction(id: string): Promise { + await ensureDaemonRunning(); + + const config = await loadConfig(); + const suffix = getNpubSuffix(config); + const resolvedId = suffix ? addSuffixToId(id, suffix) : id; + + const result = await callDaemon("/clients/delete", { + method: "POST", + body: { id: resolvedId }, + }); + + if (result.error) { + console.log(result.error); + process.exit(1); + } + + const output = result.output as + | { + message: string; + id: string; + } + | undefined; + + if (output) { + console.log(output.message); + } +} + +export interface AddClientOptions { + name?: string; + opencode?: boolean; + openclaw?: boolean; + piAgent?: boolean; + claudeCode?: boolean; +} + +export async function addClientAction(options: AddClientOptions): Promise { + await ensureDaemonRunning(); + const config = await loadConfig(); + + const integrationKeys: string[] = []; + if (options.opencode) integrationKeys.push("opencode"); + if (options.openclaw) integrationKeys.push("openclaw"); + if (options.piAgent) integrationKeys.push("pi-agent"); + if (options.claudeCode) integrationKeys.push("claude-code"); + + if (integrationKeys.length > 0) { + for (const key of integrationKeys) { + const integrationFn = CLIENT_INTEGRATIONS[key]; + const integrationConfig = CLIENT_CONFIGS[key]; + if (!integrationFn || !integrationConfig) continue; + + try { + const { client, created } = await addDaemonClient( + integrationConfig.name, + integrationConfig.clientId, + ); + if (created) { + logger.log(`Created new API key for ${integrationConfig.name}`); + } else { + logger.log(`Using existing API key for ${integrationConfig.name}`); + } + await integrationFn(config, client.apiKey, integrationConfig); + + console.log(`\n ${integrationConfig.name}:`); + console.log(` Client ID: ${client.id}`); + console.log(` API Key: ${client.apiKey}`); + } catch (error) { + logger.error( + `Failed to set up ${integrationConfig.name} integration:`, + error, + ); + continue; + } + } + + console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`); + return; + } + + if (!options.name) { + console.error( + "error: required option '-n, --name ' not specified", + ); + process.exit(1); + } + + try { + const { message, client, created } = await addDaemonClient(options.name); + + if (!created) { + console.log(`Client '${options.name}' already exists.`); + console.log(`\n ID: ${client.id}`); + console.log(` Name: ${client.name}`); + console.log(` API Key: ${client.apiKey}`); + return; + } + + if (message) { + console.log(message); + } + console.log(`\n ID: ${client.id}`); + console.log(` Name: ${client.name}`); + console.log(` API Key: ${client.apiKey}`); + console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`); + } catch (error) { + console.log((error as Error).message); + process.exit(1); + } } diff --git a/src/utils/daemon-client.ts b/src/utils/daemon-client.ts index 40f83c8..cf73f83 100644 --- a/src/utils/daemon-client.ts +++ b/src/utils/daemon-client.ts @@ -11,74 +11,12 @@ import { npubFromSecretKey, type HttpMethod, } from "./nip98"; -import { getClientsList } from "./clients"; export interface CommandResponse { output?: unknown; error?: string; } -export interface DaemonClient { - id: string; - name: string; - apiKey: string; - createdAt: number; - lastUsed?: number | null; -} - -export async function addDaemonClient( - name: string, -): Promise<{ message?: string; client: DaemonClient }> { - const result = await callDaemon("/clients/add", { - method: "POST", - body: { name }, - }); - - const output = result.output as - | { message?: string; client?: DaemonClient } - | undefined; - - if (!output?.client?.apiKey) { - throw new Error(`Daemon did not return an API key for ${name}.`); - } - - return { message: output.message, client: output.client }; -} - -export async function ensureDaemonClient( - name: string, - clientId: string, -): Promise<{ client: DaemonClient; created: boolean }> { - try { - const { client } = await addDaemonClient(name); - return { client, created: true }; - } catch (error) { - const message = (error as Error).message || ""; - if (!message.includes("already exists")) { - throw error; - } - - const clients = await getClientsList(); - const entry = clients.find((c) => c.clientId === clientId); - - if (!entry?.apiKey) { - throw new Error( - `Client '${clientId}' already exists but could not be fetched from the daemon.`, - ); - } - - const client: DaemonClient = { - id: entry.clientId, - name: entry.name, - apiKey: entry.apiKey, - createdAt: entry.createdAt, - lastUsed: entry.lastUsed, - }; - - return { client, created: false }; - } -} - export async function loadConfig(): Promise { try { if (existsSync(CONFIG_FILE)) { @@ -160,17 +98,6 @@ export async function isDaemonRunning(): Promise { } } -export function getNpubSuffix(config: RoutstrdConfig): string | null { - if (!config.daemonUrl || !config.nsec) return null; - try { - const secretKey = parseSecretKey(config.nsec); - const npub = npubFromSecretKey(secretKey); - return npub.slice(-7); - } catch { - return null; - } -} - export function getUserNpub(config: RoutstrdConfig): string | null { if (!config.nsec) return null; try { @@ -262,4 +189,4 @@ export async function handleDaemonCommand( console.error(message); process.exit(1); } -} +} \ No newline at end of file