diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7c93f6f..d5b7260 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "opencode plugins for Claude Code: delegate tasks and reviews to a local opencode server.", - "version": "0.1.4" + "version": "0.1.5" }, "plugins": [ { "name": "opencode", "description": "Use opencode from Claude Code to review code or delegate tasks.", - "version": "0.1.4", + "version": "0.1.5", "author": { "name": "opencode-plugin-cc contributors" }, diff --git a/package-lock.json b/package-lock.json index b22ab89..efc1578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-plugin-cc", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-plugin-cc", - "version": "0.1.4", + "version": "0.1.5", "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.0.0" diff --git a/package.json b/package.json index 51fedb0..381c439 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-cc", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "description": "Use opencode from Claude Code to review code or delegate tasks.", diff --git a/plugins/opencode/.claude-plugin/plugin.json b/plugins/opencode/.claude-plugin/plugin.json index f7f7871..17086b1 100644 --- a/plugins/opencode/.claude-plugin/plugin.json +++ b/plugins/opencode/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "opencode", - "version": "0.1.4", + "version": "0.1.5", "description": "Use opencode from Claude Code to review code or delegate tasks.", "author": { "name": "opencode-plugin-cc contributors" diff --git a/plugins/opencode/commands/rescue.md b/plugins/opencode/commands/rescue.md index c09cb55..3c1bc97 100644 --- a/plugins/opencode/commands/rescue.md +++ b/plugins/opencode/commands/rescue.md @@ -43,7 +43,7 @@ Operating rules: - Return the opencode companion stdout verbatim to the user. - Do not paraphrase, summarize, rewrite, or add commentary before or after it. - Do not ask the subagent to inspect files, monitor progress, poll `/opencode:status`, fetch `/opencode:result`, call `/opencode:cancel`, summarize output, or do follow-up work of its own. -- Leave the model unset unless the user explicitly asks for one. opencode picks the default model from its own configuration (`~/.config/opencode/opencode.json`). +- Pass `--model ` whenever the user mentions any model name, even colloquial (e.g. "minimax m3", "gpt-4o", "opus"). The companion resolves the name to the correct `provider/model` ID automatically. Do not investigate models yourself. Leave `--model` unset only when the user does not mention any model at all. - Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command. - If the helper reports that opencode is missing or unauthenticated, stop and tell the user to run `/opencode:setup`. - If the user did not supply a request, ask what opencode should investigate or fix. diff --git a/plugins/opencode/scripts/lib/model-resolver.mjs b/plugins/opencode/scripts/lib/model-resolver.mjs new file mode 100644 index 0000000..5f8ccac --- /dev/null +++ b/plugins/opencode/scripts/lib/model-resolver.mjs @@ -0,0 +1,107 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { runCommand } from "./process.mjs"; +import { resolveStateDir } from "./state.mjs"; + +const CACHE_FILE_NAME = "models-cache.json"; +const CACHE_TTL_MS = 60 * 60 * 1000; + +function opencodeBin() { + return process.env.OPENCODE_BIN || "opencode"; +} + +function resolveCacheFile(cwd) { + return path.join(resolveStateDir(cwd), CACHE_FILE_NAME); +} + +function readCache(cwd) { + const cacheFile = resolveCacheFile(cwd); + if (!fs.existsSync(cacheFile)) return null; + try { + const data = JSON.parse(fs.readFileSync(cacheFile, "utf8")); + if (!data.cachedAt || !Array.isArray(data.models)) return null; + if (Date.now() - new Date(data.cachedAt).getTime() > CACHE_TTL_MS) return null; + return data.models; + } catch { + return null; + } +} + +function writeCache(cwd, models) { + const cacheFile = resolveCacheFile(cwd); + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + fs.writeFileSync( + cacheFile, + `${JSON.stringify({ cachedAt: new Date().toISOString(), models }, null, 2)}\n`, + "utf8" + ); +} + +function fetchModels(cwd) { + const result = runCommand(opencodeBin(), ["models"], { cwd }); + if (result.status !== 0 || !result.stdout) return []; + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /^[a-zA-Z0-9][\w.-]*\/[a-zA-Z0-9][\w.-]*$/.test(line)); +} + +function getCachedModels(cwd) { + const cached = readCache(cwd); + if (cached) return { models: cached, fromCache: true }; + const models = fetchModels(cwd); + if (models.length > 0) writeCache(cwd, models); + return { models, fromCache: false }; +} + +export function tokenize(name) { + return String(name ?? "") + .toLowerCase() + .split(/[\s\-_]+/) + .filter(Boolean); +} + +export function matchCandidates(tokens, modelList) { + if (!tokens.length) return []; + const lowerTokens = tokens.map((t) => t.toLowerCase()); + return modelList.filter((id) => { + const lower = id.toLowerCase(); + return lowerTokens.every((token) => lower.includes(token)); + }); +} + +export function preferDirect(candidates) { + return [...candidates].sort((a, b) => { + const scoreA = a.startsWith("opencode/") ? 0 : 1; + const scoreB = b.startsWith("opencode/") ? 0 : 1; + return scoreB - scoreA; + }); +} + +export function resolveModelName(rawName, cwd) { + if (!rawName) return { resolved: null, candidates: [], fromCache: false }; + + if (rawName.includes("/")) { + return { resolved: rawName, candidates: [], fromCache: false }; + } + + const { models, fromCache } = getCachedModels(cwd); + const tokens = tokenize(rawName); + const candidates = matchCandidates(tokens, models); + const sorted = preferDirect(candidates); + + if (sorted.length === 0) { + return { resolved: null, candidates: [], fromCache }; + } + + const topScore = sorted[0].startsWith("opencode/") ? 0 : 1; + const topTier = sorted.filter((id) => (id.startsWith("opencode/") ? 0 : 1) === topScore); + + if (topTier.length === 1) { + return { resolved: sorted[0], candidates: [], fromCache }; + } + + return { resolved: null, candidates: sorted, fromCache }; +} diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 56e27e5..e73bfd7 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -52,6 +52,7 @@ import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; +import { resolveModelName } from "./lib/model-resolver.mjs"; import { renderReviewResult, renderStoredJobResult, @@ -631,6 +632,29 @@ function enqueueBackgroundTask(cwd, job, request) { }; } +function buildModelResolutionError(rawModel, candidates) { + if (candidates.length > 0) { + const list = candidates.map((c) => ` ${c}`).join("\n"); + return ( + `Could not resolve model "${rawModel}" — multiple candidates found:\n${list}\n\n` + + `Re-run with the exact model ID, e.g. --model ${candidates[0]}\n` + ); + } + return `No model found matching "${rawModel}". Run \`opencode models\` to see available models.\n`; +} + +async function resolveModelArg(rawModel, cwd) { + if (!rawModel) return { model: null, failed: false }; + if (rawModel.includes("/")) return { model: rawModel, failed: false }; + + const { resolved, candidates } = resolveModelName(rawModel, cwd); + if (resolved) return { model: resolved, failed: false }; + + process.stdout.write(buildModelResolutionError(rawModel, candidates)); + process.exitCode = 1; + return { model: null, failed: true }; +} + async function handleReviewCommand(argv, config) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["base", "scope", "model", "cwd"], @@ -643,6 +667,9 @@ async function handleReviewCommand(argv, config) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); const focusText = positionals.join(" ").trim(); + const rawModel = normalizeRequestedModel(options.model); + const { model: resolvedModel, failed: modelFailed } = await resolveModelArg(rawModel, cwd); + if (modelFailed) return; const target = resolveReviewTarget(cwd, { base: options.base, scope: options.scope @@ -665,7 +692,7 @@ async function handleReviewCommand(argv, config) { cwd, base: options.base, scope: options.scope, - model: options.model, + model: resolvedModel, focusText, reviewName: config.reviewName, onProgress: progress @@ -691,8 +718,10 @@ async function handleTask(argv) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); - const model = normalizeRequestedModel(options.model); + const rawModel = normalizeRequestedModel(options.model); const prompt = readTaskPrompt(cwd, options, positionals); + const { model, failed: modelFailed } = await resolveModelArg(rawModel, cwd); + if (modelFailed) return; const contextFiles = readContextFiles(cwd, options.context); const resumeLast = Boolean(options["resume-last"] || options.resume); diff --git a/plugins/opencode/skills/opencode-cli-runtime/SKILL.md b/plugins/opencode/skills/opencode-cli-runtime/SKILL.md index 5ad6f93..af1ea98 100644 --- a/plugins/opencode/skills/opencode-cli-runtime/SKILL.md +++ b/plugins/opencode/skills/opencode-cli-runtime/SKILL.md @@ -18,7 +18,7 @@ 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 model unset by default. Add `--model ` only when the user explicitly asks for a specific model identifier. +- Pass `--model ` whenever the user mentions any model name, even colloquial (e.g. "minimax m3", "gpt-4o", "opus"). The companion resolves the name to the correct `provider/model` ID automatically. Do not investigate models yourself. Leave `--model` unset only when the user does not mention any model at all. - 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. Command selection: diff --git a/tests/model-resolver.test.mjs b/tests/model-resolver.test.mjs new file mode 100644 index 0000000..6092814 --- /dev/null +++ b/tests/model-resolver.test.mjs @@ -0,0 +1,217 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { makeTempDir } from "./helpers.mjs"; +import { + tokenize, + matchCandidates, + preferDirect, + resolveModelName +} from "../plugins/opencode/scripts/lib/model-resolver.mjs"; + +// --- tokenize --- + +test("tokenize splits on spaces", () => { + assert.deepEqual(tokenize("minimax m3"), ["minimax", "m3"]); +}); + +test("tokenize splits on hyphens", () => { + assert.deepEqual(tokenize("MiniMax-M3"), ["minimax", "m3"]); +}); + +test("tokenize splits on underscores", () => { + assert.deepEqual(tokenize("gpt_4o"), ["gpt", "4o"]); +}); + +test("tokenize lowercases everything", () => { + assert.deepEqual(tokenize("Claude Opus"), ["claude", "opus"]); +}); + +test("tokenize returns empty array for empty input", () => { + assert.deepEqual(tokenize(""), []); + assert.deepEqual(tokenize(null), []); +}); + +// --- matchCandidates --- + +test("matchCandidates returns IDs containing all tokens", () => { + const models = ["minimax/MiniMax-M3", "opencode/minimax-m3-free", "anthropic/claude-opus-4"]; + const result = matchCandidates(["minimax", "m3"], models); + assert.deepEqual(result.sort(), ["minimax/MiniMax-M3", "opencode/minimax-m3-free"].sort()); +}); + +test("matchCandidates excludes IDs missing any token", () => { + const models = ["minimax/MiniMax-M2", "minimax/MiniMax-M3"]; + const result = matchCandidates(["minimax", "m3"], models); + assert.deepEqual(result, ["minimax/MiniMax-M3"]); +}); + +test("matchCandidates is case insensitive", () => { + const models = ["minimax/MiniMax-M3"]; + assert.deepEqual(matchCandidates(["MINIMAX", "M3"], models), ["minimax/MiniMax-M3"]); +}); + +test("matchCandidates returns empty array when no match", () => { + const models = ["anthropic/claude-opus-4"]; + assert.deepEqual(matchCandidates(["gpt", "4o"], models), []); +}); + +test("matchCandidates returns empty array on empty tokens", () => { + assert.deepEqual(matchCandidates([], ["anthropic/claude-opus-4"]), []); +}); + +// --- preferDirect --- + +test("preferDirect puts non-opencode/ before opencode/", () => { + const candidates = ["opencode/minimax-m3-free", "minimax/MiniMax-M3"]; + const sorted = preferDirect(candidates); + assert.equal(sorted[0], "minimax/MiniMax-M3"); + assert.equal(sorted[1], "opencode/minimax-m3-free"); +}); + +test("preferDirect keeps non-opencode/ stable when all are non-opencode/", () => { + const candidates = ["minimax/MiniMax-M3", "minimax/MiniMax-M3-lite"]; + const sorted = preferDirect(candidates); + assert.equal(sorted.length, 2); + assert.ok(!sorted[0].startsWith("opencode/")); + assert.ok(!sorted[1].startsWith("opencode/")); +}); + +test("preferDirect keeps opencode/ stable when all are opencode/", () => { + const candidates = ["opencode/minimax-m3-free", "opencode/gpt-4o-free"]; + const sorted = preferDirect(candidates); + assert.equal(sorted.length, 2); +}); + +// --- resolveModelName --- + +test("resolveModelName returns pass-through when rawName contains /", () => { + const cwd = makeTempDir(); + const result = resolveModelName("minimax/MiniMax-M3", cwd); + assert.equal(result.resolved, "minimax/MiniMax-M3"); + assert.deepEqual(result.candidates, []); + assert.equal(result.fromCache, false); +}); + +test("resolveModelName returns null resolved for empty input", () => { + const cwd = makeTempDir(); + const result = resolveModelName("", cwd); + assert.equal(result.resolved, null); + assert.deepEqual(result.candidates, []); +}); + +test("resolveModelName resolves unambiguous name using cache file", async () => { + const cwd = makeTempDir(); + const { resolveStateDir } = await import("../plugins/opencode/scripts/lib/state.mjs"); + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "models-cache.json"), + JSON.stringify({ + cachedAt: new Date().toISOString(), + models: ["minimax/MiniMax-M3", "opencode/minimax-m3-free", "anthropic/claude-opus-4"] + }) + "\n", + "utf8" + ); + + const result = resolveModelName("minimax m3", cwd); + assert.equal(result.resolved, "minimax/MiniMax-M3"); + assert.deepEqual(result.candidates, []); + assert.equal(result.fromCache, true); +}); + +test("resolveModelName returns null + candidates when top-score is ambiguous", async () => { + const cwd = makeTempDir(); + const { resolveStateDir } = await import("../plugins/opencode/scripts/lib/state.mjs"); + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "models-cache.json"), + JSON.stringify({ + cachedAt: new Date().toISOString(), + models: ["minimax/MiniMax-M3", "minimax/MiniMax-M3-lite"] + }) + "\n", + "utf8" + ); + + const result = resolveModelName("minimax m3", cwd); + assert.equal(result.resolved, null); + assert.ok(result.candidates.length >= 2); + assert.ok(result.candidates.every(c => !c.startsWith("opencode/"))); +}); + +test("resolveModelName returns null + empty candidates when no match", async () => { + const cwd = makeTempDir(); + const { resolveStateDir } = await import("../plugins/opencode/scripts/lib/state.mjs"); + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "models-cache.json"), + JSON.stringify({ + cachedAt: new Date().toISOString(), + models: ["anthropic/claude-opus-4"] + }) + "\n", + "utf8" + ); + + const result = resolveModelName("minimax m3", cwd); + assert.equal(result.resolved, null); + assert.deepEqual(result.candidates, []); +}); + +test("resolveModelName uses cache when within TTL", async () => { + const cwd = makeTempDir(); + const { resolveStateDir } = await import("../plugins/opencode/scripts/lib/state.mjs"); + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + const cacheFile = path.join(stateDir, "models-cache.json"); + fs.writeFileSync( + cacheFile, + JSON.stringify({ + cachedAt: new Date().toISOString(), + models: ["minimax/MiniMax-M3"] + }) + "\n", + "utf8" + ); + const mtimeBefore = fs.statSync(cacheFile).mtimeMs; + + resolveModelName("minimax m3", cwd); + + const mtimeAfter = fs.statSync(cacheFile).mtimeMs; + assert.equal(mtimeBefore, mtimeAfter); +}); + +test("resolveModelName refetches when cache is expired", async () => { + const cwd = makeTempDir(); + const { resolveStateDir } = await import("../plugins/opencode/scripts/lib/state.mjs"); + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + const cacheFile = path.join(stateDir, "models-cache.json"); + + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + fs.writeFileSync( + cacheFile, + JSON.stringify({ cachedAt: twoHoursAgo, models: ["minimax/MiniMax-M3"] }) + "\n", + "utf8" + ); + const mtimeBefore = fs.statSync(cacheFile).mtimeMs; + + const tmpBin = path.join(cwd, "fake-opencode"); + fs.writeFileSync( + tmpBin, + "#!/bin/sh\necho 'minimax/MiniMax-M3\nopencode/minimax-m3-free'\n", + { mode: 0o755 } + ); + const prev = process.env.OPENCODE_BIN; + process.env.OPENCODE_BIN = tmpBin; + try { + resolveModelName("minimax m3", cwd); + const mtimeAfter = fs.statSync(cacheFile).mtimeMs; + assert.ok(mtimeAfter > mtimeBefore); + } finally { + if (prev == null) delete process.env.OPENCODE_BIN; + else process.env.OPENCODE_BIN = prev; + } +});