routstrd 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { ModelManager } from "@routstr/sdk";
1
+ import { ModelManager, type SdkStore } from "@routstr/sdk";
2
2
  import type { ExposedModel } from "./types";
3
3
  import { logger } from "../utils/logger";
4
4
 
@@ -16,7 +16,7 @@ export type ModelWithProviders = ExposedModel & {
16
16
  providers: ModelProviderInfo[];
17
17
  };
18
18
 
19
- export function createModelService(modelManager: ModelManager) {
19
+ export function createModelService(modelManager: ModelManager, store: SdkStore) {
20
20
  let providerBootstrapPromise: Promise<void> | null = null;
21
21
 
22
22
  const ensureProvidersBootstrapped = (): Promise<void> => {
@@ -26,6 +26,22 @@ export function createModelService(modelManager: ModelManager) {
26
26
  const providers = await modelManager.bootstrapProviders(false);
27
27
  logger.log(`Bootstrapped ${providers.length} providers`);
28
28
  await modelManager.fetchModels(providers);
29
+
30
+ // Sync discovered providers into the store so `providers list` reflects
31
+ // the same set that the model manager knows about.
32
+ const { baseUrlsList, setBaseUrlsList } = store.getState();
33
+ const existing = new Set(baseUrlsList);
34
+ const merged = [
35
+ ...baseUrlsList,
36
+ ...providers.filter((url) => !existing.has(url)),
37
+ ];
38
+ if (merged.length !== baseUrlsList.length) {
39
+ setBaseUrlsList(merged);
40
+ logger.log(
41
+ `Synced ${merged.length - baseUrlsList.length} new provider(s) into store`,
42
+ );
43
+ }
44
+
29
45
  logger.log("Provider bootstrap complete.");
30
46
  })().catch((error) => {
31
47
  logger.error("Provider bootstrap failed:", error);
@@ -0,0 +1,87 @@
1
+ import { existsSync, mkdirSync } from "fs";
2
+ import { readFile, writeFile } from "fs/promises";
3
+ import { dirname } from "path";
4
+ import type { RoutstrdConfig } from "../utils/config";
5
+ import { logger } from "../utils/logger";
6
+ import type { IntegrationConfig, RoutstrModel } from "./registry";
7
+ import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
8
+
9
+ export async function installHermesIntegration(
10
+ config: RoutstrdConfig,
11
+ apiKey: string,
12
+ integrationConfig: IntegrationConfig,
13
+ ): Promise<void> {
14
+ const { name, configPath } = integrationConfig;
15
+
16
+ logger.log(`\nInstalling routstr configuration in ${configPath}...`);
17
+ logger.log(`Using API key for ${name}`);
18
+
19
+ const baseUrl = getDaemonBaseUrl(config);
20
+ const baseUrlV1 = `${baseUrl}/v1`;
21
+
22
+ let defaultModel = "minimax-m2.7";
23
+
24
+ try {
25
+ const data = await callDaemon("/models");
26
+ const models = (data.output as { models: RoutstrModel[] } | undefined)?.models || [];
27
+
28
+ if (models.length >= 3) {
29
+ defaultModel = models[2]!.id;
30
+ logger.log(`Set default model to 3rd available model: ${defaultModel}`);
31
+ } else if (models.length > 0) {
32
+ defaultModel = models[0]!.id;
33
+ logger.log(`Only ${models.length} models available, using ${defaultModel} as default.`);
34
+ } else {
35
+ logger.log("No models available from routstr daemon, using fallback default.");
36
+ }
37
+ } catch (error) {
38
+ logger.error("Failed to fetch models for Hermes integration:", error);
39
+ logger.log("Using fallback default model.");
40
+ }
41
+
42
+ let content = "";
43
+ try {
44
+ if (existsSync(configPath)) {
45
+ content = await readFile(configPath, "utf-8");
46
+ }
47
+ } catch (error) {
48
+ logger.error(`Error reading ${configPath}, creating new one.`);
49
+ }
50
+
51
+ // Remove existing model block
52
+ content = content.replace(/^model:\n(?: .*\n)*/gm, "");
53
+ // Remove existing custom_providers block
54
+ content = content.replace(/^custom_providers:\n(?:- .*\n(?: .*\n)*)*/gm, "");
55
+ // Clean up extra blank lines
56
+ content = content.replace(/\n{3,}/g, "\n\n").trim();
57
+
58
+ const urlDisplay = baseUrl.replace(/^https?:\/\//, "");
59
+
60
+ const modelBlock = `model:
61
+ default: ${defaultModel}
62
+ provider: custom
63
+ base_url: ${baseUrlV1}
64
+ api_key: ${apiKey}`;
65
+
66
+ const providerBlock = `custom_providers:
67
+ - name: Routstr (${urlDisplay})
68
+ base_url: ${baseUrlV1}
69
+ api_key: ${apiKey}
70
+ model: ${defaultModel}`;
71
+
72
+ const parts: string[] = [modelBlock];
73
+ if (content) {
74
+ parts.push(content);
75
+ }
76
+ parts.push(providerBlock);
77
+
78
+ const newContent = parts.join("\n\n") + "\n";
79
+
80
+ try {
81
+ mkdirSync(dirname(configPath), { recursive: true });
82
+ await writeFile(configPath, newContent);
83
+ logger.log(`Successfully updated ${configPath} with routstr settings.`);
84
+ } catch (error) {
85
+ logger.error(`Failed to write to ${configPath}:`, error);
86
+ }
87
+ }
@@ -4,6 +4,7 @@ import { installOpencodeIntegration } from "./opencode";
4
4
  import { installPiIntegration } from "./pi";
5
5
  import { installOpenClawIntegration } from "./openclaw";
6
6
  import { installClaudeCodeIntegration } from "./claudecode";
7
+ import { installHermesIntegration } from "./hermes";
7
8
 
8
9
  export interface IntegrationConfig {
9
10
  clientId: string;
@@ -43,6 +44,11 @@ export const CLIENT_CONFIGS: Record<string, IntegrationConfig> = {
43
44
  name: "Claude Code",
44
45
  configPath: join(process.env.HOME || "", ".claude/settings.json"),
45
46
  },
47
+ hermes: {
48
+ clientId: "hermes",
49
+ name: "Hermes",
50
+ configPath: join(process.env.HOME || "", ".hermes/config.yaml"),
51
+ },
46
52
  };
47
53
 
48
54
  export const CLIENT_INTEGRATIONS: Record<string, IntegrationFn> = {
@@ -50,6 +56,7 @@ export const CLIENT_INTEGRATIONS: Record<string, IntegrationFn> = {
50
56
  "pi-agent": installPiIntegration,
51
57
  openclaw: installOpenClawIntegration,
52
58
  "claude-code": installClaudeCodeIntegration,
59
+ hermes: installHermesIntegration,
53
60
  };
54
61
 
55
62
  export async function runIntegrationsForClients(
@@ -511,18 +511,21 @@ export function renderRecent(stats: UsageStats, width: number): string {
511
511
  const recentEntries = stats.entries.slice(0, 50);
512
512
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
513
513
 
514
+ const clientCol = 10;
514
515
  const lines: string[] = [];
515
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 60))}${COLORS.reset}`);
516
+ lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"CLIENT".padEnd(clientCol)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 70))}${COLORS.reset}`);
516
517
  lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
517
518
 
518
519
  for (const entry of recentEntries) {
519
520
  const time = formatTime(entry.timestamp).slice(0, 8);
521
+ const clientName = (entry.client || "unknown").slice(0, clientCol - 1).padEnd(clientCol);
522
+ const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
520
523
  const model = entry.modelId.slice(0, 18).padEnd(18);
521
524
  const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
522
525
  const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
523
- const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 60));
524
- const color = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
525
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${color}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
526
+ const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 70));
527
+ const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
528
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
526
529
  }
527
530
 
528
531
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
@@ -187,6 +187,7 @@ export interface AddClientOptions {
187
187
  openclaw?: boolean;
188
188
  piAgent?: boolean;
189
189
  claudeCode?: boolean;
190
+ hermes?: boolean;
190
191
  }
191
192
 
192
193
  export async function addClientAction(options: AddClientOptions): Promise<void> {
@@ -198,6 +199,7 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
198
199
  if (options.openclaw) integrationKeys.push("openclaw");
199
200
  if (options.piAgent) integrationKeys.push("pi-agent");
200
201
  if (options.claudeCode) integrationKeys.push("claude-code");
202
+ if (options.hermes) integrationKeys.push("hermes");
201
203
 
202
204
  if (integrationKeys.length > 0) {
203
205
  for (const key of integrationKeys) {
@@ -233,9 +235,14 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
233
235
  }
234
236
 
235
237
  if (!options.name) {
236
- console.error(
237
- "error: required option '-n, --name <name>' not specified",
238
- );
238
+ console.error("error: either provide a client name or specify an integration flag.\n");
239
+ console.error("Options:");
240
+ console.error(" -n, --name <name> Client name");
241
+ console.error(" --opencode Set up OpenCode integration");
242
+ console.error(" --openclaw Set up OpenClaw integration");
243
+ console.error(" --pi-agent Set up Pi Agent integration");
244
+ console.error(" --claude-code Set up Claude Code integration");
245
+ console.error(" --hermes Set up Hermes integration");
239
246
  process.exit(1);
240
247
  }
241
248
 
@@ -37,7 +37,7 @@ export function getDaemonBaseUrl(config: RoutstrdConfig): string {
37
37
 
38
38
  export async function callDaemon(
39
39
  path: string,
40
- options: { method?: "GET" | "POST" | "DELETE"; body?: object } = {},
40
+ options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {},
41
41
  ): Promise<CommandResponse> {
42
42
  const { method = "GET", body } = options;
43
43
  const config = await loadConfig();