diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index e040263..c79c006 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -9,16 +9,18 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: anomalyco/opencode/github@latest env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: anthropic/claude-sonnet-4-20250514 + model: opencode/claude-opus-4-5 prompt: | Review this pull request: - Check for code quality issues diff --git a/README.md b/README.md index 16342e8..e57bdbe 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Plugin providing Claude Code–style hooks, specialized agents (doc-writer, code - [gitingest](#gitingest) - [prompt-session](#prompt-session) - [list-child-sessions](#list-child-sessions) + - [agent-promote](#agent-promote) - [Blockchain](#blockchain) - [Configuration](#configuration) - [eth-transaction](#eth-transaction) @@ -65,6 +66,7 @@ Alternatively, clone or copy the plugin files to one of these directories: | Command | Description | Agent | |---------|-------------|-------| +| `/agent-promote ` | Change the type of a plugin agent at runtime. Grades: `subagent`, `primary`, `all` | - | | `/commit-push` | Stage, commit, and push changes with user confirmation | `build` | | `/diff-summary [source] [target]` | Show working tree changes or diff between branches | - | | `/doc-changes` | Update documentation based on uncommitted changes (new features only) | `doc-writer` | @@ -228,6 +230,46 @@ Child sessions (2): --- +### agent-promote + +Change the type of a plugin agent at runtime. Promotes subagents to primary agents (visible in Tab selection) or demotes them back. + +#### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | `string` | Yes | Name of the plugin agent (e.g., `rubber-duck`, `architect`) | +| `grade` | `string` | Yes | Target type: `subagent`, `primary`, or `all` | + +#### Grade Types + +| Grade | Effect | +|-------|--------| +| `subagent` | Available only as a subagent (default for most agents) | +| `primary` | Appears in Tab selection for direct use | +| `all` | Available both as primary and subagent | + +#### Usage Examples + +```bash +# Promote rubber-duck to use it directly via Tab +/agent-promote rubber-duck primary + +# Make architect available everywhere +/agent-promote architect all + +# Revert code-reviewer to subagent only +/agent-promote code-reviewer subagent +``` + +#### Notes + +- Only agents from this plugin can be promoted (see [Agents](#agents) table) +- Changes persist in memory until OpenCode restarts +- After promotion, use `Tab` or `a` to select the agent + +--- + ### Blockchain Tools for querying Ethereum and EVM-compatible blockchains via Etherscan APIs. diff --git a/command/agent-promote.md b/command/agent-promote.md new file mode 100644 index 0000000..77b1556 --- /dev/null +++ b/command/agent-promote.md @@ -0,0 +1,5 @@ +--- +description: Change the type of an agent to primary, subagent or all +--- + +Use the agent-promote tool to change agent $1 to grade $2. diff --git a/package-lock.json b/package-lock.json index bde3587..016f05b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-froggy", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-froggy", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", diff --git a/src/index.ts b/src/index.ts index f72cb79..4633137 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ import { gitingestTool, createPromptSessionTool, createListChildSessionsTool, + createAgentPromoteTool, + getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, @@ -92,6 +94,7 @@ const SmartfrogPlugin: Plugin = async (ctx) => { hooks: Array.from(hooks.keys()), tools: [ "gitingest", + "agent-promote", "eth-transaction", "eth-address-txs", "eth-address-balance", @@ -262,8 +265,16 @@ const SmartfrogPlugin: Plugin = async (ctx) => { return { config: async (config: Record): Promise => { - if (Object.keys(agents).length > 0) { - config.agent = { ...(config.agent as Record ?? {}), ...agents } + const loadedAgents = loadAgents(AGENT_DIR) + + for (const [name, mode] of getPromotedAgents()) { + if (loadedAgents[name]) { + loadedAgents[name].mode = mode + } + } + + if (Object.keys(loadedAgents).length > 0) { + config.agent = { ...(config.agent as Record ?? {}), ...loadedAgents } } if (Object.keys(commands).length > 0) { config.command = { ...(config.command as Record ?? {}), ...commands } @@ -274,6 +285,7 @@ const SmartfrogPlugin: Plugin = async (ctx) => { gitingest: gitingestTool, "prompt-session": createPromptSessionTool(ctx.client), "list-child-sessions": createListChildSessionsTool(ctx.client), + "agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)), "eth-transaction": ethTransactionTool, "eth-address-txs": ethAddressTxsTool, "eth-address-balance": ethAddressBalanceTool, diff --git a/src/tools/agent-promote-core.ts b/src/tools/agent-promote-core.ts new file mode 100644 index 0000000..8d1c438 --- /dev/null +++ b/src/tools/agent-promote-core.ts @@ -0,0 +1,21 @@ +export type AgentMode = "subagent" | "primary" | "all" + +export const VALID_GRADES: AgentMode[] = ["subagent", "primary", "all"] + +const promotedAgents = new Map() + +export function getPromotedAgents(): ReadonlyMap { + return promotedAgents +} + +export function setPromotedAgent(name: string, mode: AgentMode): void { + promotedAgents.set(name, mode) +} + +export function validateGrade(grade: string): grade is AgentMode { + return VALID_GRADES.includes(grade as AgentMode) +} + +export function validateAgentName(name: string, pluginAgentNames: string[]): boolean { + return pluginAgentNames.includes(name) +} diff --git a/src/tools/agent-promote.test.ts b/src/tools/agent-promote.test.ts new file mode 100644 index 0000000..f58550d --- /dev/null +++ b/src/tools/agent-promote.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest" +import { + validateGrade, + validateAgentName, + getPromotedAgents, + setPromotedAgent, + VALID_GRADES, + type AgentMode, +} from "./agent-promote-core" + +describe("agent-promote", () => { + const pluginAgentNames = ["rubber-duck", "architect", "code-reviewer"] + + describe("VALID_GRADES", () => { + it("should contain subagent, primary, and all", () => { + expect(VALID_GRADES).toContain("subagent") + expect(VALID_GRADES).toContain("primary") + expect(VALID_GRADES).toContain("all") + expect(VALID_GRADES).toHaveLength(3) + }) + }) + + describe("validateGrade", () => { + it("should return true for valid grade: subagent", () => { + expect(validateGrade("subagent")).toBe(true) + }) + + it("should return true for valid grade: primary", () => { + expect(validateGrade("primary")).toBe(true) + }) + + it("should return true for valid grade: all", () => { + expect(validateGrade("all")).toBe(true) + }) + + it("should return false for invalid grade", () => { + expect(validateGrade("invalid")).toBe(false) + expect(validateGrade("foo")).toBe(false) + expect(validateGrade("")).toBe(false) + }) + }) + + describe("validateAgentName", () => { + it("should return true for agent in plugin", () => { + expect(validateAgentName("rubber-duck", pluginAgentNames)).toBe(true) + expect(validateAgentName("architect", pluginAgentNames)).toBe(true) + expect(validateAgentName("code-reviewer", pluginAgentNames)).toBe(true) + }) + + it("should return false for agent not in plugin", () => { + expect(validateAgentName("unknown", pluginAgentNames)).toBe(false) + expect(validateAgentName("build", pluginAgentNames)).toBe(false) + expect(validateAgentName("", pluginAgentNames)).toBe(false) + }) + }) + + describe("promotedAgents Map", () => { + it("should set and get promoted agent", () => { + setPromotedAgent("test-agent-1", "primary") + const promoted = getPromotedAgents() + expect(promoted.get("test-agent-1")).toBe("primary") + }) + + it("should update existing promotion", () => { + setPromotedAgent("test-agent-2", "primary") + expect(getPromotedAgents().get("test-agent-2")).toBe("primary") + + setPromotedAgent("test-agent-2", "all") + expect(getPromotedAgents().get("test-agent-2")).toBe("all") + + setPromotedAgent("test-agent-2", "subagent") + expect(getPromotedAgents().get("test-agent-2")).toBe("subagent") + }) + + it("should handle multiple agents", () => { + setPromotedAgent("agent-a", "primary") + setPromotedAgent("agent-b", "all") + setPromotedAgent("agent-c", "subagent") + + const promoted = getPromotedAgents() + expect(promoted.get("agent-a")).toBe("primary") + expect(promoted.get("agent-b")).toBe("all") + expect(promoted.get("agent-c")).toBe("subagent") + }) + + it("should return readonly map", () => { + const promoted = getPromotedAgents() + expect(typeof promoted.get).toBe("function") + expect(typeof promoted.has).toBe("function") + expect(typeof promoted.forEach).toBe("function") + }) + }) +}) diff --git a/src/tools/agent-promote.ts b/src/tools/agent-promote.ts new file mode 100644 index 0000000..f9f352f --- /dev/null +++ b/src/tools/agent-promote.ts @@ -0,0 +1,71 @@ +import { tool, type ToolContext } from "@opencode-ai/plugin" +import type { createOpencodeClient } from "@opencode-ai/sdk" +import { log } from "../logger" +import { + type AgentMode, + VALID_GRADES, + getPromotedAgents, + setPromotedAgent, + validateGrade, + validateAgentName, +} from "./agent-promote-core" + +export { + type AgentMode, + VALID_GRADES, + getPromotedAgents, + setPromotedAgent, + validateGrade, + validateAgentName, +} from "./agent-promote-core" + +type Client = ReturnType + +export interface AgentPromoteArgs { + name: string + grade: string +} + +export function createAgentPromoteTool(client: Client, pluginAgentNames: string[]) { + return tool({ + description: "Change the type of an agent to primary, subagent or all", + args: { + name: tool.schema.string().describe("Name of the agent"), + grade: tool.schema.string().describe("Target type: 'subagent', 'primary', or 'all'"), + }, + async execute(args: AgentPromoteArgs, _context: ToolContext) { + const { name, grade } = args + + if (!validateGrade(grade)) { + return `Invalid grade "${grade}". Valid grades: ${VALID_GRADES.join(", ")}` + } + + if (!validateAgentName(name, pluginAgentNames)) { + return `Agent "${name}" not found in this plugin. Available: ${pluginAgentNames.join(", ")}` + } + + const agentsResp = await client.app.agents() + const agents = agentsResp.data ?? [] + const existingAgent = agents.find((a) => a.name === name) + + if (existingAgent && existingAgent.mode === grade) { + return `Agent "${name}" is already of type "${grade}"` + } + + setPromotedAgent(name, grade) + log("[agent-promote] Agent type changed", { name, grade }) + + await client.tui.showToast({ + body: { + message: `Promoting agent "${name}" to "${grade}"...`, + variant: "success", + duration: 3000, + }, + }) + + await client.instance.dispose() + + return `Agent "${name}" changed to type "${grade}". Use Tab or a to select it.` + }, + }) +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 715f641..694e281 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ export { gitingestTool, fetchGitingest, type GitingestArgs } from "./gitingest" export { createPromptSessionTool, type PromptSessionArgs } from "./prompt-session" export { createListChildSessionsTool } from "./list-child-sessions" +export { createAgentPromoteTool, getPromotedAgents, type AgentPromoteArgs } from "./agent-promote" export { ethTransactionTool,