ring/platforms/opencode/plugin/config/config-handler.ts
Gandalf f85aa61440
feat: add OpenCode platform integration (consolidate ring-for-opencode)
Move OpenCode runtime plugin and installer into Ring monorepo under platforms/opencode/.
The installer reads skills, agents, and commands directly from the Ring monorepo's
canonical directories (default/, dev-team/, pm-team/, etc.) — zero asset duplication.

What's included:
- installer.sh: reads from Ring dirs, applies frontmatter/tool transforms, installs to ~/.config/opencode/
- plugin/: TypeScript runtime (RingUnifiedPlugin) with hooks, lifecycle, loaders
- src/: CLI (doctor, config-manager)
- prompts/: session-start and context-injection templates
- standards/: coding standards (from dev-team/docs/)
- ring.jsonc: default config with full 86-skill/35-agent/33-command inventory

What's NOT included (intentionally):
- assets/ directory: eliminated, content comes from Ring monorepo
- scripts/codereview/: eliminated, replaced by mithril
- using-ring-opencode skill: uses canonical using-ring instead

Transforms applied by installer:
- Agent: type→mode, strip version/changelog/output_schema/input_schema
- Skill: keep name+description frontmatter, body unchanged
- Command: strip argument-hint (unsupported by OpenCode)
- All: normalize tool names (Bash→bash, Read→read, etc.)
- All: strip Model Requirement sections from agents

Replaces: LerianStudio/ring-for-opencode repository
Generated-by: Gandalf
AI-Model: claude-opus-4
2026-03-07 22:46:47 -03:00

168 lines
5 KiB
TypeScript

/**
* Ring Config Handler
*
* Creates a config hook that injects Ring agents, skills, and commands
* into OpenCode's configuration at runtime.
*
* Pattern from oh-my-opencode:
* config: async (opencodeConfig) => {
* // Modify opencodeConfig.agent, .skill, .command
* return opencodeConfig
* }
*/
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { loadRingAgents } from "../loaders/agent-loader.js"
import { loadRingCommands } from "../loaders/command-loader.js"
import { loadRingSkills } from "../loaders/skill-loader.js"
import type { RingConfig } from "./schema.js"
// Determine plugin root directory (where assets/ is located)
const __dirname = dirname(fileURLToPath(import.meta.url))
const pluginRoot = join(__dirname, "..", "..")
/**
* OpenCode config structure (subset used by Ring).
*/
export interface OpenCodeConfig {
/** Agent configurations */
agent?: Record<string, unknown>
/** Command configurations */
command?: Record<string, unknown>
/** Permission settings */
permission?: Record<string, string>
/** Tools configuration */
tools?: Record<string, boolean>
/** Model configuration */
model?: string
}
/**
* Dependencies for creating the config handler.
*/
export interface ConfigHandlerDeps {
/** Project root directory */
projectRoot: string
/** Ring plugin configuration */
ringConfig: RingConfig
}
/**
* Create the config handler that injects Ring components.
*
* This handler is called by OpenCode to modify the configuration
* before the session starts. We use this to inject:
* - Ring agents (from plugin's assets/agent/ + user's .opencode/agent/)
* - Ring skills (from plugin's assets/skill/ + user's .opencode/skill/)
* - Ring commands (from plugin's assets/command/ + user's .opencode/command/)
*
* User's customizations take priority over Ring's built-in assets.
*/
export function createConfigHandler(deps: ConfigHandlerDeps) {
const { projectRoot, ringConfig } = deps
return async (config: OpenCodeConfig): Promise<void> => {
const debug = process.env.DEBUG === "true" || process.env.RING_DEBUG === "true"
// Load Ring agents (from plugin's assets/ + user's .opencode/)
const ringAgents = loadRingAgents(pluginRoot, projectRoot, ringConfig.disabled_agents)
if (debug) {
const agentNames = Object.keys(ringAgents)
console.debug(
`[ring] Loaded ${agentNames.length} agents:`,
agentNames.slice(0, 5).join(", "),
agentNames.length > 5 ? "..." : "",
)
}
// Load Ring skills (from plugin's assets/ + user's .opencode/)
const ringSkills = loadRingSkills(pluginRoot, projectRoot, ringConfig.disabled_skills)
if (debug) {
const skillNames = Object.keys(ringSkills)
console.debug(
`[ring] Loaded ${skillNames.length} skills:`,
skillNames.slice(0, 5).join(", "),
skillNames.length > 5 ? "..." : "",
)
}
// Load Ring commands (from plugin's assets/ + user's .opencode/)
const { commands: ringCommands, validation: commandValidation } = loadRingCommands(
pluginRoot,
projectRoot,
ringConfig.disabled_commands,
debug, // Only validate refs in debug mode
)
if (debug) {
const commandNames = Object.keys(ringCommands)
console.debug(
`[ring] Loaded ${commandNames.length} commands:`,
commandNames.slice(0, 5).join(", "),
commandNames.length > 5 ? "..." : "",
)
// Log validation warnings
for (const warning of commandValidation) {
console.debug(`[ring] Command '${warning.command}': ${warning.issue}`)
}
}
// Inject agents into config
// Ring agents are added with lower priority (spread first, then existing)
// so project-specific overrides can take precedence
// TODO(review): Consider deep merge for nested agent configs
config.agent = {
...ringAgents,
...(config.agent ?? {}),
}
// Inject skills and commands
// Commands and skills both go into config.command
config.command = {
...ringSkills,
...ringCommands,
...(config.command ?? {}),
}
// Disable recursive agent calls in certain agents
const agentConfig = config.agent as Record<string, { tools?: Record<string, boolean> }>
// Prevent explore agents from using task recursively
if (agentConfig["ring:codebase-explorer"]) {
agentConfig["ring:codebase-explorer"].tools = {
...agentConfig["ring:codebase-explorer"].tools,
task: false,
}
}
// Prevent reviewers from spawning more reviewers
const reviewerAgents = [
"ring:code-reviewer",
"ring:security-reviewer",
"ring:business-logic-reviewer",
"ring:test-reviewer",
"ring:nil-safety-reviewer",
]
for (const reviewerName of reviewerAgents) {
if (agentConfig[reviewerName]) {
agentConfig[reviewerName].tools = {
...agentConfig[reviewerName].tools,
task: false,
}
}
}
if (debug) {
console.debug("[ring] Config injection complete")
}
}
}