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:
Kieran Klaassen
2026-01-21 17:00:30 -08:00
committed by GitHub
parent c50208d413
commit e97f85bd53
61 changed files with 3303 additions and 5 deletions

View 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
}