Skip to content
Merged
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
12 changes: 7 additions & 5 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -65,6 +66,7 @@ Alternatively, clone or copy the plugin files to one of these directories:

| Command | Description | Agent |
|---------|-------------|-------|
| `/agent-promote <name> <grade>` | 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` |
Expand Down Expand Up @@ -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 `<leader>a` to select the agent

---

### Blockchain

Tools for querying Ethereum and EVM-compatible blockchains via Etherscan APIs.
Expand Down
5 changes: 5 additions & 0 deletions command/agent-promote.md
Original file line number Diff line number Diff line change
@@ -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.
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.

16 changes: 14 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
gitingestTool,
createPromptSessionTool,
createListChildSessionsTool,
createAgentPromoteTool,
getPromotedAgents,
ethTransactionTool,
ethAddressTxsTool,
ethAddressBalanceTool,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -262,8 +265,16 @@ const SmartfrogPlugin: Plugin = async (ctx) => {

return {
config: async (config: Record<string, unknown>): Promise<void> => {
if (Object.keys(agents).length > 0) {
config.agent = { ...(config.agent as Record<string, unknown> ?? {}), ...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<string, unknown> ?? {}), ...loadedAgents }
}
if (Object.keys(commands).length > 0) {
config.command = { ...(config.command as Record<string, unknown> ?? {}), ...commands }
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/tools/agent-promote-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type AgentMode = "subagent" | "primary" | "all"

export const VALID_GRADES: AgentMode[] = ["subagent", "primary", "all"]

const promotedAgents = new Map<string, AgentMode>()

export function getPromotedAgents(): ReadonlyMap<string, AgentMode> {
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)
}
93 changes: 93 additions & 0 deletions src/tools/agent-promote.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
71 changes: 71 additions & 0 deletions src/tools/agent-promote.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createOpencodeClient>

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 <leader>a to select it.`
},
})
}
1 change: 1 addition & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down