From 5e984e3f5eeb727d87339dd5d90c531f9770cd9d Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:50:10 +0200 Subject: [PATCH] routstrd skill. decreased refresh interval to 21 mins. centralized config in registry. --- SKILL.md | 260 +++++++++++++++++++++++++++++++++++ src/daemon/index.ts | 10 +- src/integrations/index.ts | 9 +- src/integrations/openclaw.ts | 42 +++--- src/integrations/opencode.ts | 39 ++---- src/integrations/pi.ts | 40 ++---- src/integrations/registry.ts | 42 +++++- test_chat.sh | 29 ++++ 8 files changed, 383 insertions(+), 88 deletions(-) create mode 100644 SKILL.md create mode 100755 test_chat.sh diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..b4dfd5d --- /dev/null +++ b/SKILL.md @@ -0,0 +1,260 @@ +# routstrd CLI Reference + +Routstr daemon — a Bun-based CLI tool that runs a background HTTP server for the Routstr protocol. It integrates with `cocod` for Cashu wallet management and routes LLM requests to available providers. + +## Quick Start + +```sh +routstrd onboard # Initialize (creates config, sets up cocod) +routstrd start # Start the daemon +routstrd stop # Stop the daemon +``` + +After onboarding, the daemon listens at `http://localhost:8008` and exposes an OpenAI-compatible API. + +## Commands + +### `routstrd onboard` + +Initialize routstrd for the first time: +- Creates `~/.routstrd/` config directory +- Creates `~/.routstrd/config.json` with defaults (port 8008, apikeys mode) +- Installs `cocod` globally via bun if not present +- Runs `cocod init` to set up the wallet +- Starts the daemon and configures integrations + +### `routstrd start` + +Start the background daemon process. + +| Option | Description | +|--------|-------------| +| `--port ` | Port to listen on (default: 8008) | +| `-p, --provider ` | Default provider to use | + +### `routstrd stop` + +Stop the background daemon. + +### `routstrd restart` + +Restart the daemon (stops if running, then starts). + +| Option | Description | +|--------|-------------| +| `--port ` | Port to listen on | +| `-p, --provider ` | Default provider to use | + +### `routstrd status` + +Check daemon and wallet status. Returns JSON with current state. + +### `routstrd ping` + +Test connection to the daemon. + +### `routstrd balance` + +Get wallet and API key balances. Shows per-mint wallet balances, per-key API balances, and a grand total (all in sats). + +### `routstrd models` + +List available routstr21 models (discovered via Nostr). + +| Option | Description | +|--------|-------------| +| `-r, --refresh` | Force refresh models from Nostr | + +### `routstrd usage` + +Show recent usage logs and total sats cost. + +| Option | Default | Description | +|--------|---------|-------------| +| `-n, --limit ` | 10 | Number of recent entries (max 1000) | + +Shows timestamp, model, provider, sats cost, token counts, and request ID for each entry. + +### `routstrd providers` + +List and manage providers (subcommand required). + +#### `routstrd providers list` + +List all providers with their enabled/disabled status. Shows index, status, and base URL. + +``` +Providers (12 total, 2 disabled): + + [0] enabled https://provider1.example.com + [1] enabled https://provider2.example.com + [2] DISABLED https://provider3.example.com +``` + +#### `routstrd providers disable ` + +Disable providers by their index numbers. + +```sh +routstrd providers disable 0 2 5 +``` + +#### `routstrd providers enable ` + +Enable providers by their index numbers. + +```sh +routstrd providers enable 0 2 5 +``` + +### `routstrd clients` + +List and manage API clients (subcommand required). + +#### `routstrd clients list` + +List all registered clients with their ID, name, API key, and creation date. + +#### `routstrd clients add` + +Add a new client. + +| Option | Description | +|--------|-------------| +| `-n, --name ` | **Required.** Client name | + +Returns the client ID and API key for use with the OpenAI-compatible API. + +### `routstrd mode` + +Interactive prompt to set the client mode: +1. **lazyrefund/apikeys** (default) — Pseudonymous accounts kept with Routstr nodes, refunded after 5 mins if unused. +2. **xcashu** (coming soon) — Balances never kept with nodes, all refunded in response. + +Changing mode restarts the daemon automatically. + +### `routstrd monitor` + +Open an interactive TUI (htop-like) for usage monitoring. + +### `routstrd logs` + +View daemon logs. + +| Option | Default | Description | +|--------|---------|-------------| +| `-f, --follow` | false | Follow log output (like `tail -f`) | +| `-n, --lines ` | 50 | Number of lines to show | + +Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`. + +## Wallet Commands + +### `routstrd wallet status` + +Check wallet status. + +### `routstrd wallet unlock ` + +Unlock the wallet with a passphrase. + +### `routstrd wallet balance` + +Get wallet balance. + +### `routstrd wallet receive cashu ` + +Receive funds via a Cashu token. + +### `routstrd wallet receive bolt11 ` + +Create a Lightning invoice to receive funds. Displays a QR code. + +| Option | Description | +|--------|-------------| +| `--mint-url ` | Mint URL to use | + +### `routstrd wallet send cashu ` + +Create a Cashu token to send. + +| Option | Description | +|--------|-------------| +| `--mint-url ` | Mint URL to use | + +### `routstrd wallet send bolt11 ` + +Pay a Lightning invoice. + +| Option | Description | +|--------|-------------| +| `--mint-url ` | Mint URL to use | + +### `routstrd wallet mints list` + +List configured wallet mints. + +### `routstrd wallet mints add ` + +Add a new mint by URL. + +### `routstrd wallet mints info ` + +Get info about a specific mint. + +## Daemon API + +The daemon exposes an OpenAI-compatible HTTP API at `http://localhost:8008`: + +### `GET /health` + +Health check endpoint. + +### `GET /v1/models` + +List available models (OpenAI-compatible). + +### `POST /v1/chat/completions` + +Route a chat completion request. + +```json +{ + "model": "model-id", + "messages": [{ "role": "user", "content": "Hello" }], + "stream": false +} +``` + +## Configuration + +Config file: `~/.routstrd/config.json` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `port` | number | 8008 | Daemon HTTP port | +| `provider` | string\|null | null | Default provider URL | +| `cocodPath` | string\|null | null | Custom path to cocod executable | +| `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) | + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ROUTSTRD_DIR` | `~/.routstrd` | Config directory | +| `ROUTSTRD_SOCKET` | `~/.routstrd/routstrd.sock` | IPC socket path | +| `ROUTSTRD_PID` | `~/.routstrd/routstrd.pid` | PID file path | + +## Pi Integration + +When `routstrd onboard` runs, it automatically configures a `routstr` provider in `pi`'s `models.json` with an OpenAI-compatible base URL and API key. This allows pi (the AI coding agent) to use Routstr providers seamlessly. + +## File Locations + +| Path | Description | +|------|-------------| +| `~/.routstrd/config.json` | Configuration | +| `~/.routstrd/routstr.db` | SQLite database | +| `~/.routstrd/routstrd.sock` | IPC socket | +| `~/.routstrd/routstrd.pid` | PID file | +| `~/.routstrd/logs/YYYY-MM-DD.log` | Daily log files | diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 8a0dce0..b03d126 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -90,7 +90,7 @@ async function main(): Promise { // Ignore } - const REFRESH_INTERVAL_MS = 3.5 * 60 * 60 * 1000; // 3.5 hours + const REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins // Recurring job to refresh routstr21 models let refreshInterval: ReturnType | null = null; @@ -110,9 +110,7 @@ async function main(): Promise { const state = store.getState(); const clientIds = state.clientIds || []; if (clientIds.length > 0) { - logger.log( - `Refreshing ${clientIds.length} client integration(s)...`, - ); + logger.log(`Refreshing ${clientIds.length} client integration(s)...`); await runIntegrationsForClients(clientIds, updatedConfig, store); logger.log("Client integrations refreshed."); } @@ -221,9 +219,7 @@ async function main(): Promise { const state = store.getState(); const clientIds = state.clientIds || []; if (clientIds.length > 0) { - logger.log( - `Refreshing ${clientIds.length} client integration(s)...`, - ); + logger.log(`Refreshing ${clientIds.length} client integration(s)...`); await runIntegrationsForClients(clientIds, updatedConfig, store); logger.log("Client integrations refreshed."); } diff --git a/src/integrations/index.ts b/src/integrations/index.ts index 852e6a6..73f6a85 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -4,7 +4,8 @@ import { installOpencodeIntegration } from "./opencode"; import { installOpenClawIntegration } from "./openclaw"; import { installPiIntegration } from "./pi"; import type { SdkStore } from "@routstr/sdk"; -export { CLIENT_INTEGRATIONS, runIntegrationsForClients } from "./registry"; +import { CLIENT_CONFIGS } from "./registry"; +export { CLIENT_INTEGRATIONS, CLIENT_CONFIGS, runIntegrationsForClients } from "./registry"; function ask(question: string): Promise { process.stdout.write(question); @@ -50,17 +51,17 @@ export async function setupIntegration( const choice = parseChoice(answer); if (choice === 1) { - await installOpencodeIntegration(config, store); + await installOpencodeIntegration(config, store, CLIENT_CONFIGS.opencode!); return; } if (choice === 2) { - await installOpenClawIntegration(config, store); + await installOpenClawIntegration(config, store, CLIENT_CONFIGS.openclaw!); return; } if (choice === 3) { - await installPiIntegration(config, store); + await installPiIntegration(config, store, CLIENT_CONFIGS["pi-agent"]!); return; } diff --git a/src/integrations/openclaw.ts b/src/integrations/openclaw.ts index 4560d3a..b16a204 100644 --- a/src/integrations/openclaw.ts +++ b/src/integrations/openclaw.ts @@ -1,23 +1,15 @@ -import { randomBytes } from "crypto"; import { existsSync, mkdirSync } from "fs"; import { readFile, writeFile } from "fs/promises"; -import { dirname, join } from "path"; +import { dirname } from "path"; import type { RoutstrdConfig } from "../utils/config"; import { logger } from "../utils/logger"; import type { SdkStore } from "@routstr/sdk"; +import type { IntegrationConfig, RoutstrModel } from "./registry"; +import { generateApiKey } from "./registry"; -const OPENCLAW_CONFIG_PATH = join(process.env.HOME || "", ".openclaw/openclaw.json"); const OPENCLAW_PROVIDER_ID = "routstr"; -const OPENCLAW_API_BASE = "http://localhost:8008/v1"; const OPENCLAW_DEFAULT_PRIMARY_MODEL = "routstr/minimax-m2.5"; const OPENCLAW_DEFAULT_FALLBACK_MODEL = "routstr/kimi-k2.5"; -const OPENCLAW_CLIENT_ID = "openclaw"; -const OPENCLAW_NAME = "OpenClaw"; - -type RoutstrModel = { - id: string; - name?: string; -}; type OpenClawModelEntry = { id: string; @@ -58,15 +50,13 @@ function toAlias(modelId: string): string { return modelId; } -function generateApiKey(): string { - const bytes = randomBytes(24); - return `sk-${bytes.toString("hex")}`; -} - export async function installOpenClawIntegration( config: RoutstrdConfig, store: SdkStore, + integrationConfig: IntegrationConfig, ): Promise { + const { clientId, name, configPath } = integrationConfig; + logger.log("\nInstalling routstr models in openclaw.json..."); const port = config.port || 8008; @@ -74,33 +64,33 @@ export async function installOpenClawIntegration( // Get or create clientId entry for OpenClaw const state = store.getState(); const existingClient = (state.clientIds || []).find( - (c: { clientId: string }) => c.clientId === OPENCLAW_CLIENT_ID, + (c: { clientId: string }) => c.clientId === clientId, ); let apiKey: string; if (existingClient) { apiKey = existingClient.apiKey; - logger.log(`Using existing API key for ${OPENCLAW_NAME}`); + logger.log(`Using existing API key for ${name}`); } else { apiKey = generateApiKey(); // Add new clientId entry using proper store action store.getState().setClientIds((prev) => [ ...(prev || []), { - clientId: OPENCLAW_CLIENT_ID, - name: OPENCLAW_NAME, + clientId, + name, apiKey, createdAt: Date.now(), }, ]); - logger.log(`Created new API key for ${OPENCLAW_NAME}`); + logger.log(`Created new API key for ${name}`); } let openclawConfig: OpenClawConfig = {}; try { - if (existsSync(OPENCLAW_CONFIG_PATH)) { - const content = await readFile(OPENCLAW_CONFIG_PATH, "utf-8"); + if (existsSync(configPath)) { + const content = await readFile(configPath, "utf-8"); openclawConfig = JSON.parse(content) as OpenClawConfig; } } catch { @@ -121,7 +111,7 @@ export async function installOpenClawIntegration( } try { - mkdirSync(dirname(OPENCLAW_CONFIG_PATH), { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); const response = await fetch(`http://localhost:${port}/models`); const data = await response.json() as { output?: { models: RoutstrModel[] } }; @@ -139,7 +129,7 @@ export async function installOpenClawIntegration( })); openclawConfig.models.providers[OPENCLAW_PROVIDER_ID] = { - baseUrl: OPENCLAW_API_BASE, + baseUrl: `http://localhost:${port}/v1`, apiKey, api: "openai-completions", models: providerModels, @@ -169,7 +159,7 @@ export async function installOpenClawIntegration( // } // openclawConfig.agents.defaults.models = aliasMap; - await writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify(openclawConfig, null, 2)); + await writeFile(configPath, JSON.stringify(openclawConfig, null, 2)); logger.log(`Added "${OPENCLAW_PROVIDER_ID}" provider with ${models.length} models to openclaw.json`); } catch (error) { logger.error("Failed to install models in openclaw.json:", error); diff --git a/src/integrations/opencode.ts b/src/integrations/opencode.ts index 0add518..325becf 100644 --- a/src/integrations/opencode.ts +++ b/src/integrations/opencode.ts @@ -1,30 +1,21 @@ -import { randomBytes } from "crypto"; import { existsSync, mkdirSync } from "fs"; import { readFile, writeFile } from "fs/promises"; -import { dirname, join } from "path"; +import { dirname } from "path"; import type { RoutstrdConfig } from "../utils/config"; import { logger } from "../utils/logger"; import type { SdkStore } from "@routstr/sdk"; +import type { IntegrationConfig, RoutstrModel } from "./registry"; +import { generateApiKey } from "./registry"; -const OPENCODE_CONFIG_PATH = join(process.env.HOME || "", ".config/opencode/opencode.json"); const OPENCODE_SMALL_MODEL = "routstr/minimax-m2.5"; -const OPENCODE_CLIENT_ID = "opencode"; -const OPENCODE_NAME = "OpenCode"; - -type RoutstrModel = { - id: string; - name?: string; -}; - -function generateApiKey(): string { - const bytes = randomBytes(24); - return `sk-${bytes.toString("hex")}`; -} export async function installOpencodeIntegration( config: RoutstrdConfig, store: SdkStore, + integrationConfig: IntegrationConfig, ): Promise { + const { clientId, name, configPath } = integrationConfig; + logger.log("\nInstalling routstr models in opencode.json..."); const port = config.port || 8008; @@ -32,26 +23,26 @@ export async function installOpencodeIntegration( // Get or create clientId entry for OpenCode const state = store.getState(); const existingClient = (state.clientIds || []).find( - (c: { clientId: string }) => c.clientId === OPENCODE_CLIENT_ID, + (c: { clientId: string }) => c.clientId === clientId, ); let apiKey: string; if (existingClient) { apiKey = existingClient.apiKey; - logger.log(`Using existing API key for ${OPENCODE_NAME}`); + logger.log(`Using existing API key for ${name}`); } else { apiKey = generateApiKey(); // Add new clientId entry using proper store action store.getState().setClientIds((prev) => [ ...(prev || []), { - clientId: OPENCODE_CLIENT_ID, - name: OPENCODE_NAME, + clientId, + name, apiKey, createdAt: Date.now(), }, ]); - logger.log(`Created new API key for ${OPENCODE_NAME}`); + logger.log(`Created new API key for ${name}`); } let opencodeConfig: { @@ -69,8 +60,8 @@ export async function installOpencodeIntegration( }; try { - if (existsSync(OPENCODE_CONFIG_PATH)) { - const content = await readFile(OPENCODE_CONFIG_PATH, "utf-8"); + if (existsSync(configPath)) { + const content = await readFile(configPath, "utf-8"); opencodeConfig = JSON.parse(content); } else { opencodeConfig = { provider: {} }; @@ -84,7 +75,7 @@ export async function installOpencodeIntegration( } try { - mkdirSync(dirname(OPENCODE_CONFIG_PATH), { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); const response = await fetch(`http://localhost:${port}/models`); const data = await response.json() as { output?: { models: RoutstrModel[] } }; @@ -112,7 +103,7 @@ export async function installOpencodeIntegration( }; opencodeConfig.small_model = OPENCODE_SMALL_MODEL; - await writeFile(OPENCODE_CONFIG_PATH, JSON.stringify(opencodeConfig, null, 2)); + await writeFile(configPath, JSON.stringify(opencodeConfig, null, 2)); logger.log(`Added "routstr" provider with ${models.length} models to opencode.json`); } catch (error) { logger.error("Failed to install models in opencode.json:", error); diff --git a/src/integrations/pi.ts b/src/integrations/pi.ts index 4d03fed..2823010 100644 --- a/src/integrations/pi.ts +++ b/src/integrations/pi.ts @@ -1,19 +1,11 @@ -import { randomBytes } from "crypto"; import { existsSync, mkdirSync } from "fs"; import { readFile, writeFile } from "fs/promises"; -import { dirname, join } from "path"; +import { dirname } from "path"; import type { RoutstrdConfig } from "../utils/config"; import { logger } from "../utils/logger"; import type { SdkStore } from "@routstr/sdk"; - -const PI_CONFIG_PATH = join(process.env.HOME || "", ".pi/agent/models.json"); -const PI_CLIENT_ID = "pi-agent"; -const PI_NAME = "Pi Agent"; - -type RoutstrModel = { - id: string; - name?: string; -}; +import type { IntegrationConfig, RoutstrModel } from "./registry"; +import { generateApiKey } from "./registry"; type PiModelEntry = { id: string; @@ -30,15 +22,13 @@ type PiConfig = { providers?: Record; }; -function generateApiKey(): string { - const bytes = randomBytes(24); - return `sk-${bytes.toString("hex")}`; -} - export async function installPiIntegration( config: RoutstrdConfig, store: SdkStore, + integrationConfig: IntegrationConfig, ): Promise { + const { clientId, name, configPath } = integrationConfig; + logger.log("\nInstalling routstr models in pi models.json..."); const port = config.port || 8008; @@ -47,33 +37,33 @@ export async function installPiIntegration( // Get or create clientId entry for Pi Agent const state = store.getState(); const existingClient = (state.clientIds || []).find( - (c: { clientId: string }) => c.clientId === PI_CLIENT_ID, + (c: { clientId: string }) => c.clientId === clientId, ); let apiKey: string; if (existingClient) { apiKey = existingClient.apiKey; - logger.log(`Using existing API key for ${PI_NAME}`); + logger.log(`Using existing API key for ${name}`); } else { apiKey = generateApiKey(); // Add new clientId entry using proper store action store.getState().setClientIds((prev) => [ ...(prev || []), { - clientId: PI_CLIENT_ID, - name: PI_NAME, + clientId, + name, apiKey, createdAt: Date.now(), }, ]); - logger.log(`Created new API key for ${PI_NAME}`); + logger.log(`Created new API key for ${name}`); } let piConfig: PiConfig = {}; try { - if (existsSync(PI_CONFIG_PATH)) { - const content = await readFile(PI_CONFIG_PATH, "utf-8"); + if (existsSync(configPath)) { + const content = await readFile(configPath, "utf-8"); piConfig = JSON.parse(content) as PiConfig; } } catch { @@ -86,7 +76,7 @@ export async function installPiIntegration( try { // Ensure directory exists - mkdirSync(dirname(PI_CONFIG_PATH), { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); const response = await fetch(`http://localhost:${port}/models`); const data = await response.json() as { output?: { models: RoutstrModel[] } }; @@ -108,7 +98,7 @@ export async function installPiIntegration( models: providerModels, }; - await writeFile(PI_CONFIG_PATH, JSON.stringify(piConfig, null, 2)); + await writeFile(configPath, JSON.stringify(piConfig, null, 2)); logger.log(`Added "routstr" provider with ${models.length} models to pi models.json`); } catch (error) { logger.error("Failed to install models in pi models.json:", error); diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index e6e7727..d5456ed 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -1,14 +1,51 @@ +import { randomBytes } from "crypto"; +import { join } from "path"; import type { RoutstrdConfig } from "../utils/config"; import type { SdkStore } from "@routstr/sdk"; import { installOpencodeIntegration } from "./opencode"; import { installPiIntegration } from "./pi"; import { installOpenClawIntegration } from "./openclaw"; +export interface IntegrationConfig { + clientId: string; + name: string; + configPath: string; +} + +export type RoutstrModel = { + id: string; + name?: string; +}; + +export function generateApiKey(): string { + const bytes = randomBytes(24); + return `sk-${bytes.toString("hex")}`; +} + export type IntegrationFn = ( config: RoutstrdConfig, store: SdkStore, + integrationConfig: IntegrationConfig, ) => Promise; +export const CLIENT_CONFIGS: Record = { + opencode: { + clientId: "opencode", + name: "OpenCode", + configPath: join(process.env.HOME || "", ".config/opencode/opencode.json"), + }, + "pi-agent": { + clientId: "pi-agent", + name: "Pi Agent", + configPath: join(process.env.HOME || "", ".pi/agent/models.json"), + }, + openclaw: { + clientId: "openclaw", + name: "OpenClaw", + configPath: join(process.env.HOME || "", ".openclaw/openclaw.json"), + }, +}; + export const CLIENT_INTEGRATIONS: Record = { opencode: installOpencodeIntegration, "pi-agent": installPiIntegration, @@ -22,9 +59,10 @@ export async function runIntegrationsForClients( ): Promise { for (const client of clientIds) { const integrationFn = CLIENT_INTEGRATIONS[client.clientId]; - if (integrationFn) { + const integrationConfig = CLIENT_CONFIGS[client.clientId]; + if (integrationFn && integrationConfig) { try { - await integrationFn(config, store); + await integrationFn(config, store, integrationConfig); } catch (error) { console.error(`Integration failed for ${client.clientId}:`, error); } diff --git a/test_chat.sh b/test_chat.sh new file mode 100755 index 0000000..b6d8a2e --- /dev/null +++ b/test_chat.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +AUTH="Bearer sk-a2d0981bd84ebf214f4bfb861a273873bfe3e10e6af97533" +BASE_URL="http://localhost:8008/v1/chat/completions" + +echo "=== Testing GPT-4 ===" +curl -s "$BASE_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: $AUTH" \ + -d '{ + "model": "gpt-4", + "messages": [ + {"role": "user", "content": "Hello, how are you?"} + ], + "max_tokens": 128 + }' | python3 -m json.tool + +echo "" +echo "=== Testing GLM-4.7 ===" +curl -s "$BASE_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: $AUTH" \ + -d '{ + "model": "glm-4.7", + "messages": [ + {"role": "user", "content": "Hello, how are you?"} + ], + "max_tokens": 128 + }' | python3 -m json.tool