feat: add OpenCode/Codex outputs and update changelog (#104)
* Add OpenCode converter coverage and specs * Add Codex target support and spec docs * Generate Codex command skills and refresh spec docs * Add global Codex install path * fix: harden plugin path loading and codex descriptions * feat: ensure codex agents block on convert/install * docs: clarify target branch usage for review * chore: prep npm package metadata and release notes * docs: mention opencode and codex in changelog * docs: update CLI usage and remove stale todos * feat: install from GitHub with global outputs
This commit is contained in:
124
src/converters/claude-to-codex.ts
Normal file
124
src/converters/claude-to-codex.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { formatFrontmatter } from "../utils/frontmatter"
|
||||
import type { ClaudeAgent, ClaudeCommand, ClaudePlugin } from "../types/claude"
|
||||
import type { CodexBundle, CodexGeneratedSkill } from "../types/codex"
|
||||
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
|
||||
|
||||
export type ClaudeToCodexOptions = ClaudeToOpenCodeOptions
|
||||
|
||||
const CODEX_DESCRIPTION_MAX_LENGTH = 1024
|
||||
|
||||
export function convertClaudeToCodex(
|
||||
plugin: ClaudePlugin,
|
||||
_options: ClaudeToCodexOptions,
|
||||
): CodexBundle {
|
||||
const promptNames = new Set<string>()
|
||||
const skillDirs = plugin.skills.map((skill) => ({
|
||||
name: skill.name,
|
||||
sourceDir: skill.sourceDir,
|
||||
}))
|
||||
|
||||
const usedSkillNames = new Set<string>(skillDirs.map((skill) => normalizeName(skill.name)))
|
||||
const commandSkills: CodexGeneratedSkill[] = []
|
||||
const prompts = plugin.commands.map((command) => {
|
||||
const promptName = uniqueName(normalizeName(command.name), promptNames)
|
||||
const commandSkill = convertCommandSkill(command, usedSkillNames)
|
||||
commandSkills.push(commandSkill)
|
||||
const content = renderPrompt(command, commandSkill.name)
|
||||
return { name: promptName, content }
|
||||
})
|
||||
|
||||
const agentSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames))
|
||||
const generatedSkills = [...commandSkills, ...agentSkills]
|
||||
|
||||
return {
|
||||
prompts,
|
||||
skillDirs,
|
||||
generatedSkills,
|
||||
mcpServers: plugin.mcpServers,
|
||||
}
|
||||
}
|
||||
|
||||
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): CodexGeneratedSkill {
|
||||
const name = uniqueName(normalizeName(agent.name), usedNames)
|
||||
const description = sanitizeDescription(
|
||||
agent.description ?? `Converted from Claude agent ${agent.name}`,
|
||||
)
|
||||
const frontmatter: Record<string, unknown> = { name, description }
|
||||
|
||||
let body = agent.body.trim()
|
||||
if (agent.capabilities && agent.capabilities.length > 0) {
|
||||
const capabilities = agent.capabilities.map((capability) => `- ${capability}`).join("\n")
|
||||
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
|
||||
}
|
||||
if (body.length === 0) {
|
||||
body = `Instructions converted from the ${agent.name} agent.`
|
||||
}
|
||||
|
||||
const content = formatFrontmatter(frontmatter, body)
|
||||
return { name, content }
|
||||
}
|
||||
|
||||
function convertCommandSkill(command: ClaudeCommand, usedNames: Set<string>): CodexGeneratedSkill {
|
||||
const name = uniqueName(normalizeName(command.name), usedNames)
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
name,
|
||||
description: sanitizeDescription(
|
||||
command.description ?? `Converted from Claude command ${command.name}`,
|
||||
),
|
||||
}
|
||||
const sections: string[] = []
|
||||
if (command.argumentHint) {
|
||||
sections.push(`## Arguments\n${command.argumentHint}`)
|
||||
}
|
||||
if (command.allowedTools && command.allowedTools.length > 0) {
|
||||
sections.push(`## Allowed tools\n${command.allowedTools.map((tool) => `- ${tool}`).join("\n")}`)
|
||||
}
|
||||
sections.push(command.body.trim())
|
||||
const body = sections.filter(Boolean).join("\n\n").trim()
|
||||
const content = formatFrontmatter(frontmatter, body.length > 0 ? body : command.body)
|
||||
return { name, content }
|
||||
}
|
||||
|
||||
function renderPrompt(command: ClaudeCommand, skillName: string): string {
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
description: command.description,
|
||||
"argument-hint": command.argumentHint,
|
||||
}
|
||||
const instructions = `Use the $${skillName} skill for this command and follow its instructions.`
|
||||
const body = [instructions, "", command.body].join("\n").trim()
|
||||
return formatFrontmatter(frontmatter, body)
|
||||
}
|
||||
|
||||
function normalizeName(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return "item"
|
||||
const normalized = trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[\\/]+/g, "-")
|
||||
.replace(/[:\s]+/g, "-")
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return normalized || "item"
|
||||
}
|
||||
|
||||
function sanitizeDescription(value: string, maxLength = CODEX_DESCRIPTION_MAX_LENGTH): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim()
|
||||
if (normalized.length <= maxLength) return normalized
|
||||
const ellipsis = "..."
|
||||
return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
|
||||
}
|
||||
|
||||
function uniqueName(base: string, used: Set<string>): string {
|
||||
if (!used.has(base)) {
|
||||
used.add(base)
|
||||
return base
|
||||
}
|
||||
let index = 2
|
||||
while (used.has(`${base}-${index}`)) {
|
||||
index += 1
|
||||
}
|
||||
const name = `${base}-${index}`
|
||||
used.add(name)
|
||||
return name
|
||||
}
|
||||
392
src/converters/claude-to-opencode.ts
Normal file
392
src/converters/claude-to-opencode.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { formatFrontmatter } from "../utils/frontmatter"
|
||||
import type {
|
||||
ClaudeAgent,
|
||||
ClaudeCommand,
|
||||
ClaudeHooks,
|
||||
ClaudePlugin,
|
||||
ClaudeMcpServer,
|
||||
} from "../types/claude"
|
||||
import type {
|
||||
OpenCodeBundle,
|
||||
OpenCodeCommandConfig,
|
||||
OpenCodeConfig,
|
||||
OpenCodeMcpServer,
|
||||
} from "../types/opencode"
|
||||
|
||||
export type PermissionMode = "none" | "broad" | "from-commands"
|
||||
|
||||
export type ClaudeToOpenCodeOptions = {
|
||||
agentMode: "primary" | "subagent"
|
||||
inferTemperature: boolean
|
||||
permissions: PermissionMode
|
||||
}
|
||||
|
||||
const TOOL_MAP: Record<string, string> = {
|
||||
bash: "bash",
|
||||
read: "read",
|
||||
write: "write",
|
||||
edit: "edit",
|
||||
grep: "grep",
|
||||
glob: "glob",
|
||||
list: "list",
|
||||
webfetch: "webfetch",
|
||||
skill: "skill",
|
||||
patch: "patch",
|
||||
task: "task",
|
||||
question: "question",
|
||||
todowrite: "todowrite",
|
||||
todoread: "todoread",
|
||||
}
|
||||
|
||||
type HookEventMapping = {
|
||||
events: string[]
|
||||
type: "tool" | "session" | "permission" | "message"
|
||||
requireError?: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
const HOOK_EVENT_MAP: Record<string, HookEventMapping> = {
|
||||
PreToolUse: { events: ["tool.execute.before"], type: "tool" },
|
||||
PostToolUse: { events: ["tool.execute.after"], type: "tool" },
|
||||
PostToolUseFailure: { events: ["tool.execute.after"], type: "tool", requireError: true, note: "Claude PostToolUseFailure" },
|
||||
SessionStart: { events: ["session.created"], type: "session" },
|
||||
SessionEnd: { events: ["session.deleted"], type: "session" },
|
||||
Stop: { events: ["session.idle"], type: "session" },
|
||||
PreCompact: { events: ["experimental.session.compacting"], type: "session" },
|
||||
PermissionRequest: { events: ["permission.requested", "permission.replied"], type: "permission", note: "Claude PermissionRequest" },
|
||||
UserPromptSubmit: { events: ["message.created", "message.updated"], type: "message", note: "Claude UserPromptSubmit" },
|
||||
Notification: { events: ["message.updated"], type: "message", note: "Claude Notification" },
|
||||
Setup: { events: ["session.created"], type: "session", note: "Claude Setup" },
|
||||
SubagentStart: { events: ["message.updated"], type: "message", note: "Claude SubagentStart" },
|
||||
SubagentStop: { events: ["message.updated"], type: "message", note: "Claude SubagentStop" },
|
||||
}
|
||||
|
||||
export function convertClaudeToOpenCode(
|
||||
plugin: ClaudePlugin,
|
||||
options: ClaudeToOpenCodeOptions,
|
||||
): OpenCodeBundle {
|
||||
const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
|
||||
const commandMap = convertCommands(plugin.commands)
|
||||
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
|
||||
const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []
|
||||
|
||||
const config: OpenCodeConfig = {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
command: Object.keys(commandMap).length > 0 ? commandMap : undefined,
|
||||
mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined,
|
||||
}
|
||||
|
||||
applyPermissions(config, plugin.commands, options.permissions)
|
||||
|
||||
return {
|
||||
config,
|
||||
agents: agentFiles,
|
||||
plugins,
|
||||
skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
|
||||
}
|
||||
}
|
||||
|
||||
function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) {
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
description: agent.description,
|
||||
mode: options.agentMode,
|
||||
}
|
||||
|
||||
if (agent.model && agent.model !== "inherit") {
|
||||
frontmatter.model = normalizeModel(agent.model)
|
||||
}
|
||||
|
||||
if (options.inferTemperature) {
|
||||
const temperature = inferTemperature(agent)
|
||||
if (temperature !== undefined) {
|
||||
frontmatter.temperature = temperature
|
||||
}
|
||||
}
|
||||
|
||||
const content = formatFrontmatter(frontmatter, agent.body)
|
||||
|
||||
return {
|
||||
name: agent.name,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function convertCommands(commands: ClaudeCommand[]): Record<string, OpenCodeCommandConfig> {
|
||||
const result: Record<string, OpenCodeCommandConfig> = {}
|
||||
for (const command of commands) {
|
||||
const entry: OpenCodeCommandConfig = {
|
||||
description: command.description,
|
||||
template: command.body,
|
||||
}
|
||||
if (command.model && command.model !== "inherit") {
|
||||
entry.model = normalizeModel(command.model)
|
||||
}
|
||||
result[command.name] = entry
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, OpenCodeMcpServer> {
|
||||
const result: Record<string, OpenCodeMcpServer> = {}
|
||||
for (const [name, server] of Object.entries(servers)) {
|
||||
if (server.command) {
|
||||
result[name] = {
|
||||
type: "local",
|
||||
command: [server.command, ...(server.args ?? [])],
|
||||
environment: server.env,
|
||||
enabled: true,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (server.url) {
|
||||
result[name] = {
|
||||
type: "remote",
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function convertHooks(hooks: ClaudeHooks) {
|
||||
const handlerBlocks: string[] = []
|
||||
const hookMap = hooks.hooks
|
||||
const unmappedEvents: string[] = []
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(hookMap)) {
|
||||
const mapping = HOOK_EVENT_MAP[eventName]
|
||||
if (!mapping) {
|
||||
unmappedEvents.push(eventName)
|
||||
continue
|
||||
}
|
||||
if (matchers.length === 0) continue
|
||||
for (const event of mapping.events) {
|
||||
handlerBlocks.push(
|
||||
renderHookHandlers(event, matchers, {
|
||||
useToolMatcher: mapping.type === "tool" || mapping.type === "permission",
|
||||
requireError: mapping.requireError ?? false,
|
||||
note: mapping.note,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const unmappedComment = unmappedEvents.length > 0
|
||||
? `// Unmapped Claude hook events: ${unmappedEvents.join(", ")}\n`
|
||||
: ""
|
||||
|
||||
const content = `${unmappedComment}import type { Plugin } from "@opencode-ai/plugin"\n\nexport const ConvertedHooks: Plugin = async ({ $ }) => {\n return {\n${handlerBlocks.join(",\n")}\n }\n}\n\nexport default ConvertedHooks\n`
|
||||
|
||||
return {
|
||||
name: "converted-hooks.ts",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function renderHookHandlers(
|
||||
event: string,
|
||||
matchers: ClaudeHooks["hooks"][string],
|
||||
options: { useToolMatcher: boolean; requireError: boolean; note?: string },
|
||||
) {
|
||||
const statements: string[] = []
|
||||
for (const matcher of matchers) {
|
||||
statements.push(...renderHookStatements(matcher, options.useToolMatcher))
|
||||
}
|
||||
const rendered = statements.map((line) => ` ${line}`).join("\n")
|
||||
const wrapped = options.requireError
|
||||
? ` if (input?.error) {\n${statements.map((line) => ` ${line}`).join("\n")}\n }`
|
||||
: rendered
|
||||
const note = options.note ? ` // ${options.note}\n` : ""
|
||||
return ` "${event}": async (input) => {\n${note}${wrapped}\n }`
|
||||
}
|
||||
|
||||
function renderHookStatements(
|
||||
matcher: ClaudeHooks["hooks"][string][number],
|
||||
useToolMatcher: boolean,
|
||||
): string[] {
|
||||
if (!matcher.hooks || matcher.hooks.length === 0) return []
|
||||
const tools = matcher.matcher
|
||||
.split("|")
|
||||
.map((tool) => tool.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
const useMatcher = useToolMatcher && tools.length > 0 && !tools.includes("*")
|
||||
const condition = useMatcher
|
||||
? tools.map((tool) => `input.tool === "${tool}"`).join(" || ")
|
||||
: null
|
||||
const statements: string[] = []
|
||||
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === "command") {
|
||||
if (condition) {
|
||||
statements.push(`if (${condition}) { await $\`${hook.command}\` }`)
|
||||
} else {
|
||||
statements.push(`await $\`${hook.command}\``)
|
||||
}
|
||||
if (hook.timeout) {
|
||||
statements.push(`// timeout: ${hook.timeout}s (not enforced)`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (hook.type === "prompt") {
|
||||
statements.push(`// Prompt hook for ${matcher.matcher}: ${hook.prompt.replace(/\n/g, " ")}`)
|
||||
continue
|
||||
}
|
||||
statements.push(`// Agent hook for ${matcher.matcher}: ${hook.agent}`)
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
function normalizeModel(model: string): string {
|
||||
if (model.includes("/")) return model
|
||||
if (/^claude-/.test(model)) return `anthropic/${model}`
|
||||
if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}`
|
||||
if (/^gemini-/.test(model)) return `google/${model}`
|
||||
return `anthropic/${model}`
|
||||
}
|
||||
|
||||
function inferTemperature(agent: ClaudeAgent): number | undefined {
|
||||
const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase()
|
||||
if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
|
||||
return 0.1
|
||||
}
|
||||
if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) {
|
||||
return 0.2
|
||||
}
|
||||
if (/(doc|readme|changelog|editor|writer)/.test(sample)) {
|
||||
return 0.3
|
||||
}
|
||||
if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) {
|
||||
return 0.6
|
||||
}
|
||||
return 0.3
|
||||
}
|
||||
|
||||
function applyPermissions(
|
||||
config: OpenCodeConfig,
|
||||
commands: ClaudeCommand[],
|
||||
mode: PermissionMode,
|
||||
) {
|
||||
if (mode === "none") return
|
||||
|
||||
const sourceTools = [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"glob",
|
||||
"list",
|
||||
"webfetch",
|
||||
"skill",
|
||||
"patch",
|
||||
"task",
|
||||
"question",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
]
|
||||
let enabled = new Set<string>()
|
||||
const patterns: Record<string, Set<string>> = {}
|
||||
|
||||
if (mode === "broad") {
|
||||
enabled = new Set(sourceTools)
|
||||
} else {
|
||||
for (const command of commands) {
|
||||
if (!command.allowedTools) continue
|
||||
for (const tool of command.allowedTools) {
|
||||
const parsed = parseToolSpec(tool)
|
||||
if (!parsed.tool) continue
|
||||
enabled.add(parsed.tool)
|
||||
if (parsed.pattern) {
|
||||
const normalizedPattern = normalizePattern(parsed.tool, parsed.pattern)
|
||||
if (!patterns[parsed.tool]) patterns[parsed.tool] = new Set()
|
||||
patterns[parsed.tool].add(normalizedPattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const permission: Record<string, "allow" | "deny"> = {}
|
||||
const tools: Record<string, boolean> = {}
|
||||
|
||||
for (const tool of sourceTools) {
|
||||
tools[tool] = mode === "broad" ? true : enabled.has(tool)
|
||||
}
|
||||
|
||||
if (mode === "broad") {
|
||||
for (const tool of sourceTools) {
|
||||
permission[tool] = "allow"
|
||||
}
|
||||
} else {
|
||||
for (const tool of sourceTools) {
|
||||
const toolPatterns = patterns[tool]
|
||||
if (toolPatterns && toolPatterns.size > 0) {
|
||||
const patternPermission: Record<string, "allow" | "deny"> = { "*": "deny" }
|
||||
for (const pattern of toolPatterns) {
|
||||
patternPermission[pattern] = "allow"
|
||||
}
|
||||
;(permission as Record<string, typeof patternPermission>)[tool] = patternPermission
|
||||
} else {
|
||||
permission[tool] = enabled.has(tool) ? "allow" : "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== "broad") {
|
||||
for (const [tool, toolPatterns] of Object.entries(patterns)) {
|
||||
if (!toolPatterns || toolPatterns.size === 0) continue
|
||||
const patternPermission: Record<string, "allow" | "deny"> = { "*": "deny" }
|
||||
for (const pattern of toolPatterns) {
|
||||
patternPermission[pattern] = "allow"
|
||||
}
|
||||
;(permission as Record<string, typeof patternPermission>)[tool] = patternPermission
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled.has("write") || enabled.has("edit")) {
|
||||
if (typeof permission.edit === "string") permission.edit = "allow"
|
||||
if (typeof permission.write === "string") permission.write = "allow"
|
||||
}
|
||||
if (patterns.write || patterns.edit) {
|
||||
const combined = new Set<string>()
|
||||
for (const pattern of patterns.write ?? []) combined.add(pattern)
|
||||
for (const pattern of patterns.edit ?? []) combined.add(pattern)
|
||||
const combinedPermission: Record<string, "allow" | "deny"> = { "*": "deny" }
|
||||
for (const pattern of combined) {
|
||||
combinedPermission[pattern] = "allow"
|
||||
}
|
||||
;(permission as Record<string, typeof combinedPermission>).edit = combinedPermission
|
||||
;(permission as Record<string, typeof combinedPermission>).write = combinedPermission
|
||||
}
|
||||
|
||||
config.permission = permission
|
||||
config.tools = tools
|
||||
}
|
||||
|
||||
function normalizeTool(raw: string): string | null {
|
||||
return parseToolSpec(raw).tool
|
||||
}
|
||||
|
||||
function parseToolSpec(raw: string): { tool: string | null; pattern?: string } {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return { tool: null }
|
||||
const [namePart, patternPart] = trimmed.split("(", 2)
|
||||
const name = namePart.trim().toLowerCase()
|
||||
const tool = TOOL_MAP[name] ?? null
|
||||
if (!patternPart) return { tool }
|
||||
const normalizedPattern = patternPart.endsWith(")")
|
||||
? patternPart.slice(0, -1).trim()
|
||||
: patternPart.trim()
|
||||
return { tool, pattern: normalizedPattern }
|
||||
}
|
||||
|
||||
function normalizePattern(tool: string, pattern: string): string {
|
||||
if (tool === "bash") {
|
||||
return pattern.replace(/:/g, " ").trim()
|
||||
}
|
||||
return pattern
|
||||
}
|
||||
Reference in New Issue
Block a user