diff --git a/README.md b/README.md index 17a543d6..6611eb62 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,21 @@ npx poe-code@latest spawn codex "Say hello" echo "Say hello" | npx poe-code@latest spawn codex ``` +#### Set a default model + +```bash +# Set global default model +npx poe-code@latest default-model set --model anthropic/claude-sonnet-4.6 + +# Set default model for a specific tool +npx poe-code@latest default-model set --tool codex --model anthropic/claude-sonnet-4.6 + +# Show all configured default models +npx poe-code@latest default-model show +``` + +When no model is specified in a spawn or wrap call, the tool-specific default is used, falling back to the global default. + #### Test a configured service ```bash diff --git a/src/cli/commands/default-model.test.ts b/src/cli/commands/default-model.test.ts new file mode 100644 index 00000000..e857cdcf --- /dev/null +++ b/src/cli/commands/default-model.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { registerDefaultModelCommand } from "./default-model.js"; +import { createCliContainer } from "../container.js"; +import type { FileSystem } from "../utils/file-system.js"; +import { createHomeFs } from "../../../tests/test-helpers.js"; +import { Command } from "commander"; +import { loadDefaultModels } from "../../services/config.js"; + +const cwd = "/repo"; +const homeDir = "/home/test"; +const configPath = homeDir + "/.poe-code/config.json"; + +describe("default-model command", () => { + let fs: FileSystem; + + beforeEach(() => { + fs = createHomeFs(homeDir); + }); + + function createContainer() { + const prompts = vi.fn().mockResolvedValue({}); + const container = createCliContainer({ + fs, + prompts, + env: { cwd, homeDir }, + logger: () => {}, + commandRunner: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })) + }); + return { container, prompts }; + } + + function buildProgram(container: ReturnType["container"]) { + const program = new Command(); + program.exitOverride(); + program + .name("poe-code") + .option("-y, --yes") + .option("--dry-run"); + registerDefaultModelCommand(program, container); + return program; + } + + it("saves a global default model via set subcommand", async () => { + const { container } = createContainer(); + vi.spyOn(container.options, "ensure").mockResolvedValue("anthropic/claude-sonnet-4.6"); + + const program = buildProgram(container); + await program.parseAsync(["node", "poe-code", "default-model", "set"]); + + const defaults = await loadDefaultModels({ fs, filePath: configPath }); + expect(defaults.global).toBe("anthropic/claude-sonnet-4.6"); + }); + + it("saves a tool-specific default model when --tool is provided", async () => { + const { container } = createContainer(); + vi.spyOn(container.options, "ensure").mockResolvedValue("openai/gpt-5.2-codex"); + + const program = buildProgram(container); + await program.parseAsync(["node", "poe-code", "default-model", "set", "--tool", "codex"]); + + const defaults = await loadDefaultModels({ fs, filePath: configPath }); + expect(defaults.codex).toBe("openai/gpt-5.2-codex"); + expect(defaults.global).toBeUndefined(); + }); + + it("uses --model flag to skip prompting", async () => { + const { container } = createContainer(); + const ensureSpy = vi.spyOn(container.options, "ensure"); + + const program = buildProgram(container); + await program.parseAsync([ + "node", "poe-code", "default-model", "set", + "--tool", "codex", + "--model", "openai/gpt-5.3-codex" + ]); + + expect(ensureSpy).toHaveBeenCalledWith( + expect.objectContaining({ value: "openai/gpt-5.3-codex" }) + ); + }); + + it("skips writing during dry run", async () => { + const { container } = createContainer(); + vi.spyOn(container.options, "ensure").mockResolvedValue("anthropic/claude-sonnet-4.6"); + + const program = new Command(); + program.exitOverride(); + program + .name("poe-code") + .option("-y, --yes") + .option("--dry-run"); + registerDefaultModelCommand(program, container); + + await program.parseAsync([ + "node", "poe-code", "--dry-run", "default-model", "set" + ]); + + // Config file should not exist since dry run skips writes + await expect(fs.readFile(configPath, "utf8")).rejects.toThrow(); + }); +}); diff --git a/src/cli/commands/default-model.ts b/src/cli/commands/default-model.ts new file mode 100644 index 00000000..c2612e70 --- /dev/null +++ b/src/cli/commands/default-model.ts @@ -0,0 +1,92 @@ +import type { Command } from "commander"; +import type { CliContainer } from "../container.js"; +import { createExecutionResources, resolveCommandFlags } from "./shared.js"; +import { + saveDefaultModel, + loadDefaultModels +} from "../../services/config.js"; + +export function registerDefaultModelCommand( + program: Command, + container: CliContainer +): void { + const defaultModelCommand = program + .command("default-model") + .description("Configure or view the default model used when no model is specified."); + + defaultModelCommand + .command("set") + .description("Set the default model for a tool or globally.") + .option( + "--tool ", + 'Tool to configure (e.g. "codex", "claude-code"). Omit for global default.' + ) + .option("--model ", "Model identifier to use as default") + .action(async function (this: Command) { + const flags = resolveCommandFlags(program); + const opts = this.opts<{ tool?: string; model?: string }>(); + const resources = createExecutionResources(container, flags, "default-model"); + resources.logger.intro("default-model set"); + + const key = opts.tool ?? "global"; + const label = key === "global" ? "Global default model" : `Default model for ${key}`; + + const model = await container.options.ensure({ + value: opts.model, + fallback: flags.assumeYes ? "anthropic/claude-sonnet-4.6" : undefined, + descriptor: { + name: "model", + message: label, + type: "text", + initial: "anthropic/claude-sonnet-4.6" + } + }); + + resources.context.complete({ + success: `Set ${label.toLowerCase()} to "${model}".`, + dry: `Dry run: would set ${label.toLowerCase()} to "${model}".` + }); + + if (!flags.dryRun) { + await saveDefaultModel({ + fs: container.fs, + filePath: container.env.configPath, + key, + model + }); + resources.logger.resolved(label, model); + } + + resources.context.finalize(); + }); + + defaultModelCommand + .command("show") + .description("Show all configured default models.") + .action(async function (this: Command) { + const flags = resolveCommandFlags(program); + const resources = createExecutionResources(container, flags, "default-model"); + resources.logger.intro("default-model show"); + + const defaults = await loadDefaultModels({ + fs: container.fs, + filePath: container.env.configPath + }); + + const entries = Object.entries(defaults); + if (entries.length === 0) { + resources.logger.info( + "No default models configured. Use `poe-code default-model set` to configure one." + ); + } else { + for (const [key, model] of entries) { + resources.logger.resolved( + key === "global" ? "global" : key, + model + ); + } + } + + resources.context.finalize(); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 7cd494b8..ecc334b7 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -23,6 +23,7 @@ import { registerVersionOption } from "./commands/version.js"; import { registerRalphCommand } from "./commands/ralph.js"; import { registerUsageCommand } from "./commands/usage.js"; import { registerModelsCommand } from "./commands/models.js"; +import { registerDefaultModelCommand } from "./commands/default-model.js"; import packageJson from "../../package.json" with { type: "json" }; import { throwCommandNotFound } from "./command-not-found.js"; import { @@ -127,6 +128,16 @@ function formatHelpText(input: { name: "usage list", args: "", description: "Display usage history" + }, + { + name: "default-model set", + args: "", + description: "Set the default model for a tool or globally" + }, + { + name: "default-model show", + args: "", + description: "Show all configured default models" } ]; const nameWidth = Math.max(0, ...commandRows.map((row) => row.name.length)); @@ -320,6 +331,7 @@ function bootstrapProgram(container: CliContainer): Command { registerRalphCommand(program, container); registerUsageCommand(program, container); registerModelsCommand(program, container); + registerDefaultModelCommand(program, container); program.allowExcessArguments().action(function (this: Command) { const args = this.args; diff --git a/src/index.ts b/src/index.ts index 223695f5..6b57853d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,11 @@ import { pathToFileURL } from "node:url"; export { spawn } from "./sdk/spawn.js"; export { generate, generateImage, generateVideo, generateAudio } from "./sdk/generate.js"; export { getPoeApiKey } from "./sdk/credentials.js"; +export { + setDefaultModel, + getDefaultModels, + resolveDefaultModel +} from "./sdk/default-model.js"; export type { SpawnOptions, SpawnResult, @@ -14,6 +19,7 @@ export type { GenerateResult, MediaGenerateResult } from "./sdk/types.js"; +export type { DefaultModelsConfig } from "./sdk/default-model.js"; async function main(): Promise { const [{ createProgram }, { createCliMain }] = await Promise.all([ diff --git a/src/sdk/default-model.ts b/src/sdk/default-model.ts new file mode 100644 index 00000000..3b428a6f --- /dev/null +++ b/src/sdk/default-model.ts @@ -0,0 +1,104 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import { resolveConfigPath } from "../cli/environment.js"; +import { + saveDefaultModel as saveDefaultModelToConfig, + loadDefaultModels as loadDefaultModelsFromConfig, + resolveDefaultModel as resolveDefaultModelFromConfig +} from "../services/config.js"; +import type { DefaultModelsConfig } from "../services/config.js"; + +function buildFileSystem() { + return { + readFile: ((path: string, encoding?: BufferEncoding) => { + if (encoding) return fs.readFile(path, encoding); + return fs.readFile(path); + }) as any, + writeFile: (path: string, data: any, opts?: any) => fs.writeFile(path, data, opts), + mkdir: (path: string, opts?: any) => fs.mkdir(path, opts).then(() => {}), + stat: (path: string) => fs.stat(path), + rm: (path: string, opts?: any) => fs.rm(path, opts), + unlink: (path: string) => fs.unlink(path), + readdir: (path: string) => fs.readdir(path), + copyFile: (src: string, dest: string) => fs.copyFile(src, dest), + chmod: (path: string, mode: number) => fs.chmod(path, mode) + }; +} + +function getConfigFilePath(homeDir?: string): string { + return resolveConfigPath(homeDir ?? os.homedir()); +} + +/** + * Sets the default model for a tool or globally. + * + * @param key - The scope: `"global"` applies to all tools; a tool name (e.g. `"codex"`) + * applies to that specific tool; an endpoint path (e.g. `"/v1/responses"`) applies to + * that endpoint. + * @param model - The model identifier to use as default (e.g. `"anthropic/claude-sonnet-4.6"`). + * @param homeDir - Optional home directory override (defaults to `os.homedir()`). + * + * @example + * // Set a global default for all tools + * await setDefaultModel("global", "anthropic/claude-sonnet-4.6"); + * + * // Set a default specifically for codex + * await setDefaultModel("codex", "openai/gpt-5.2-codex"); + */ +export async function setDefaultModel( + key: string, + model: string, + homeDir?: string +): Promise { + await saveDefaultModelToConfig({ + fs: buildFileSystem(), + filePath: getConfigFilePath(homeDir), + key, + model + }); +} + +/** + * Returns all configured default models. + * + * @param homeDir - Optional home directory override (defaults to `os.homedir()`). + * @returns A record mapping keys to model identifiers. An empty object means no defaults configured. + * + * @example + * const defaults = await getDefaultModels(); + * // { global: "anthropic/claude-sonnet-4.6", codex: "openai/gpt-5.2-codex" } + */ +export async function getDefaultModels(homeDir?: string): Promise { + return loadDefaultModelsFromConfig({ + fs: buildFileSystem(), + filePath: getConfigFilePath(homeDir) + }); +} + +/** + * Resolves the default model for a given key. + * + * Lookup order: + * 1. Key-specific default (e.g. `"codex"`) + * 2. Global default (`"global"`) + * 3. `null` — no default configured + * + * @param key - Tool name or endpoint path to resolve the model for. + * @param homeDir - Optional home directory override (defaults to `os.homedir()`). + * + * @example + * const model = await resolveDefaultModel("codex"); + * // "openai/gpt-5.2-codex" (or the global default if no codex-specific one is set) + */ +export async function resolveDefaultModel( + key: string, + homeDir?: string +): Promise { + return resolveDefaultModelFromConfig({ + fs: buildFileSystem(), + filePath: getConfigFilePath(homeDir), + key + }); +} + +export type { DefaultModelsConfig }; diff --git a/src/sdk/spawn.ts b/src/sdk/spawn.ts index ceef61a5..4467ffa0 100644 --- a/src/sdk/spawn.ts +++ b/src/sdk/spawn.ts @@ -11,6 +11,7 @@ import { } from "@poe-code/agent-spawn"; import type { SpawnOptions, SpawnResult } from "./types.js"; import { spawnPoeAgentWithAcp } from "../providers/poe-agent.js"; +import { resolveDefaultModel } from "./default-model.js"; /** * Spawns an agent with optional streaming. @@ -91,15 +92,24 @@ export function spawn( try { await getPoeApiKey(); - if (options.interactive) { + // Apply configured default model when no model was explicitly specified + let resolvedOptions = options; + if (resolvedOptions.model == null) { + const configuredDefault = await resolveDefaultModel(service); + if (configuredDefault != null) { + resolvedOptions = { ...resolvedOptions, model: configuredDefault }; + } + } + + if (resolvedOptions.interactive) { resolveEventsOnce(emptyEvents); const interactiveResult = await spawnInteractive(service, { - prompt: options.prompt, - cwd: options.cwd, - model: options.model, - mode: options.mode, - args: options.args, - ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}) + prompt: resolvedOptions.prompt, + cwd: resolvedOptions.cwd, + model: resolvedOptions.model, + mode: resolvedOptions.mode, + args: resolvedOptions.args, + ...(resolvedOptions.mcpServers ? { mcpServers: resolvedOptions.mcpServers } : {}) }); return { stdout: interactiveResult.stdout, @@ -115,11 +125,11 @@ export function spawn( : undefined; const { events: innerEvents, done } = spawnPoeAgentWithAcp({ - prompt: options.prompt, - cwd: options.cwd, - model: options.model, + prompt: resolvedOptions.prompt, + cwd: resolvedOptions.cwd, + model: resolvedOptions.model, ...(poeBaseUrl ? { baseUrl: poeBaseUrl } : {}), - ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}) + ...(resolvedOptions.mcpServers ? { mcpServers: resolvedOptions.mcpServers } : {}) }); resolveEventsOnce(innerEvents); @@ -142,12 +152,12 @@ export function spawn( if (supportsStreaming) { const { events: innerEvents, done } = spawnStreaming({ agentId: service, - prompt: options.prompt, - cwd: options.cwd, - model: options.model, - mode: options.mode, - args: options.args, - ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}), + prompt: resolvedOptions.prompt, + cwd: resolvedOptions.cwd, + model: resolvedOptions.model, + mode: resolvedOptions.mode, + args: resolvedOptions.args, + ...(resolvedOptions.mcpServers ? { mcpServers: resolvedOptions.mcpServers } : {}), useStdin: false }); @@ -165,26 +175,26 @@ export function spawn( if (spawnConfig && spawnConfig.kind === "cli") { resolveEventsOnce(emptyEvents); return spawnNonStreaming(service, { - prompt: options.prompt, - cwd: options.cwd, - model: options.model, - mode: options.mode, - args: options.args, - ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}), + prompt: resolvedOptions.prompt, + cwd: resolvedOptions.cwd, + model: resolvedOptions.model, + mode: resolvedOptions.mode, + args: resolvedOptions.args, + ...(resolvedOptions.mcpServers ? { mcpServers: resolvedOptions.mcpServers } : {}), useStdin: false }); } resolveEventsOnce(emptyEvents); - const container = createSdkContainer({ cwd: options.cwd }); + const container = createSdkContainer({ cwd: resolvedOptions.cwd }); return spawnCore(container, service, { - prompt: options.prompt, - cwd: options.cwd, - model: options.model, - mode: options.mode, - args: options.args, - ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}), + prompt: resolvedOptions.prompt, + cwd: resolvedOptions.cwd, + model: resolvedOptions.model, + mode: resolvedOptions.mode, + args: resolvedOptions.args, + ...(resolvedOptions.mcpServers ? { mcpServers: resolvedOptions.mcpServers } : {}), useStdin: false }); } catch (error) { diff --git a/src/services/config.test.ts b/src/services/config.test.ts index 29949127..dff95e51 100644 --- a/src/services/config.test.ts +++ b/src/services/config.test.ts @@ -7,7 +7,10 @@ import { saveConfig, loadConfiguredServices, saveConfiguredService, - unconfigureService + unconfigureService, + saveDefaultModel, + loadDefaultModels, + resolveDefaultModel } from "./config.js"; function createMemFs(): FileSystem { @@ -163,3 +166,84 @@ describe("config store", () => { expect(JSON.parse(rewritten)).toEqual({}); }); }); + +describe("default model store", () => { + const configPath = "/home/user/.poe-code/config.json"; + let fs: FileSystem; + + beforeEach(async () => { + fs = createMemFs(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + }); + + it("saves and loads a global default model", async () => { + await saveDefaultModel({ + fs, + filePath: configPath, + key: "global", + model: "anthropic/claude-sonnet-4.6" + }); + + const defaults = await loadDefaultModels({ fs, filePath: configPath }); + expect(defaults).toEqual({ global: "anthropic/claude-sonnet-4.6" }); + }); + + it("saves and loads a tool-specific default model", async () => { + await saveDefaultModel({ + fs, + filePath: configPath, + key: "codex", + model: "openai/gpt-5.2-codex" + }); + + const defaults = await loadDefaultModels({ fs, filePath: configPath }); + expect(defaults).toEqual({ codex: "openai/gpt-5.2-codex" }); + }); + + it("returns empty object when no default models are configured", async () => { + const defaults = await loadDefaultModels({ fs, filePath: configPath }); + expect(defaults).toEqual({}); + }); + + it("resolves tool-specific default before global", async () => { + await saveDefaultModel({ fs, filePath: configPath, key: "global", model: "anthropic/claude-sonnet-4.6" }); + await saveDefaultModel({ fs, filePath: configPath, key: "codex", model: "openai/gpt-5.2-codex" }); + + const model = await resolveDefaultModel({ fs, filePath: configPath, key: "codex" }); + expect(model).toBe("openai/gpt-5.2-codex"); + }); + + it("falls back to global default when no tool-specific default exists", async () => { + await saveDefaultModel({ fs, filePath: configPath, key: "global", model: "anthropic/claude-sonnet-4.6" }); + + const model = await resolveDefaultModel({ fs, filePath: configPath, key: "codex" }); + expect(model).toBe("anthropic/claude-sonnet-4.6"); + }); + + it("returns null when no defaults are configured", async () => { + const model = await resolveDefaultModel({ fs, filePath: configPath, key: "codex" }); + expect(model).toBeNull(); + }); + + it("preserves api key and configured services when saving default model", async () => { + await saveDefaultModel({ + fs, + filePath: configPath, + key: "global", + model: "anthropic/claude-sonnet-4.6" + }); + await saveConfig({ fs, filePath: configPath, apiKey: "sk-test" }); + + const updated = JSON.parse(await fs.readFile(configPath, "utf8")); + expect(updated.apiKey).toBe("sk-test"); + expect(updated.default_models).toEqual({ global: "anthropic/claude-sonnet-4.6" }); + }); + + it("overwrites an existing default for the same key", async () => { + await saveDefaultModel({ fs, filePath: configPath, key: "codex", model: "openai/gpt-5.2-codex" }); + await saveDefaultModel({ fs, filePath: configPath, key: "codex", model: "openai/gpt-5.3-codex" }); + + const defaults = await loadDefaultModels({ fs, filePath: configPath }); + expect(defaults.codex).toBe("openai/gpt-5.3-codex"); + }); +}); diff --git a/src/services/config.ts b/src/services/config.ts index eaaf2ecb..d7f05ec4 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -15,9 +15,18 @@ export interface ConfiguredServiceMetadata { files: string[]; } +/** + * Default model configuration keyed by tool name, endpoint path, or "global". + * - "global": fallback for all tools with no specific default + * - "" (e.g. "codex"): default for a specific tool + * - "" (e.g. "/v1/responses"): default for a specific API endpoint + */ +export type DefaultModelsConfig = Record; + interface ConfigDocument { apiKey?: string; configured_services?: Record; + default_models?: DefaultModelsConfig; } export interface SaveConfiguredServiceOptions @@ -31,6 +40,17 @@ export interface UnconfigureServiceOptions service: string; } +export interface SaveDefaultModelOptions extends ConfigStoreOptions { + /** Key identifying the scope: "global", a tool name, or an endpoint path */ + key: string; + model: string; +} + +export interface ResolveDefaultModelOptions extends ConfigStoreOptions { + /** Tool name or endpoint to check for a specific default before falling back to "global" */ + key: string; +} + export async function saveConfig( options: SaveConfigOptions ): Promise { @@ -103,6 +123,37 @@ export async function unconfigureService( return true; } +export async function saveDefaultModel( + options: SaveDefaultModelOptions +): Promise { + const { fs, filePath, key, model } = options; + const document = await readConfigDocument(fs, filePath); + document.default_models = { + ...(document.default_models ?? {}), + [key]: model + }; + await writeConfigDocument(fs, filePath, document); +} + +export async function loadDefaultModels( + options: ConfigStoreOptions +): Promise { + const { fs, filePath } = options; + const document = await readConfigDocument(fs, filePath); + return { ...(document.default_models ?? {}) }; +} + +/** + * Resolves the configured default model for a given key. + * Lookup order: key-specific → "global" → null + */ +export async function resolveDefaultModel( + options: ResolveDefaultModelOptions +): Promise { + const defaults = await loadDefaultModels(options); + return defaults[options.key] ?? defaults["global"] ?? null; +} + function normalizeConfiguredServiceMetadata( metadata: ConfiguredServiceMetadata ): ConfiguredServiceMetadata { @@ -182,9 +233,26 @@ function normalizeConfigDocument(value: unknown): ConfigDocument { if (Object.keys(services).length > 0) { document.configured_services = services; } + const defaultModels = normalizeDefaultModels(value.default_models); + if (Object.keys(defaultModels).length > 0) { + document.default_models = defaultModels; + } return document; } +function normalizeDefaultModels(value: unknown): DefaultModelsConfig { + if (!isRecord(value)) { + return {}; + } + const result: DefaultModelsConfig = {}; + for (const [key, model] of Object.entries(value)) { + if (typeof model === "string" && model.length > 0 && key.length > 0) { + result[key] = model; + } + } + return result; +} + function normalizeConfiguredServices( value: unknown ): Record { @@ -217,6 +285,9 @@ async function writeConfigDocument( if (document.configured_services) { payload.configured_services = document.configured_services; } + if (document.default_models && Object.keys(document.default_models).length > 0) { + payload.default_models = document.default_models; + } await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8" });