diff --git a/README.md b/README.md index 07d1484..49bbca3 100644 --- a/README.md +++ b/README.md @@ -93,17 +93,30 @@ To check your configured providers: | `/codex:status` | `/opencode:status` | Show running/recent jobs | | `/codex:result` | `/opencode:result` | Show finished job output | | `/codex:cancel` | `/opencode:cancel` | Cancel active background job | -| `/codex:setup` | `/opencode:setup` | Check install/auth, toggle review gate | +| `/codex:setup` | `/opencode:setup` | Check install/auth, configure defaults, toggle review gate | ## Slash Commands -- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base `, `--pr `, `--model `, `--free`, `--wait`, and `--background`. -- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base `, `--pr `, `--model `, `--free`, `--wait`, `--background`, and custom focus text. -- `/opencode:rescue` -- Delegates a task to OpenCode via the `safe-command.mjs` bridge, which validates flags and feeds the task text through a shell-insulated heredoc. Supports `--model`, `--free`, `--agent`, `--resume`, `--fresh`, `--worktree`, `--wait`, and `--background`. Foreground is the default; `--wait` is an explicit no-op alias for foreground; `--background` detaches a worker and returns a job id you can poll with `/opencode:status`. +- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base `, `--pr `, `--model `, `--free`, `--wait`, and `--background`. Uses the saved default model when configured and no runtime model flag is supplied. +- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base `, `--pr `, `--model `, `--free`, `--wait`, `--background`, and custom focus text. Uses the saved default model when configured and no runtime model flag is supplied. +- `/opencode:rescue` -- Delegates a task to OpenCode via the `safe-command.mjs` bridge, which validates flags and feeds the task text through a shell-insulated heredoc. Supports `--model`, `--free`, `--agent`, `--resume`, `--fresh`, `--worktree`, `--wait`, and `--background`. Foreground is the default; `--wait` is an explicit no-op alias for foreground; `--background` detaches a worker and returns a job id you can poll with `/opencode:status`. Uses saved default model/agent values when configured and no runtime flag is supplied. - `/opencode:status` -- Shows running/recent OpenCode jobs for the current repo. - `/opencode:result` -- Shows final output for a finished job, including OpenCode session ID for resuming. - `/opencode:cancel` -- Cancels an active OpenCode job. -- `/opencode:setup` -- Checks OpenCode install/auth, can enable/disable the review gate hook, and can configure review-gate throttles. +- `/opencode:setup` -- Checks OpenCode install/auth, can configure default model/agent values, can enable/disable the review gate hook, and can configure review-gate throttles. + +## Command Defaults + +Persist model and rescue-agent defaults with setup: + +``` +/opencode:setup --default-model anthropic/claude-opus-4-6 --default-agent build +/opencode:setup --default-model off +/opencode:setup --default-agent off +``` + +- `--default-model ` applies to `/opencode:review`, `/opencode:adversarial-review`, and `/opencode:rescue` unless a command includes `--model` or `--free`. +- `--default-agent ` applies to `/opencode:rescue` unless the command includes `--agent`. Review commands keep using the bundled read-only review agent. ## Review Gate diff --git a/plugins/opencode/commands/adversarial-review.md b/plugins/opencode/commands/adversarial-review.md index 5d4513e..7842756 100644 --- a/plugins/opencode/commands/adversarial-review.md +++ b/plugins/opencode/commands/adversarial-review.md @@ -37,7 +37,7 @@ Argument handling: - Do not strip `--wait`, `--background`, `--model`, `--free`, or `--pr` yourself. - Adversarial reviews support custom focus text. Any text after flags is treated as a focus area. - The companion script handles `--adversarial` internally. -- `--model ` overrides OpenCode's default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it. +- `--model ` overrides the saved setup default model and OpenCode's own default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it. - `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given. - `--pr ` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated. diff --git a/plugins/opencode/commands/rescue.md b/plugins/opencode/commands/rescue.md index 1058a63..2b8a625 100644 --- a/plugins/opencode/commands/rescue.md +++ b/plugins/opencode/commands/rescue.md @@ -34,9 +34,9 @@ Flag handling (all of these are recognized, validated, and forwarded by `safe-co - `--worktree` — run OpenCode in an isolated git worktree instead of editing the working directory in-place. - `--resume` (or `--resume-last`) — continue the most recent OpenCode session from this Claude session. The bridge translates `--resume` into the companion-native `--resume-last`. - `--fresh` — explicit marker that the task must NOT resume. The bridge strips it (the absence of `--resume-last` already conveys "fresh"). -- `--model ` — override OpenCode's default model for this single task. Value must match `[A-Za-z0-9._/:-]+`. +- `--model ` — override OpenCode's default model for this single task. Value must match `[A-Za-z0-9._/:-]+`. When `--model` and `--free` are both omitted, the companion applies the saved default model from `/opencode:setup --default-model` if one is configured. - `--free` — tells the companion to pick a random first-party `opencode/*` free-tier model from `opencode models`. Restricted to `opencode/*` because OpenRouter free models have inconsistent tool-use support. -- `--agent ` — override the OpenCode agent. Value must be `build` or `plan`. +- `--agent ` — override the OpenCode agent. Value must be `build` or `plan`. When `--agent` is omitted, the companion applies the saved default agent from `/opencode:setup --default-agent` if one is configured. - `--free` and `--model` are mutually exclusive — the bridge rejects payloads that include both. If the user supplies both, return the bridge's error verbatim and stop. Resume detection (runs before the final bridged call, only when neither `--resume` nor `--fresh` is in the raw user request): diff --git a/plugins/opencode/commands/review.md b/plugins/opencode/commands/review.md index 7ae6a12..1054d6d 100644 --- a/plugins/opencode/commands/review.md +++ b/plugins/opencode/commands/review.md @@ -39,7 +39,7 @@ Argument handling: - The companion script parses `--wait` and `--background`, but Claude Code's `Bash(..., run_in_background: true)` is what actually detaches the run. - `/opencode:review` is native-review only. It does not support staged-only review, unstaged-only review, or extra focus text. - If the user needs custom review instructions or more adversarial framing, they should use `/opencode:adversarial-review`. -- `--model ` overrides OpenCode's default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it. +- `--model ` overrides the saved setup default model and OpenCode's own default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it. - `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given. - `--pr ` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated. Pass it through verbatim if the user supplied it. - **PR reference extraction (REQUIRED)**: if the user's input contains a PR reference like `PR #390`, `pr #390`, `PR 390`, or `pr 390` (e.g. `/opencode:review on PR #390`), you MUST extract the number yourself and pass it as `--pr 390`. Do not pass `PR #390` literally to bash — bash strips unquoted `#NNN` tokens as comments before they reach the companion script. Example: `node ... review --pr 390`, NOT `node ... review on PR #390`. diff --git a/plugins/opencode/commands/setup.md b/plugins/opencode/commands/setup.md index c1d8655..f10ebe1 100644 --- a/plugins/opencode/commands/setup.md +++ b/plugins/opencode/commands/setup.md @@ -1,6 +1,6 @@ --- -description: Check whether the local OpenCode CLI is ready and optionally toggle the stop-time review gate -argument-hint: '[--enable-review-gate|--disable-review-gate] [--review-gate-max ] [--review-gate-cooldown ]' +description: Check whether the local OpenCode CLI is ready, configure defaults, and optionally toggle the stop-time review gate +argument-hint: '[--default-model ] [--default-agent ] [--enable-review-gate|--disable-review-gate] [--review-gate-max ] [--review-gate-cooldown ]' allowed-tools: Bash(node:*), Bash(npm:*), Bash(brew:*), Bash(curl:*), AskUserQuestion --- @@ -39,3 +39,5 @@ Output rules: - Present the final setup output to the user. - If installation was skipped, present the original setup output. - If OpenCode is installed but no provider is configured, guide the user to run `!opencode providers` to set up authentication. +- `--default-model ` sets the model used by `/opencode:review`, `/opencode:adversarial-review`, and `/opencode:rescue` when no `--model` or `--free` flag is supplied. Use `--default-model off` to clear it. +- `--default-agent ` sets the rescue/task agent used when no `--agent` flag is supplied. Review commands keep using the bundled read-only review agent. Use `--default-agent off` to clear it. diff --git a/plugins/opencode/scripts/lib/defaults.mjs b/plugins/opencode/scripts/lib/defaults.mjs new file mode 100644 index 0000000..a230ec7 --- /dev/null +++ b/plugins/opencode/scripts/lib/defaults.mjs @@ -0,0 +1,94 @@ +// Persistent command-default helpers for the OpenCode companion. +// +// `/opencode:setup` can persist defaults in `state.config.defaults`. +// These helpers keep precedence rules centralized and testable: +// explicit runtime flags win, otherwise saved defaults apply. + +import { parseModelString } from "./model.mjs"; + +const SUPPORTED_DEFAULT_AGENTS = new Set(["build", "plan"]); + +/** + * @param {Record|undefined|null} options + * @param {string} key + * @returns {boolean} + */ +export function hasOwnOption(options, key) { + return Object.prototype.hasOwnProperty.call(options ?? {}, key); +} + +/** + * Normalize persisted defaults read from state. Invalid or missing values are + * ignored so a hand-edited state file cannot break every command invocation. + * @param {unknown} raw + * @returns {{ model: string | null, agent: string | null }} + */ +export function normalizeDefaults(raw) { + const defaults = raw && typeof raw === "object" ? raw : {}; + + const modelRaw = typeof defaults.model === "string" ? defaults.model.trim() : ""; + const model = modelRaw && parseModelString(modelRaw) ? modelRaw : null; + + const agentRaw = typeof defaults.agent === "string" ? defaults.agent.trim() : ""; + const agent = SUPPORTED_DEFAULT_AGENTS.has(agentRaw) ? agentRaw : null; + + return { model, agent }; +} + +/** + * Parse a `/opencode:setup --default-model` value. Returns null for "off". + * @param {unknown} value + * @returns {string | null} + */ +export function parseDefaultModelSetting(value) { + const raw = typeof value === "string" ? value.trim() : ""; + if (raw === "off") return null; + if (!raw || !parseModelString(raw)) { + throw new Error( + `--default-model must be "off" or a provider/model-id value ` + + `(e.g. anthropic/claude-opus-4-6).` + ); + } + return raw; +} + +/** + * Parse a `/opencode:setup --default-agent` value. Returns null for "off". + * @param {unknown} value + * @returns {"build" | "plan" | null} + */ +export function parseDefaultAgentSetting(value) { + const raw = typeof value === "string" ? value.trim() : ""; + if (raw === "off") return null; + if (!SUPPORTED_DEFAULT_AGENTS.has(raw)) { + throw new Error(`--default-agent must be "build", "plan", or "off".`); + } + return raw; +} + +/** + * Apply a persisted model default when the user did not explicitly supply + * either `--model` or `--free`. + * @param {Record} options + * @param {{ model?: string | null }} defaults + * @returns {Record} + */ +export function applyDefaultModelOptions(options, defaults) { + if (hasOwnOption(options, "model") || options?.free) return options; + if (!defaults?.model) return options; + return { ...options, model: defaults.model }; +} + +/** + * Resolve the task agent using explicit CLI args first, then persisted + * defaults, then the existing write/read-only fallback. + * @param {Record} options + * @param {{ agent?: string | null }} defaults + * @param {boolean} isWrite + * @returns {string} + */ +export function resolveTaskAgentName(options, defaults, isWrite) { + if (hasOwnOption(options, "agent")) return options.agent; + if (defaults?.agent) return defaults.agent; + return isWrite ? "build" : "plan"; +} diff --git a/plugins/opencode/scripts/lib/render.mjs b/plugins/opencode/scripts/lib/render.mjs index b1fc1d5..2cdd5f0 100644 --- a/plugins/opencode/scripts/lib/render.mjs +++ b/plugins/opencode/scripts/lib/render.mjs @@ -207,6 +207,10 @@ export function renderSetup(status) { } else if (status.installed) { lines.push(`- **Providers**: None configured. Run \`!opencode providers\` to set up.`); } + if (status.defaults) { + lines.push(`- **Default Model**: ${status.defaults.model ? `\`${status.defaults.model}\`` : "Unset"}`); + lines.push(`- **Default Agent**: ${status.defaults.agent ? `\`${status.defaults.agent}\`` : "Unset"}`); + } if (status.reviewGate !== undefined) { const parts = [status.reviewGate ? "Enabled" : "Disabled"]; if (status.reviewGateMaxPerSession != null) { diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index ad6a998..e97d7c7 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -84,6 +84,14 @@ import { import { readJson, readDenyRules } from "./lib/fs.mjs"; import { resolveReviewAgent } from "./lib/review-agent.mjs"; import { parseModelString, selectFreeModel } from "./lib/model.mjs"; +import { + applyDefaultModelOptions, + hasOwnOption, + normalizeDefaults, + parseDefaultAgentSetting, + parseDefaultModelSetting, + resolveTaskAgentName, +} from "./lib/defaults.mjs"; const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(import.meta.dirname, ".."); @@ -125,7 +133,7 @@ handler(argv).catch((err) => { async function handleSetup(argv) { const { options } = parseArgs(argv, { - valueOptions: ["review-gate-max", "review-gate-cooldown"], + valueOptions: ["review-gate-max", "review-gate-cooldown", "default-model", "default-agent"], booleanOptions: ["json", "enable-review-gate", "disable-review-gate"], }); @@ -155,6 +163,26 @@ async function handleSetup(argv) { reviewGateCooldownMinutesOverride = cooldown; } + let defaultModelOverride; + if (hasOwnOption(options, "default-model")) { + try { + defaultModelOverride = parseDefaultModelSetting(options["default-model"]); + } catch (err) { + console.error(err.message); + process.exit(1); + } + } + + let defaultAgentOverride; + if (hasOwnOption(options, "default-agent")) { + try { + defaultAgentOverride = parseDefaultAgentSetting(options["default-agent"]); + } catch (err) { + console.error(err.message); + process.exit(1); + } + } + const installed = await isOpencodeInstalled(); const version = installed ? await getOpencodeVersion() : null; @@ -182,7 +210,9 @@ async function handleSetup(argv) { if ( reviewGateOverride !== undefined || reviewGateMaxPerSessionOverride !== undefined || - reviewGateCooldownMinutesOverride !== undefined + reviewGateCooldownMinutesOverride !== undefined || + defaultModelOverride !== undefined || + defaultAgentOverride !== undefined ) { updateState(workspace, (state) => { state.config = state.config || {}; @@ -195,6 +225,15 @@ async function handleSetup(argv) { if (reviewGateCooldownMinutesOverride !== undefined) { state.config.reviewGateCooldownMinutes = reviewGateCooldownMinutesOverride; } + if (defaultModelOverride !== undefined || defaultAgentOverride !== undefined) { + state.config.defaults = state.config.defaults || {}; + } + if (defaultModelOverride !== undefined) { + state.config.defaults.model = defaultModelOverride; + } + if (defaultAgentOverride !== undefined) { + state.config.defaults.agent = defaultAgentOverride; + } }); } @@ -202,12 +241,14 @@ async function handleSetup(argv) { const reviewGate = finalState.config?.reviewGate ?? false; const reviewGateMaxPerSession = finalState.config?.reviewGateMaxPerSession ?? null; const reviewGateCooldownMinutes = finalState.config?.reviewGateCooldownMinutes ?? null; + const defaults = normalizeDefaults(finalState.config?.defaults); const status = { installed, version, serverRunning, providers, + defaults, reviewGate, reviewGateMaxPerSession, reviewGateCooldownMinutes, @@ -237,13 +278,14 @@ async function handleSetup(argv) { * @returns {Promise<{providerID: string, modelID: string, raw?: string} | null>} */ async function resolveRequestedModel(options) { - if (options.free && options.model) { + const hasModel = hasOwnOption(options, "model"); + if (options.free && hasModel) { throw new Error("--free and --model are mutually exclusive; pick one."); } if (options.free) { return selectFreeModel(); } - if (options.model) { + if (hasModel) { const parsed = parseModelString(options.model); if (!parsed) { throw new Error( @@ -251,7 +293,7 @@ async function resolveRequestedModel(options) { `e.g. openrouter/anthropic/claude-haiku-4.5)` ); } - return { ...parsed, raw: options.model }; + return { ...parsed, raw: options.model.trim() }; } return null; } @@ -268,18 +310,22 @@ async function handleReview(argv) { process.exit(1); } + const workspace = await resolveWorkspace(); + const state = loadState(workspace); + const defaults = normalizeDefaults(state.config?.defaults); + const modelOptions = applyDefaultModelOptions(options, defaults); + let requestedModel; try { - requestedModel = await resolveRequestedModel(options); + requestedModel = await resolveRequestedModel(modelOptions); } catch (err) { console.error(err.message); process.exit(1); } - const workspace = await resolveWorkspace(); const job = createJobRecord(workspace, "review", { base: options.base, - model: options.model, + model: requestedModel?.raw ?? modelOptions.model, pr: prNumber, }); @@ -299,7 +345,7 @@ async function handleReview(argv) { }, PLUGIN_ROOT); const reviewAgent = await resolveReviewAgent(client, log); - const modelLabel = requestedModel?.raw ?? requestedModel ?? null; + const modelLabel = requestedModel?.raw ?? null; report("reviewing", "Running review..."); log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}${modelLabel ? `, model: ${modelLabel}${options.free ? " (--free picked)" : ""}` : ""}${prNumber ? `, pr: #${prNumber}` : ""}`); @@ -339,9 +385,14 @@ async function handleAdversarialReview(argv) { booleanOptions: ["wait", "background", "free"], }); + const workspace = await resolveWorkspace(); + const state = loadState(workspace); + const defaults = normalizeDefaults(state.config?.defaults); + const modelOptions = applyDefaultModelOptions(options, defaults); + let requestedModel; try { - requestedModel = await resolveRequestedModel(options); + requestedModel = await resolveRequestedModel(modelOptions); } catch (err) { console.error(err.message); process.exit(1); @@ -367,11 +418,10 @@ async function handleAdversarialReview(argv) { } } - const workspace = await resolveWorkspace(); const job = createJobRecord(workspace, "adversarial-review", { base: options.base, focus, - model: options.model, + model: requestedModel?.raw ?? modelOptions.model, pr: prNumber, }); @@ -444,20 +494,24 @@ async function handleTask(argv) { process.exit(1); } + const workspace = await resolveWorkspace(); + const state = loadState(workspace); + const defaults = normalizeDefaults(state.config?.defaults); + const modelOptions = applyDefaultModelOptions(options, defaults); + // Resolve --free / --model once here so background workers inherit a // concrete model string and can't drift if `opencode models` changes // between dispatch and execution. let requestedModel; try { - requestedModel = await resolveRequestedModel(options); + requestedModel = await resolveRequestedModel(modelOptions); } catch (err) { console.error(err.message); process.exit(1); } - const workspace = await resolveWorkspace(); const isWrite = options.write !== undefined ? options.write : true; - const agentName = options.agent ?? (isWrite ? "build" : "plan"); + const agentName = resolveTaskAgentName(options, defaults, isWrite); const useWorktree = Boolean(options.worktree); if (useWorktree && !isWrite) { @@ -499,6 +553,7 @@ async function handleTask(argv) { const job = createJobRecord(workspace, "task", { agent: agentName, + model: requestedModel?.raw ?? modelOptions.model, resumeSessionId, worktree: useWorktree, }); diff --git a/plugins/opencode/scripts/safe-command.mjs b/plugins/opencode/scripts/safe-command.mjs index f9174dd..eb4f09d 100644 --- a/plugins/opencode/scripts/safe-command.mjs +++ b/plugins/opencode/scripts/safe-command.mjs @@ -194,12 +194,21 @@ function parseSetupArgs(text) { out.push(token); continue; } - if (token === "--review-gate-max" || token === "--review-gate-cooldown") { + if ( + token === "--review-gate-max" || + token === "--review-gate-cooldown" || + token === "--default-model" || + token === "--default-agent" + ) { const value = tokens[++i]; if (value == null) { throw new Error(`${token} requires a value.`); } - if (value !== "off" && !/^[1-9][0-9]*$/.test(value)) { + if ( + (token === "--review-gate-max" || token === "--review-gate-cooldown") && + value !== "off" && + !/^[1-9][0-9]*$/.test(value) + ) { throw new Error(`${token} must be a positive integer or "off".`); } out.push(token, value); diff --git a/tests/companion-cli.test.mjs b/tests/companion-cli.test.mjs index 8890cfa..28a2f4c 100644 --- a/tests/companion-cli.test.mjs +++ b/tests/companion-cli.test.mjs @@ -82,7 +82,13 @@ describe("opencode-companion CLI", () => { it("setup validates all config inputs before mutating state", async () => { saveState(workspace, { - config: { reviewGate: false }, + config: { + reviewGate: false, + defaults: { + model: "anthropic/claude-sonnet-4-5", + agent: "build", + }, + }, jobs: [], }); @@ -99,6 +105,92 @@ describe("opencode-companion CLI", () => { assert.match(result.stderr, /--review-gate-max must be a positive integer/); assert.equal(loadState(workspace).config.reviewGate, false); assert.equal(loadState(workspace).config.reviewGateMaxPerSession, undefined); + assert.deepEqual(loadState(workspace).config.defaults, { + model: "anthropic/claude-sonnet-4-5", + agent: "build", + }); + }); + + it("setup persists default model and agent", async () => { + const result = await runNodeScript([ + companionScript, + "setup", + "--json", + "--default-model", + "anthropic/claude-opus-4-6", + "--default-agent", + "plan", + ]); + + assert.equal(result.exitCode, 0); + const status = JSON.parse(result.stdout); + assert.deepEqual(status.defaults, { + model: "anthropic/claude-opus-4-6", + agent: "plan", + }); + assert.deepEqual(loadState(workspace).config.defaults, { + model: "anthropic/claude-opus-4-6", + agent: "plan", + }); + }); + + it("setup can clear default model and agent", async () => { + saveState(workspace, { + config: { + defaults: { + model: "anthropic/claude-opus-4-6", + agent: "build", + }, + }, + jobs: [], + }); + + const result = await runNodeScript([ + companionScript, + "setup", + "--json", + "--default-model", + "off", + "--default-agent", + "off", + ]); + + assert.equal(result.exitCode, 0); + const status = JSON.parse(result.stdout); + assert.deepEqual(status.defaults, { model: null, agent: null }); + assert.deepEqual(loadState(workspace).config.defaults, { + model: null, + agent: null, + }); + }); + + it("setup rejects invalid defaults before mutating state", async () => { + saveState(workspace, { + config: { + defaults: { + model: "anthropic/claude-sonnet-4-5", + agent: "build", + }, + }, + jobs: [], + }); + + const result = await runNodeScript([ + companionScript, + "setup", + "--json", + "--default-model", + "claude-opus-4-6", + "--default-agent", + "plan", + ]); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /--default-model/); + assert.deepEqual(loadState(workspace).config.defaults, { + model: "anthropic/claude-sonnet-4-5", + agent: "build", + }); }); it("foreground worktree setup failure marks the created job failed", async () => { @@ -122,7 +214,11 @@ describe("opencode-companion CLI", () => { it("safe-command forwards setup multi-token args from stdin", async () => { const result = await runNodeScript( [safeCommandScript, "setup"], - { input: "--enable-review-gate --review-gate-max 2 --review-gate-cooldown off\n" } + { + input: + "--enable-review-gate --review-gate-max 2 --review-gate-cooldown off " + + "--default-model anthropic/claude-opus-4-6 --default-agent build\n", + } ); assert.equal(result.exitCode, 0); @@ -130,6 +226,10 @@ describe("opencode-companion CLI", () => { assert.equal(status.reviewGate, true); assert.equal(status.reviewGateMaxPerSession, 2); assert.equal(status.reviewGateCooldownMinutes, null); + assert.deepEqual(status.defaults, { + model: "anthropic/claude-opus-4-6", + agent: "build", + }); }); it("safe-command rejects shell-shaped job refs as data", async () => { diff --git a/tests/defaults.test.mjs b/tests/defaults.test.mjs new file mode 100644 index 0000000..5706715 --- /dev/null +++ b/tests/defaults.test.mjs @@ -0,0 +1,89 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyDefaultModelOptions, + hasOwnOption, + normalizeDefaults, + parseDefaultAgentSetting, + parseDefaultModelSetting, + resolveTaskAgentName, +} from "../plugins/opencode/scripts/lib/defaults.mjs"; + +describe("command defaults", () => { + it("normalizes valid persisted defaults", () => { + assert.deepEqual( + normalizeDefaults({ + model: " anthropic/claude-opus-4-6 ", + agent: "build", + }), + { + model: "anthropic/claude-opus-4-6", + agent: "build", + }, + ); + }); + + it("ignores invalid persisted defaults", () => { + assert.deepEqual( + normalizeDefaults({ + model: "claude-opus-4-6", + agent: "custom-agent", + }), + { model: null, agent: null }, + ); + }); + + it("parses setup default model values", () => { + assert.equal( + parseDefaultModelSetting(" anthropic/claude-opus-4-6 "), + "anthropic/claude-opus-4-6", + ); + assert.equal(parseDefaultModelSetting("off"), null); + assert.throws( + () => parseDefaultModelSetting("claude-opus-4-6"), + /--default-model/, + ); + }); + + it("parses setup default agent values", () => { + assert.equal(parseDefaultAgentSetting("build"), "build"); + assert.equal(parseDefaultAgentSetting("plan"), "plan"); + assert.equal(parseDefaultAgentSetting("off"), null); + assert.throws( + () => parseDefaultAgentSetting("review"), + /--default-agent/, + ); + }); + + it("applies the default model only when no explicit model selector exists", () => { + const defaults = { model: "anthropic/claude-opus-4-6" }; + + assert.deepEqual( + applyDefaultModelOptions({}, defaults), + { model: "anthropic/claude-opus-4-6" }, + ); + assert.deepEqual( + applyDefaultModelOptions({ model: "openrouter/anthropic/claude-haiku-4.5" }, defaults), + { model: "openrouter/anthropic/claude-haiku-4.5" }, + ); + assert.deepEqual( + applyDefaultModelOptions({ free: true }, defaults), + { free: true }, + ); + }); + + it("treats an explicitly present model option as intentional even when empty", () => { + assert.equal(hasOwnOption({ model: "" }, "model"), true); + assert.deepEqual( + applyDefaultModelOptions({ model: "" }, { model: "anthropic/claude-opus-4-6" }), + { model: "" }, + ); + }); + + it("resolves task agent precedence", () => { + assert.equal(resolveTaskAgentName({ agent: "custom" }, { agent: "plan" }, true), "custom"); + assert.equal(resolveTaskAgentName({}, { agent: "plan" }, true), "plan"); + assert.equal(resolveTaskAgentName({}, {}, true), "build"); + assert.equal(resolveTaskAgentName({}, {}, false), "plan"); + }); +}); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 3a76d81..3f66ac6 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -57,10 +57,22 @@ describe("renderReview", () => { describe("renderSetup", () => { it("renders installed status", () => { - const output = renderSetup({ installed: true, version: "1.3.9", serverRunning: true, providers: ["anthropic"], reviewGate: false }); + const output = renderSetup({ + installed: true, + version: "1.3.9", + serverRunning: true, + providers: ["anthropic"], + defaults: { + model: "anthropic/claude-opus-4-6", + agent: "build", + }, + reviewGate: false, + }); assert.ok(output.includes("Yes")); assert.ok(output.includes("1.3.9")); assert.ok(output.includes("anthropic")); + assert.ok(output.includes("anthropic/claude-opus-4-6")); + assert.ok(output.includes("build")); }); it("renders not installed status", () => {