From 4497903cf5335c76fa131963086901d04d03a2e3 Mon Sep 17 00:00:00 2001 From: JohnnyVicious Date: Mon, 13 Apr 2026 07:45:38 +0200 Subject: [PATCH] feat(setup): persist command defaults --- README.md | 23 +++- plugins/opencode/agents/opencode-rescue.md | 4 +- .../opencode/commands/adversarial-review.md | 2 +- plugins/opencode/commands/rescue.md | 2 +- plugins/opencode/commands/review.md | 2 +- plugins/opencode/commands/setup.md | 6 +- plugins/opencode/scripts/lib/defaults.mjs | 94 ++++++++++++++++ plugins/opencode/scripts/lib/render.mjs | 4 + .../opencode/scripts/opencode-companion.mjs | 85 +++++++++++--- plugins/opencode/scripts/safe-command.mjs | 13 ++- .../opencode/skills/opencode-runtime/SKILL.md | 4 +- tests/companion-cli.test.mjs | 104 +++++++++++++++++- tests/defaults.test.mjs | 89 +++++++++++++++ tests/render.test.mjs | 14 ++- 14 files changed, 412 insertions(+), 34 deletions(-) create mode 100644 plugins/opencode/scripts/lib/defaults.mjs create mode 100644 tests/defaults.test.mjs diff --git a/README.md b/README.md index d48ff80..bceb4dd 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 `opencode:opencode-rescue` subagent. Supports `--model`, `--free`, `--agent`, `--resume`, `--fresh`, `--worktree`, `--wait`, and `--background`. +- `/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 `opencode:opencode-rescue` subagent. Supports `--model`, `--free`, `--agent`, `--resume`, `--fresh`, `--worktree`, `--wait`, and `--background`. 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/agents/opencode-rescue.md b/plugins/opencode/agents/opencode-rescue.md index a4701a2..b91bb09 100644 --- a/plugins/opencode/agents/opencode-rescue.md +++ b/plugins/opencode/agents/opencode-rescue.md @@ -26,8 +26,8 @@ Forwarding rules: - Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. - Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`. This subagent only forwards to `task`. -- Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan). -- Leave model unset by default. Only add `--model` or `--free` when the user explicitly asks for a specific model or a free-tier pick. `--free` and `--model` are mutually exclusive. +- Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan). The companion may apply a saved setup default when `--agent` is omitted. +- Leave model unset by default. Only add `--model` or `--free` when the user explicitly asks for a specific model or a free-tier pick. The companion may apply a saved setup default when both flags are omitted. `--free` and `--model` are mutually exclusive. - Treat `--agent `, `--model `, and `--free` as runtime controls and do not include them in the task text you pass through. - If the request includes `--worktree`, pass `--worktree` through to `task`. This runs OpenCode in an isolated git worktree instead of editing the working directory in-place. - Default to a write-capable OpenCode run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. 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 2545680..b037d07 100644 --- a/plugins/opencode/commands/rescue.md +++ b/plugins/opencode/commands/rescue.md @@ -17,7 +17,7 @@ Execution mode: - If the request includes `--wait`, run the `opencode:opencode-rescue` subagent in the foreground. - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. -- `--model`, `--free`, and `--agent` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. `--free` tells the companion to pick a random first-party `opencode/*` free-tier model from `opencode models`; it is restricted to `opencode/*` because OpenRouter free models have inconsistent tool-use support. `--free` is mutually exclusive with `--model`. +- `--model`, `--free`, and `--agent` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. If `--model`/`--free` or `--agent` are omitted, companion-level defaults configured by `/opencode:setup` may apply. `--free` tells the companion to pick a random first-party `opencode/*` free-tier model from `opencode models`; it is restricted to `opencode/*` because OpenRouter free models have inconsistent tool-use support. `--free` is mutually exclusive with `--model`. - `--worktree` is an isolation flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. When present, OpenCode runs in an isolated git worktree instead of editing the working directory in-place. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. 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 0aeae1a..9e40143 100644 --- a/plugins/opencode/scripts/safe-command.mjs +++ b/plugins/opencode/scripts/safe-command.mjs @@ -64,12 +64,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/plugins/opencode/skills/opencode-runtime/SKILL.md b/plugins/opencode/skills/opencode-runtime/SKILL.md index 192caa3..8d2e85c 100644 --- a/plugins/opencode/skills/opencode-runtime/SKILL.md +++ b/plugins/opencode/skills/opencode-runtime/SKILL.md @@ -18,8 +18,8 @@ Execution rules: - Use `task` for every rescue request, including diagnosis, planning, research, and explicit fix requests. - You may use the `opencode-prompting` skill to rewrite the user's request into a tighter OpenCode prompt before the single `task` call. - That prompt drafting is the only Claude-side work allowed. Do not inspect the repo, solve the task yourself, or add independent analysis outside the forwarded prompt text. -- Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan). -- Leave model unset by default. Add `--model` only when the user explicitly asks for one, or `--free` when they explicitly ask for a free-tier pick. `--free` and `--model` are mutually exclusive. +- Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan). The companion may apply a saved setup default when `--agent` is omitted. +- Leave model unset by default. Add `--model` only when the user explicitly asks for one, or `--free` when they explicitly ask for a free-tier pick. The companion may apply a saved setup default when both flags are omitted. `--free` and `--model` are mutually exclusive. Command selection: - Use exactly one `task` invocation per rescue handoff. 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", () => {