Merge pull request #21 from Routstr/clients-refactor

Clients refactor
This commit is contained in:
redshift
2026-04-30 06:39:42 +00:00
committed by GitHub
6 changed files with 314 additions and 262 deletions
+38 -153
View File
@@ -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 <id>")
.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 <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);
},
);
+8 -27
View File
@@ -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<void> {
@@ -37,7 +36,8 @@ async function main(): Promise<void> {
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<void> {
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<void> {
// 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();
});
});
}
+25 -4
View File
@@ -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<any[]>,
config: RoutstrdConfig,
label: string = "Scheduled",
): Promise<void> {
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<string> {
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,
);
+1 -1
View File
@@ -88,7 +88,7 @@ export async function fetchUsage(limit = 10000): Promise<UsageStats | null> {
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))
+241 -3
View File
@@ -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<ClientEntry[]> {
const config = await loadConfig();
const result = await callDaemon("/clients");
const clients = (
result.output as
@@ -56,11 +106,199 @@ export async function getClientsList(): Promise<ClientEntry[]> {
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<void> {
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<void> {
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<void> {
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 <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);
}
}
+1 -74
View File
@@ -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<RoutstrdConfig> {
try {
if (existsSync(CONFIG_FILE)) {
@@ -160,17 +98,6 @@ export async function isDaemonRunning(): Promise<boolean> {
}
}
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);
}
}
}