Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions src/cli/commands/default-model.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createContainer>["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();
});
});
92 changes: 92 additions & 0 deletions src/cli/commands/default-model.ts
Original file line number Diff line number Diff line change
@@ -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>",
'Tool to configure (e.g. "codex", "claude-code"). Omit for global default.'
)
.option("--model <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();
});
}
12 changes: 12 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +19,7 @@ export type {
GenerateResult,
MediaGenerateResult
} from "./sdk/types.js";
export type { DefaultModelsConfig } from "./sdk/default-model.js";

async function main(): Promise<void> {
const [{ createProgram }, { createCliMain }] = await Promise.all([
Expand Down
104 changes: 104 additions & 0 deletions src/sdk/default-model.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<DefaultModelsConfig> {
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<string | null> {
return resolveDefaultModelFromConfig({
fs: buildFileSystem(),
filePath: getConfigFilePath(homeDir),
key
});
}

export type { DefaultModelsConfig };
Loading