diff --git a/apps/cli/package.json b/apps/cli/package.json index 937a7921..77e2ed3a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@rowboatlabs/rowboatx", - "version": "0.12.0", + "version": "0.13.0", "main": "index.js", "type": "module", "scripts": { diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 0cc1d490..634923bf 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -12,6 +12,7 @@ import { ToolCallPart } from "./application/entities/message.js"; import { Agent } from "./application/entities/agent.js"; import { McpServerConfig, McpServerDefinition } from "./application/entities/mcp.js"; import { z } from "zod"; +import { Flavor, ModelConfig } from "./application/entities/models.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -225,15 +226,15 @@ export async function modelConfig() { const rl = createInterface({ input, output }); try { - const flavors = [ - "openai", - "anthropic", - "google", - "ollama", - "openai-compatible", - "openrouter", - ] as const; - const defaultBaseUrls: Record<(typeof flavors)[number], string> = { + const defaultApiKeyEnvVars: Record, string> = { + openai: "OPENAI_API_KEY", + anthropic: "ANTHROPIC_API_KEY", + google: "GOOGLE_GENERATIVE_AI_API_KEY", + ollama: "", + "openai-compatible": "", + openrouter: "", + }; + const defaultBaseUrls: Record, string> = { openai: "https://api.openai.com/v1", anthropic: "https://api.anthropic.com/v1", google: "https://generativelanguage.googleapis.com/v1beta", @@ -241,7 +242,7 @@ export async function modelConfig() { "openai-compatible": "http://localhost:8080/v1", openrouter: "https://openrouter.ai/api/v1", }; - const defaultModels: Record<(typeof flavors)[number], string> = { + const defaultModels: Record, string> = { openai: "gpt-5.1", anthropic: "claude-sonnet-4-5", google: "gemini-2.5-pro", @@ -257,25 +258,22 @@ export async function modelConfig() { renderCurrentModel(currentProvider || "none", currentProviderConfig?.flavor || "", currentModel || "none"); } - const flavorPromptLines = flavors + const FlavorList = [...Flavor.options]; + const flavorPromptLines = FlavorList .map((f, idx) => ` ${idx + 1}. ${f}`) .join("\n"); const flavorAnswer = await rl.question( - `Select a provider type:\n${flavorPromptLines}\nEnter number or name` + - (currentProvider ? ` [${currentProvider}]` : "") + - ": ", + `Select a provider type:\n${flavorPromptLines}\nEnter number or name: ` ); let selectedFlavorRaw = flavorAnswer.trim(); - let selectedFlavor: (typeof flavors)[number] | null = null; - if (selectedFlavorRaw === "" && currentProvider && (flavors as readonly string[]).includes(currentProvider)) { - selectedFlavor = currentProvider as (typeof flavors)[number]; - } else if (/^\d+$/.test(selectedFlavorRaw)) { + let selectedFlavor: z.infer | null = null; + if (/^\d+$/.test(selectedFlavorRaw)) { const idx = parseInt(selectedFlavorRaw, 10) - 1; - if (idx >= 0 && idx < flavors.length) { - selectedFlavor = flavors[idx]; + if (idx >= 0 && idx < FlavorList.length) { + selectedFlavor = FlavorList[idx]; } - } else if ((flavors as readonly string[]).includes(selectedFlavorRaw)) { - selectedFlavor = selectedFlavorRaw as (typeof flavors)[number]; + } else if (FlavorList.includes(selectedFlavorRaw as z.infer)) { + selectedFlavor = selectedFlavorRaw as z.infer; } if (!selectedFlavor) { console.error("Invalid selection. Exiting."); @@ -344,21 +342,38 @@ export async function modelConfig() { return; } + const headers: Record = {}; + const providerNameAns = await rl.question( `Enter a name/alias for this provider [${selectedFlavor}]: `, ); providerName = providerNameAns.trim() || selectedFlavor; - const baseUrlDefault = defaultBaseUrls[selectedFlavor] || ""; const baseUrlAns = await rl.question( - `Enter baseURL for ${selectedFlavor} [${baseUrlDefault}]: `, + `Enter baseURL for ${selectedFlavor} [${defaultBaseUrls[selectedFlavor]}]: `, ); - const baseURL = (baseUrlAns.trim() || baseUrlDefault) || undefined; + const baseURL = baseUrlAns.trim() || undefined; - const apiKeyAns = await rl.question( - `Enter API key for ${selectedFlavor} (leave blank to skip): `, - ); - const apiKey = apiKeyAns.trim() || undefined; + let apiKey: string | undefined = undefined; + if (selectedFlavor !== "ollama") { + let autopickText = ""; + if (defaultApiKeyEnvVars[selectedFlavor]) { + autopickText = ` (leave blank to pick from environment variable ${defaultApiKeyEnvVars[selectedFlavor]})`; + } + const apiKeyAns = await rl.question( + `Enter API key for ${selectedFlavor}${autopickText}: `, + ); + apiKey = apiKeyAns.trim() || undefined; + } + if (selectedFlavor === "ollama") { + const keyAns = await rl.question( + `Enter API key for ${selectedFlavor} (optional): ` + ); + const key = keyAns.trim(); + if (key) { + headers["Authorization"] = `Bearer ${key}`; + } + } const modelDefault = defaultModels[selectedFlavor]; const modelAns = await rl.question( @@ -372,6 +387,7 @@ export async function modelConfig() { flavor: selectedFlavor, ...(apiKey ? { apiKey } : {}), ...(baseURL ? { baseURL } : {}), + ...(headers ? { headers } : {}), }, }; const newConfig = { diff --git a/apps/cli/src/application/entities/models.ts b/apps/cli/src/application/entities/models.ts index 435c94db..ac6d014c 100644 --- a/apps/cli/src/application/entities/models.ts +++ b/apps/cli/src/application/entities/models.ts @@ -1,14 +1,16 @@ import z from "zod"; +export const Flavor = z.enum([ + "anthropic", + "google", + "ollama", + "openai", + "openai-compatible", + "openrouter", +]); + export const Provider = z.object({ - flavor: z.enum([ - "anthropic", - "google", - "ollama", - "openai", - "openai-compatible", - "openrouter", - ]), + flavor: Flavor, apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(),