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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode/commands/rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>` 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.
107 changes: 107 additions & 0 deletions plugins/opencode/scripts/lib/model-resolver.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
33 changes: 31 additions & 2 deletions plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode/skills/opencode-cli-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <provider/model>` only when the user explicitly asks for a specific model identifier.
- Pass `--model <value>` 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:
Expand Down
Loading