diff --git a/README.md b/README.md index 8950adaf..94f62545 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Inspired by Claude Code, RowboatX brings the same shell-native power to backgrou ## Quick start ```bash -npx @rowboatlabs/rowboatx +npx @rowboatlabs/rowboatx@latest ``` ## Demo diff --git a/apps/cli/package.json b/apps/cli/package.json index 9752e26a..94adbfcf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@rowboatlabs/rowboatx", - "version": "0.11.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 a45bf84e..d185737e 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -7,7 +7,8 @@ import { WorkDir, getModelConfig, updateModelConfig } from "./application/config import { RunEvent } from "./application/entities/run-events.js"; import { createInterface, Interface } from "node:readline/promises"; import { ToolCallPart } from "./application/entities/message.js"; -import { z } from "zod"; +import { keyof, z } from "zod"; +import { Flavor, ModelConfig } from "./application/entities/models.js"; export async function updateState(agent: string, runId: string) { const state = new AgentState(agent, runId); @@ -216,15 +217,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", @@ -232,12 +233,12 @@ 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-3.5-sonnet", - google: "gemini-1.5-pro", + anthropic: "claude-sonnet-4-5", + google: "gemini-2.5-pro", ollama: "llama3.1", - "openai-compatible": "gpt-4o", + "openai-compatible": "openai/gpt-5.1", openrouter: "openrouter/auto", }; @@ -245,31 +246,25 @@ export async function modelConfig() { const currentModel = config?.defaults?.model; const currentProviderConfig = currentProvider ? config?.providers?.[currentProvider] : undefined; if (config) { - console.log("Currently using:"); - console.log(`- provider: ${currentProvider || "none"}${currentProviderConfig?.flavor ? ` (${currentProviderConfig.flavor})` : ""}`); - console.log(`- model: ${currentModel || "none"}`); - console.log(""); + 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."); @@ -338,21 +333,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( @@ -366,6 +378,7 @@ export async function modelConfig() { flavor: selectedFlavor, ...(apiKey ? { apiKey } : {}), ...(baseURL ? { baseURL } : {}), + ...(headers ? { headers } : {}), }, }; const newConfig = { @@ -377,8 +390,16 @@ export async function modelConfig() { }; await updateModelConfig(newConfig as any); - console.log(`Model configuration updated. Provider '${providerName}' ${config?.providers?.[providerName] ? "overwritten" : "added"}.`); + renderCurrentModel(providerName, selectedFlavor, model); + console.log(`Configuration written to ${WorkDir}/config/models.json. You can also edit this file manually`); } finally { rl.close(); } +} + +function renderCurrentModel(provider: string, flavor: string, model: string) { + console.log("Currently using:"); + console.log(`- provider: ${provider}${flavor ? ` (${flavor})` : ""}`); + console.log(`- model: ${model}`); + console.log(""); } \ No newline at end of file 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(),