From 0eb447113efc54333ec2ef6ee82cb94aa17b54c6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 17:23:29 +0900 Subject: [PATCH] feat(cli): add --model option to run command for model override Add -m, --model option to oh-my-opencode run command. Allows users to override the model while keeping the agent unchanged. Changes: - Add model?: string to RunOptions interface - Create model-resolver.ts to parse provider/model format - Add model-resolver.test.ts with 7 test cases (TDD) - Add --model CLI option with help text examples - Wire resolveRunModel in runner.ts and pass to promptAsync - Export resolveRunModel from barrel (index.ts) Example usage: bunx oh-my-opencode run --model anthropic/claude-sonnet-4 "Fix the bug" bunx oh-my-opencode run --agent Sisyphus --model openai/gpt-5.4 "Task" --- src/cli/cli-program.ts | 4 ++ src/cli/run/index.ts | 1 + src/cli/run/model-resolver.test.ts | 83 ++++++++++++++++++++++++++++++ src/cli/run/model-resolver.ts | 29 +++++++++++ src/cli/run/runner.ts | 7 +++ src/cli/run/types.ts | 1 + 6 files changed, 125 insertions(+) create mode 100644 src/cli/run/model-resolver.test.ts create mode 100644 src/cli/run/model-resolver.ts diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 9b3be87ef..fc3197efa 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -69,6 +69,7 @@ program .passThroughOptions() .description("Run opencode with todo/background task completion enforcement") .option("-a, --agent ", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)") + .option("-m, --model ", "Model override (e.g., anthropic/claude-sonnet-4)") .option("-d, --directory ", "Working directory") .option("-p, --port ", "Server port (attaches if port already in use)", parseInt) .option("--attach ", "Attach to existing opencode server URL") @@ -86,6 +87,8 @@ Examples: $ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId $ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug" $ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work" + $ bunx oh-my-opencode run --model anthropic/claude-sonnet-4 "Fix the bug" + $ bunx oh-my-opencode run --agent Sisyphus --model openai/gpt-5.4 "Implement feature X" Agent resolution order: 1) --agent flag @@ -108,6 +111,7 @@ Unlike 'opencode run', this command waits until: const runOptions: RunOptions = { message, agent: options.agent, + model: options.model, directory: options.directory, port: options.port, attach: options.attach, diff --git a/src/cli/run/index.ts b/src/cli/run/index.ts index 8e4528e81..65c4d9330 100644 --- a/src/cli/run/index.ts +++ b/src/cli/run/index.ts @@ -1,5 +1,6 @@ export { run } from "./runner" export { resolveRunAgent } from "./agent-resolver" +export { resolveRunModel } from "./model-resolver" export { createServerConnection } from "./server-connection" export { resolveSession } from "./session-resolver" export { createJsonOutputManager } from "./json-output" diff --git a/src/cli/run/model-resolver.test.ts b/src/cli/run/model-resolver.test.ts new file mode 100644 index 000000000..28bb83d33 --- /dev/null +++ b/src/cli/run/model-resolver.test.ts @@ -0,0 +1,83 @@ +/// + +import { describe, it, expect } from "bun:test" +import { resolveRunModel } from "./model-resolver" + +describe("resolveRunModel", () => { + it("given no model string, when resolved, then returns undefined", () => { + // given + const modelString = undefined + + // when + const result = resolveRunModel(modelString) + + // then + expect(result).toBeUndefined() + }) + + it("given empty string, when resolved, then throws Error", () => { + // given + const modelString = "" + + // when + const resolve = () => resolveRunModel(modelString) + + // then + expect(resolve).toThrow() + }) + + it("given valid 'anthropic/claude-sonnet-4', when resolved, then returns correct object", () => { + // given + const modelString = "anthropic/claude-sonnet-4" + + // when + const result = resolveRunModel(modelString) + + // then + expect(result).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4" }) + }) + + it("given nested slashes 'openai/gpt-5.3/preview', when resolved, then modelID is 'gpt-5.3/preview'", () => { + // given + const modelString = "openai/gpt-5.3/preview" + + // when + const result = resolveRunModel(modelString) + + // then + expect(result).toEqual({ providerID: "openai", modelID: "gpt-5.3/preview" }) + }) + + it("given no slash 'claude-sonnet-4', when resolved, then throws Error", () => { + // given + const modelString = "claude-sonnet-4" + + // when + const resolve = () => resolveRunModel(modelString) + + // then + expect(resolve).toThrow() + }) + + it("given empty provider '/claude-sonnet-4', when resolved, then throws Error", () => { + // given + const modelString = "/claude-sonnet-4" + + // when + const resolve = () => resolveRunModel(modelString) + + // then + expect(resolve).toThrow() + }) + + it("given trailing slash 'anthropic/', when resolved, then throws Error", () => { + // given + const modelString = "anthropic/" + + // when + const resolve = () => resolveRunModel(modelString) + + // then + expect(resolve).toThrow() + }) +}) diff --git a/src/cli/run/model-resolver.ts b/src/cli/run/model-resolver.ts new file mode 100644 index 000000000..3db60b4ba --- /dev/null +++ b/src/cli/run/model-resolver.ts @@ -0,0 +1,29 @@ +export function resolveRunModel( + modelString?: string +): { providerID: string; modelID: string } | undefined { + if (modelString === undefined) { + return undefined + } + + const trimmed = modelString.trim() + if (trimmed.length === 0) { + throw new Error("Model string cannot be empty") + } + + const parts = trimmed.split("/") + if (parts.length < 2) { + throw new Error("Model string must be in 'provider/model' format") + } + + const providerID = parts[0] + if (providerID.length === 0) { + throw new Error("Provider cannot be empty") + } + + const modelID = parts.slice(1).join("/") + if (modelID.length === 0) { + throw new Error("Model ID cannot be empty") + } + + return { providerID, modelID } +} diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index c56d07021..84dd22a42 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -7,6 +7,7 @@ import { resolveSession } from "./session-resolver" import { createJsonOutputManager } from "./json-output" import { executeOnCompleteHook } from "./on-complete-hook" import { resolveRunAgent } from "./agent-resolver" +import { resolveRunModel } from "./model-resolver" import { pollForCompletion } from "./poll-for-completion" import { loadAgentProfileColors } from "./agent-profile-colors" import { suppressRunInput } from "./stdin-suppression" @@ -46,6 +47,7 @@ export async function run(options: RunOptions): Promise { const pluginConfig = loadPluginConfig(directory, { command: "run" }) const resolvedAgent = resolveRunAgent(options, pluginConfig) + const resolvedModel = resolveRunModel(options.model) const abortController = new AbortController() try { @@ -78,6 +80,10 @@ export async function run(options: RunOptions): Promise { console.log(pc.dim(`Session: ${sessionID}`)) + if (resolvedModel) { + console.log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`)) + } + const ctx: RunContext = { client, sessionID, @@ -96,6 +102,7 @@ export async function run(options: RunOptions): Promise { path: { id: sessionID }, body: { agent: resolvedAgent, + ...(resolvedModel ? { model: resolvedModel } : {}), tools: { question: false, }, diff --git a/src/cli/run/types.ts b/src/cli/run/types.ts index 98c452a9d..30bacaee7 100644 --- a/src/cli/run/types.ts +++ b/src/cli/run/types.ts @@ -4,6 +4,7 @@ export type { OpencodeClient } export interface RunOptions { message: string agent?: string + model?: string timestamp?: boolean verbose?: boolean directory?: string