diff --git a/bun.lock b/bun.lock index 3ef2700..08472a0 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "nostr-tools": "^2.12.0", "qrcode": "^1.5.4", "rxjs": "^7.8.1", + "yaml": "^2.9.0", "zustand": "^5.0.5", }, "devDependencies": { @@ -325,6 +326,8 @@ "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], diff --git a/package.json b/package.json index 02c6114..a45bccc 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "nostr-tools": "^2.12.0", "qrcode": "^1.5.4", "rxjs": "^7.8.1", + "yaml": "^2.9.0", "zustand": "^5.0.5" } } diff --git a/src/integrations/hermes.ts b/src/integrations/hermes.ts index 6fb7bd9..f5f3a28 100644 --- a/src/integrations/hermes.ts +++ b/src/integrations/hermes.ts @@ -1,10 +1,65 @@ import { existsSync, mkdirSync } from "fs"; import { readFile, writeFile } from "fs/promises"; import { dirname } from "path"; +import { parseDocument } from "yaml"; import type { RoutstrdConfig } from "../utils/config"; import type { IntegrationConfig, RoutstrModel } from "./registry"; import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client"; +interface HermesRoutstrConfig { + baseUrl: string; + apiKey: string; + defaultModel: string; +} + +interface HermesCustomProvider { + name?: string; + [key: string]: unknown; +} + +export function mergeHermesConfig( + content: string, + routstr: HermesRoutstrConfig, +): string { + const document = parseDocument(content || "{}", { prettyErrors: true }); + if (document.errors.length > 0) { + throw document.errors[0]; + } + + const urlDisplay = routstr.baseUrl + .replace(/\/v1$/, "") + .replace(/^https?:\/\//, ""); + const provider = { + name: `Routstr (${urlDisplay})`, + base_url: routstr.baseUrl, + api_key: routstr.apiKey, + model: routstr.defaultModel, + }; + + const isNewConfig = content.trim() === ""; + if (isNewConfig) { + document.set("model", { + default: routstr.defaultModel, + provider: "custom", + base_url: routstr.baseUrl, + api_key: routstr.apiKey, + }); + } + + const existingConfig = document.toJS() as { + custom_providers?: HermesCustomProvider[]; + }; + const existingProviders = existingConfig.custom_providers; + const providers = Array.isArray(existingProviders) ? existingProviders : []; + if (providers.some((item) => item.name?.startsWith("Routstr ("))) { + return content; + } + providers.push(provider); + document.set("custom_providers", providers); + + return document.toString(); +} + export async function installHermesIntegration( config: RoutstrdConfig, apiKey: string, @@ -26,10 +81,10 @@ export async function installHermesIntegration( if (models.length >= 3) { defaultModel = models[2]!.id; - console.log(`Set default model to 3rd available model: ${defaultModel}`); + console.log(`Using 3rd available model for new Hermes configurations: ${defaultModel}`); } else if (models.length > 0) { defaultModel = models[0]!.id; - console.log(`Only ${models.length} models available, using ${defaultModel} as default.`); + console.log(`Only ${models.length} models available, using ${defaultModel} for new Hermes configurations.`); } else { console.log("No models available from routstr daemon, using fallback default."); } @@ -47,36 +102,24 @@ export async function installHermesIntegration( console.error(`Error reading ${configPath}, creating new one.`); } - // Remove existing model block - content = content.replace(/^model:\n(?: .*\n)*/gm, ""); - // Remove existing custom_providers block - content = content.replace(/^custom_providers:\n(?:- .*\n(?: .*\n)*)*/gm, ""); - // Clean up extra blank lines - content = content.replace(/\n{3,}/g, "\n\n").trim(); - - const urlDisplay = baseUrl.replace(/^https?:\/\//, ""); - - const modelBlock = `model: - default: ${defaultModel} - provider: custom - base_url: ${baseUrlV1} - api_key: ${apiKey}`; - - const providerBlock = `custom_providers: -- name: Routstr (${urlDisplay}) - base_url: ${baseUrlV1} - api_key: ${apiKey} - model: ${defaultModel}`; - - const parts: string[] = [modelBlock]; - if (content) { - parts.push(content); + let newContent: string; + try { + newContent = mergeHermesConfig(content, { + baseUrl: baseUrlV1, + apiKey, + defaultModel, + }); + } catch (error) { + console.error(`Failed to parse ${configPath} as YAML; leaving it unchanged:`, error); + return; } - parts.push(providerBlock); - - const newContent = parts.join("\n\n") + "\n"; try { + if (newContent === content) { + console.log(`${configPath} already contains current routstr settings.`); + return; + } + mkdirSync(dirname(configPath), { recursive: true }); await writeFile(configPath, newContent); console.log(`Successfully updated ${configPath} with routstr settings.`); diff --git a/tests/integrations/hermes.test.ts b/tests/integrations/hermes.test.ts new file mode 100644 index 0000000..12ac581 --- /dev/null +++ b/tests/integrations/hermes.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; +import { parse } from "yaml"; +import { mergeHermesConfig } from "../../src/integrations/hermes"; + +const ROUTSTR = { + baseUrl: "http://localhost:8008/v1", + apiKey: "routstr-key", + defaultModel: "deepseek-v4-flash", +}; + +describe("mergeHermesConfig", () => { + it("preserves the user's model and unrelated custom providers", () => { + const existing = `model: + default: user-model + provider: custom + base_url: https://user.example/v1 + api_key: user-key + +custom_providers: + - name: User Provider + base_url: https://user.example/v1 + api_key: user-key + model: user-model + +other_setting: true +`; + + const merged = parse(mergeHermesConfig(existing, ROUTSTR)); + + expect(merged.model).toEqual({ + default: "user-model", + provider: "custom", + base_url: "https://user.example/v1", + api_key: "user-key", + }); + expect(merged.other_setting).toBe(true); + expect(merged.custom_providers).toContainEqual({ + name: "User Provider", + base_url: "https://user.example/v1", + api_key: "user-key", + model: "user-model", + }); + expect(merged.custom_providers).toContainEqual({ + name: "Routstr (localhost:8008)", + base_url: ROUTSTR.baseUrl, + api_key: ROUTSTR.apiKey, + model: ROUTSTR.defaultModel, + }); + }); + + it("does not add a model configuration to an existing Hermes file", () => { + const merged = parse(mergeHermesConfig("other_setting: true\n", ROUTSTR)); + + expect(merged.model).toBeUndefined(); + expect(merged.other_setting).toBe(true); + }); + + it("creates a Routstr default when creating a new Hermes file", () => { + const merged = parse(mergeHermesConfig("", ROUTSTR)); + + expect(merged.model).toEqual({ + default: ROUTSTR.defaultModel, + provider: "custom", + base_url: ROUTSTR.baseUrl, + api_key: ROUTSTR.apiKey, + }); + }); + + it("keeps an existing Routstr provider unchanged without duplicating it", () => { + const existing = `custom_providers: + - name: Routstr (old-host:8008) + base_url: http://old-host:8008/v1 + api_key: old-key + model: old-model +`; + + const once = mergeHermesConfig(existing, ROUTSTR); + const twice = mergeHermesConfig(once, ROUTSTR); + const merged = parse(twice); + const routstrProviders = merged.custom_providers.filter( + (provider: { name?: string }) => provider.name?.startsWith("Routstr ("), + ); + + expect(routstrProviders).toHaveLength(1); + expect(routstrProviders[0]).toEqual({ + name: "Routstr (old-host:8008)", + base_url: "http://old-host:8008/v1", + api_key: "old-key", + model: "old-model", + }); + expect(twice).toBe(once); + }); + + it("rejects malformed YAML instead of replacing it", () => { + expect(() => mergeHermesConfig("model: [unterminated", ROUTSTR)).toThrow(); + }); +});