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

156
src/commands/convert.ts Normal file
View File

@@ -0,0 +1,156 @@
import { defineCommand } from "citty"
import os from "os"
import path from "path"
import { loadClaudePlugin } from "../parsers/claude"
import { targets } from "../targets"
import type { PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
export default defineCommand({
meta: {
name: "convert",
description: "Convert a Claude Code plugin into another format",
},
args: {
source: {
type: "positional",
required: true,
description: "Path to the Claude plugin directory",
},
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex)",
},
output: {
type: "string",
alias: "o",
description: "Output directory (project root)",
},
codexHome: {
type: "string",
alias: "codex-home",
description: "Write Codex output to this .codex root (ex: ~/.codex)",
},
also: {
type: "string",
description: "Comma-separated extra targets to generate (ex: codex)",
},
permissions: {
type: "string",
default: "broad",
description: "Permission mapping: none | broad | from-commands",
},
agentMode: {
type: "string",
default: "subagent",
description: "Default agent mode: primary | subagent",
},
inferTemperature: {
type: "boolean",
default: true,
description: "Infer agent temperature from name/description",
},
},
async run({ args }) {
const targetName = String(args.to)
const target = targets[targetName]
if (!target) {
throw new Error(`Unknown target: ${targetName}`)
}
if (!target.implemented) {
throw new Error(`Target ${targetName} is registered but not implemented yet.`)
}
const permissions = String(args.permissions)
if (!permissionModes.includes(permissions as PermissionMode)) {
throw new Error(`Unknown permissions mode: ${permissions}`)
}
const plugin = await loadClaudePlugin(String(args.source))
const outputRoot = resolveOutputRoot(args.output)
const codexHome = resolveCodexRoot(args.codexHome)
const options = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
inferTemperature: Boolean(args.inferTemperature),
permissions: permissions as PermissionMode,
}
const primaryOutputRoot = targetName === "codex" && codexHome ? codexHome : outputRoot
const bundle = target.convert(plugin, options)
if (!bundle) {
throw new Error(`Target ${targetName} did not return a bundle.`)
}
await target.write(primaryOutputRoot, bundle)
console.log(`Converted ${plugin.manifest.name} to ${targetName} at ${primaryOutputRoot}`)
const extraTargets = parseExtraTargets(args.also)
const allTargets = [targetName, ...extraTargets]
for (const extra of extraTargets) {
const handler = targets[extra]
if (!handler) {
console.warn(`Skipping unknown target: ${extra}`)
continue
}
if (!handler.implemented) {
console.warn(`Skipping ${extra}: not implemented yet.`)
continue
}
const extraBundle = handler.convert(plugin, options)
if (!extraBundle) {
console.warn(`Skipping ${extra}: no output returned.`)
continue
}
const extraRoot = extra === "codex" && codexHome
? codexHome
: path.join(outputRoot, extra)
await handler.write(extraRoot, extraBundle)
console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`)
}
if (allTargets.includes("codex")) {
await ensureCodexAgentsFile(codexHome)
}
},
})
function parseExtraTargets(value: unknown): string[] {
if (!value) return []
return String(value)
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
}
function resolveCodexHome(value: unknown): string | null {
if (!value) return null
const raw = String(value).trim()
if (!raw) return null
const expanded = expandHome(raw)
return path.resolve(expanded)
}
function resolveCodexRoot(value: unknown): string {
return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex")
}
function expandHome(value: string): string {
if (value === "~") return os.homedir()
if (value.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), value.slice(2))
}
return value
}
function resolveOutputRoot(value: unknown): string {
if (value && String(value).trim()) {
const expanded = expandHome(String(value).trim())
return path.resolve(expanded)
}
return process.cwd()
}

221
src/commands/install.ts Normal file
View File

@@ -0,0 +1,221 @@
import { defineCommand } from "citty"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { loadClaudePlugin } from "../parsers/claude"
import { targets } from "../targets"
import { pathExists } from "../utils/files"
import type { PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
export default defineCommand({
meta: {
name: "install",
description: "Install and convert a Claude plugin",
},
args: {
plugin: {
type: "positional",
required: true,
description: "Plugin name or path",
},
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex)",
},
output: {
type: "string",
alias: "o",
description: "Output directory (project root)",
},
codexHome: {
type: "string",
alias: "codex-home",
description: "Write Codex output to this .codex root (ex: ~/.codex)",
},
also: {
type: "string",
description: "Comma-separated extra targets to generate (ex: codex)",
},
permissions: {
type: "string",
default: "broad",
description: "Permission mapping: none | broad | from-commands",
},
agentMode: {
type: "string",
default: "subagent",
description: "Default agent mode: primary | subagent",
},
inferTemperature: {
type: "boolean",
default: true,
description: "Infer agent temperature from name/description",
},
},
async run({ args }) {
const targetName = String(args.to)
const target = targets[targetName]
if (!target) {
throw new Error(`Unknown target: ${targetName}`)
}
if (!target.implemented) {
throw new Error(`Target ${targetName} is registered but not implemented yet.`)
}
const permissions = String(args.permissions)
if (!permissionModes.includes(permissions as PermissionMode)) {
throw new Error(`Unknown permissions mode: ${permissions}`)
}
const resolvedPlugin = await resolvePluginPath(String(args.plugin))
try {
const plugin = await loadClaudePlugin(resolvedPlugin.path)
const outputRoot = resolveOutputRoot(args.output)
const codexHome = resolveCodexRoot(args.codexHome)
const options = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
inferTemperature: Boolean(args.inferTemperature),
permissions: permissions as PermissionMode,
}
const bundle = target.convert(plugin, options)
if (!bundle) {
throw new Error(`Target ${targetName} did not return a bundle.`)
}
const primaryOutputRoot = targetName === "codex" && codexHome ? codexHome : outputRoot
await target.write(primaryOutputRoot, bundle)
console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
const extraTargets = parseExtraTargets(args.also)
const allTargets = [targetName, ...extraTargets]
for (const extra of extraTargets) {
const handler = targets[extra]
if (!handler) {
console.warn(`Skipping unknown target: ${extra}`)
continue
}
if (!handler.implemented) {
console.warn(`Skipping ${extra}: not implemented yet.`)
continue
}
const extraBundle = handler.convert(plugin, options)
if (!extraBundle) {
console.warn(`Skipping ${extra}: no output returned.`)
continue
}
const extraRoot = extra === "codex" && codexHome
? codexHome
: path.join(outputRoot, extra)
await handler.write(extraRoot, extraBundle)
console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`)
}
if (allTargets.includes("codex")) {
await ensureCodexAgentsFile(codexHome)
}
} finally {
if (resolvedPlugin.cleanup) {
await resolvedPlugin.cleanup()
}
}
},
})
type ResolvedPluginPath = {
path: string
cleanup?: () => Promise<void>
}
async function resolvePluginPath(input: string): Promise<ResolvedPluginPath> {
const directPath = path.resolve(input)
if (await pathExists(directPath)) return { path: directPath }
const pluginsPath = path.join(process.cwd(), "plugins", input)
if (await pathExists(pluginsPath)) return { path: pluginsPath }
return await resolveGitHubPluginPath(input)
}
function parseExtraTargets(value: unknown): string[] {
if (!value) return []
return String(value)
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
}
function resolveCodexHome(value: unknown): string | null {
if (!value) return null
const raw = String(value).trim()
if (!raw) return null
const expanded = expandHome(raw)
return path.resolve(expanded)
}
function resolveCodexRoot(value: unknown): string {
return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex")
}
function expandHome(value: string): string {
if (value === "~") return os.homedir()
if (value.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), value.slice(2))
}
return value
}
function resolveOutputRoot(value: unknown): string {
if (value && String(value).trim()) {
const expanded = expandHome(String(value).trim())
return path.resolve(expanded)
}
return path.join(os.homedir(), ".opencode")
}
async function resolveGitHubPluginPath(pluginName: string): Promise<ResolvedPluginPath> {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-"))
const source = resolveGitHubSource()
try {
await cloneGitHubRepo(source, tempRoot)
} catch (error) {
await fs.rm(tempRoot, { recursive: true, force: true })
throw error
}
const pluginPath = path.join(tempRoot, "plugins", pluginName)
if (!(await pathExists(pluginPath))) {
await fs.rm(tempRoot, { recursive: true, force: true })
throw new Error(`Could not find plugin ${pluginName} in ${source}.`)
}
return {
path: pluginPath,
cleanup: async () => {
await fs.rm(tempRoot, { recursive: true, force: true })
},
}
}
function resolveGitHubSource(): string {
const override = process.env.COMPOUND_PLUGIN_GITHUB_SOURCE
if (override && override.trim()) return override.trim()
return "https://github.com/EveryInc/compound-engineering-plugin"
}
async function cloneGitHubRepo(source: string, destination: string): Promise<void> {
const proc = Bun.spawn(["git", "clone", "--depth", "1", source, destination], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`Failed to clone ${source}. ${stderr.trim()}`)
}
}

37
src/commands/list.ts Normal file
View File

@@ -0,0 +1,37 @@
import path from "path"
import { promises as fs } from "fs"
import { defineCommand } from "citty"
import { pathExists } from "../utils/files"
export default defineCommand({
meta: {
name: "list",
description: "List available Claude plugins under plugins/",
},
async run() {
const root = process.cwd()
const pluginsDir = path.join(root, "plugins")
if (!(await pathExists(pluginsDir))) {
console.log("No plugins directory found.")
return
}
const entries = await fs.readdir(pluginsDir, { withFileTypes: true })
const plugins: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const manifestPath = path.join(pluginsDir, entry.name, ".claude-plugin", "plugin.json")
if (await pathExists(manifestPath)) {
plugins.push(entry.name)
}
}
if (plugins.length === 0) {
console.log("No Claude plugins found under plugins/.")
return
}
console.log(plugins.sort().join("\n"))
},
})

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
}

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

20
src/index.ts Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bun
import { defineCommand, runMain } from "citty"
import convert from "./commands/convert"
import install from "./commands/install"
import listCommand from "./commands/list"
const main = defineCommand({
meta: {
name: "compound-plugin",
version: "0.1.0",
description: "Convert Claude Code plugins into other agent formats",
},
subCommands: {
convert: () => convert,
install: () => install,
list: () => listCommand,
},
})
runMain(main)

248
src/parsers/claude.ts Normal file
View File

@@ -0,0 +1,248 @@
import path from "path"
import { parseFrontmatter } from "../utils/frontmatter"
import { readJson, readText, pathExists, walkFiles } from "../utils/files"
import type {
ClaudeAgent,
ClaudeCommand,
ClaudeHooks,
ClaudeManifest,
ClaudeMcpServer,
ClaudePlugin,
ClaudeSkill,
} from "../types/claude"
const PLUGIN_MANIFEST = path.join(".claude-plugin", "plugin.json")
export async function loadClaudePlugin(inputPath: string): Promise<ClaudePlugin> {
const root = await resolveClaudeRoot(inputPath)
const manifestPath = path.join(root, PLUGIN_MANIFEST)
const manifest = await readJson<ClaudeManifest>(manifestPath)
const agents = await loadAgents(resolveComponentDirs(root, "agents", manifest.agents))
const commands = await loadCommands(resolveComponentDirs(root, "commands", manifest.commands))
const skills = await loadSkills(resolveComponentDirs(root, "skills", manifest.skills))
const hooks = await loadHooks(root, manifest.hooks)
const mcpServers = await loadMcpServers(root, manifest)
return {
root,
manifest,
agents,
commands,
skills,
hooks,
mcpServers,
}
}
async function resolveClaudeRoot(inputPath: string): Promise<string> {
const absolute = path.resolve(inputPath)
const manifestAtPath = path.join(absolute, PLUGIN_MANIFEST)
if (await pathExists(manifestAtPath)) {
return absolute
}
if (absolute.endsWith(PLUGIN_MANIFEST)) {
return path.dirname(path.dirname(absolute))
}
if (absolute.endsWith("plugin.json")) {
return path.dirname(path.dirname(absolute))
}
throw new Error(`Could not find ${PLUGIN_MANIFEST} under ${inputPath}`)
}
async function loadAgents(agentsDirs: string[]): Promise<ClaudeAgent[]> {
const files = await collectMarkdownFiles(agentsDirs)
const agents: ClaudeAgent[] = []
for (const file of files) {
const raw = await readText(file)
const { data, body } = parseFrontmatter(raw)
const name = (data.name as string) ?? path.basename(file, ".md")
agents.push({
name,
description: data.description as string | undefined,
capabilities: data.capabilities as string[] | undefined,
model: data.model as string | undefined,
body: body.trim(),
sourcePath: file,
})
}
return agents
}
async function loadCommands(commandsDirs: string[]): Promise<ClaudeCommand[]> {
const files = await collectMarkdownFiles(commandsDirs)
const commands: ClaudeCommand[] = []
for (const file of files) {
const raw = await readText(file)
const { data, body } = parseFrontmatter(raw)
const name = (data.name as string) ?? path.basename(file, ".md")
const allowedTools = parseAllowedTools(data["allowed-tools"])
commands.push({
name,
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
model: data.model as string | undefined,
allowedTools,
body: body.trim(),
sourcePath: file,
})
}
return commands
}
async function loadSkills(skillsDirs: string[]): Promise<ClaudeSkill[]> {
const entries = await collectFiles(skillsDirs)
const skillFiles = entries.filter((file) => path.basename(file) === "SKILL.md")
const skills: ClaudeSkill[] = []
for (const file of skillFiles) {
const raw = await readText(file)
const { data } = parseFrontmatter(raw)
const name = (data.name as string) ?? path.basename(path.dirname(file))
skills.push({
name,
description: data.description as string | undefined,
sourceDir: path.dirname(file),
skillPath: file,
})
}
return skills
}
async function loadHooks(root: string, hooksField?: ClaudeManifest["hooks"]): Promise<ClaudeHooks | undefined> {
const hookConfigs: ClaudeHooks[] = []
const defaultPath = path.join(root, "hooks", "hooks.json")
if (await pathExists(defaultPath)) {
hookConfigs.push(await readJson<ClaudeHooks>(defaultPath))
}
if (hooksField) {
if (typeof hooksField === "string" || Array.isArray(hooksField)) {
const hookPaths = toPathList(hooksField)
for (const hookPath of hookPaths) {
const resolved = resolveWithinRoot(root, hookPath, "hooks path")
if (await pathExists(resolved)) {
hookConfigs.push(await readJson<ClaudeHooks>(resolved))
}
}
} else {
hookConfigs.push(hooksField)
}
}
if (hookConfigs.length === 0) return undefined
return mergeHooks(hookConfigs)
}
async function loadMcpServers(
root: string,
manifest: ClaudeManifest,
): Promise<Record<string, ClaudeMcpServer> | undefined> {
const field = manifest.mcpServers
if (field) {
if (typeof field === "string" || Array.isArray(field)) {
return mergeMcpConfigs(await loadMcpPaths(root, field))
}
return field as Record<string, ClaudeMcpServer>
}
const mcpPath = path.join(root, ".mcp.json")
if (await pathExists(mcpPath)) {
return readJson<Record<string, ClaudeMcpServer>>(mcpPath)
}
return undefined
}
function parseAllowedTools(value: unknown): string[] | undefined {
if (!value) return undefined
if (Array.isArray(value)) {
return value.map((item) => String(item))
}
if (typeof value === "string") {
return value
.split(/,/)
.map((item) => item.trim())
.filter(Boolean)
}
return undefined
}
function resolveComponentDirs(
root: string,
defaultDir: string,
custom?: string | string[],
): string[] {
const dirs = [path.join(root, defaultDir)]
for (const entry of toPathList(custom)) {
dirs.push(resolveWithinRoot(root, entry, `${defaultDir} path`))
}
return dirs
}
function toPathList(value?: string | string[]): string[] {
if (!value) return []
if (Array.isArray(value)) return value
return [value]
}
async function collectMarkdownFiles(dirs: string[]): Promise<string[]> {
const entries = await collectFiles(dirs)
return entries.filter((file) => file.endsWith(".md"))
}
async function collectFiles(dirs: string[]): Promise<string[]> {
const files: string[] = []
for (const dir of dirs) {
if (!(await pathExists(dir))) continue
const entries = await walkFiles(dir)
files.push(...entries)
}
return files
}
function mergeHooks(hooksList: ClaudeHooks[]): ClaudeHooks {
const merged: ClaudeHooks = { hooks: {} }
for (const hooks of hooksList) {
for (const [event, matchers] of Object.entries(hooks.hooks)) {
if (!merged.hooks[event]) {
merged.hooks[event] = []
}
merged.hooks[event].push(...matchers)
}
}
return merged
}
async function loadMcpPaths(
root: string,
value: string | string[],
): Promise<Record<string, ClaudeMcpServer>[]> {
const configs: Record<string, ClaudeMcpServer>[] = []
for (const entry of toPathList(value)) {
const resolved = resolveWithinRoot(root, entry, "mcpServers path")
if (await pathExists(resolved)) {
configs.push(await readJson<Record<string, ClaudeMcpServer>>(resolved))
}
}
return configs
}
function mergeMcpConfigs(configs: Record<string, ClaudeMcpServer>[]): Record<string, ClaudeMcpServer> {
return configs.reduce((acc, config) => ({ ...acc, ...config }), {})
}
function resolveWithinRoot(root: string, entry: string, label: string): string {
const resolvedRoot = path.resolve(root)
const resolvedPath = path.resolve(root, entry)
if (resolvedPath === resolvedRoot || resolvedPath.startsWith(resolvedRoot + path.sep)) {
return resolvedPath
}
throw new Error(`Invalid ${label}: ${entry}. Paths must stay within the plugin root.`)
}

91
src/targets/codex.ts Normal file
View File

@@ -0,0 +1,91 @@
import path from "path"
import { copyDir, ensureDir, writeText } from "../utils/files"
import type { CodexBundle } from "../types/codex"
import type { ClaudeMcpServer } from "../types/claude"
export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): Promise<void> {
const codexRoot = resolveCodexRoot(outputRoot)
await ensureDir(codexRoot)
if (bundle.prompts.length > 0) {
const promptsDir = path.join(codexRoot, "prompts")
for (const prompt of bundle.prompts) {
await writeText(path.join(promptsDir, `${prompt.name}.md`), prompt.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
const skillsRoot = path.join(codexRoot, "skills")
for (const skill of bundle.skillDirs) {
await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
}
}
if (bundle.generatedSkills.length > 0) {
const skillsRoot = path.join(codexRoot, "skills")
for (const skill of bundle.generatedSkills) {
await writeText(path.join(skillsRoot, skill.name, "SKILL.md"), skill.content + "\n")
}
}
const config = renderCodexConfig(bundle.mcpServers)
if (config) {
await writeText(path.join(codexRoot, "config.toml"), config)
}
}
function resolveCodexRoot(outputRoot: string): string {
return path.basename(outputRoot) === ".codex" ? outputRoot : path.join(outputRoot, ".codex")
}
export function renderCodexConfig(mcpServers?: Record<string, ClaudeMcpServer>): string | null {
if (!mcpServers || Object.keys(mcpServers).length === 0) return null
const lines: string[] = ["# Generated by compound-plugin", ""]
for (const [name, server] of Object.entries(mcpServers)) {
const key = formatTomlKey(name)
lines.push(`[mcp_servers.${key}]`)
if (server.command) {
lines.push(`command = ${formatTomlString(server.command)}`)
if (server.args && server.args.length > 0) {
const args = server.args.map((arg) => formatTomlString(arg)).join(", ")
lines.push(`args = [${args}]`)
}
if (server.env && Object.keys(server.env).length > 0) {
lines.push("")
lines.push(`[mcp_servers.${key}.env]`)
for (const [envKey, value] of Object.entries(server.env)) {
lines.push(`${formatTomlKey(envKey)} = ${formatTomlString(value)}`)
}
}
} else if (server.url) {
lines.push(`url = ${formatTomlString(server.url)}`)
if (server.headers && Object.keys(server.headers).length > 0) {
lines.push(`http_headers = ${formatTomlInlineTable(server.headers)}`)
}
}
lines.push("")
}
return lines.join("\n")
}
function formatTomlString(value: string): string {
return JSON.stringify(value)
}
function formatTomlKey(value: string): string {
if (/^[A-Za-z0-9_-]+$/.test(value)) return value
return JSON.stringify(value)
}
function formatTomlInlineTable(entries: Record<string, string>): string {
const parts = Object.entries(entries).map(
([key, value]) => `${formatTomlKey(key)} = ${formatTomlString(value)}`,
)
return `{ ${parts.join(", ")} }`
}

29
src/targets/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { ClaudePlugin } from "../types/claude"
import type { OpenCodeBundle } from "../types/opencode"
import type { CodexBundle } from "../types/codex"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { writeOpenCodeBundle } from "./opencode"
import { writeCodexBundle } from "./codex"
export type TargetHandler<TBundle = unknown> = {
name: string
implemented: boolean
convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null
write: (outputRoot: string, bundle: TBundle) => Promise<void>
}
export const targets: Record<string, TargetHandler> = {
opencode: {
name: "opencode",
implemented: true,
convert: convertClaudeToOpenCode,
write: writeOpenCodeBundle,
},
codex: {
name: "codex",
implemented: true,
convert: convertClaudeToCodex as TargetHandler<CodexBundle>["convert"],
write: writeCodexBundle as TargetHandler<CodexBundle>["write"],
},
}

48
src/targets/opencode.ts Normal file
View File

@@ -0,0 +1,48 @@
import path from "path"
import { copyDir, ensureDir, writeJson, writeText } from "../utils/files"
import type { OpenCodeBundle } from "../types/opencode"
export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise<void> {
const paths = resolveOpenCodePaths(outputRoot)
await ensureDir(paths.root)
await writeJson(paths.configPath, bundle.config)
const agentsDir = paths.agentsDir
for (const agent of bundle.agents) {
await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n")
}
if (bundle.plugins.length > 0) {
const pluginsDir = paths.pluginsDir
for (const plugin of bundle.plugins) {
await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
const skillsRoot = paths.skillsDir
for (const skill of bundle.skillDirs) {
await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
}
}
}
function resolveOpenCodePaths(outputRoot: string) {
if (path.basename(outputRoot) === ".opencode") {
return {
root: outputRoot,
configPath: path.join(outputRoot, "opencode.json"),
agentsDir: path.join(outputRoot, "agents"),
pluginsDir: path.join(outputRoot, "plugins"),
skillsDir: path.join(outputRoot, "skills"),
}
}
return {
root: outputRoot,
configPath: path.join(outputRoot, "opencode.json"),
agentsDir: path.join(outputRoot, ".opencode", "agents"),
pluginsDir: path.join(outputRoot, ".opencode", "plugins"),
skillsDir: path.join(outputRoot, ".opencode", "skills"),
}
}

88
src/types/claude.ts Normal file
View File

@@ -0,0 +1,88 @@
export type ClaudeMcpServer = {
type?: string
command?: string
args?: string[]
url?: string
env?: Record<string, string>
headers?: Record<string, string>
}
export type ClaudeManifest = {
name: string
version: string
description?: string
author?: {
name?: string
email?: string
url?: string
}
keywords?: string[]
agents?: string | string[]
commands?: string | string[]
skills?: string | string[]
hooks?: string | string[] | ClaudeHooks
mcpServers?: Record<string, ClaudeMcpServer> | string | string[]
}
export type ClaudeAgent = {
name: string
description?: string
capabilities?: string[]
model?: string
body: string
sourcePath: string
}
export type ClaudeCommand = {
name: string
description?: string
argumentHint?: string
model?: string
allowedTools?: string[]
body: string
sourcePath: string
}
export type ClaudeSkill = {
name: string
description?: string
sourceDir: string
skillPath: string
}
export type ClaudePlugin = {
root: string
manifest: ClaudeManifest
agents: ClaudeAgent[]
commands: ClaudeCommand[]
skills: ClaudeSkill[]
hooks?: ClaudeHooks
mcpServers?: Record<string, ClaudeMcpServer>
}
export type ClaudeHookCommand = {
type: "command"
command: string
timeout?: number
}
export type ClaudeHookPrompt = {
type: "prompt"
prompt: string
}
export type ClaudeHookAgent = {
type: "agent"
agent: string
}
export type ClaudeHookEntry = ClaudeHookCommand | ClaudeHookPrompt | ClaudeHookAgent
export type ClaudeHookMatcher = {
matcher: string
hooks: ClaudeHookEntry[]
}
export type ClaudeHooks = {
hooks: Record<string, ClaudeHookMatcher[]>
}

23
src/types/codex.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { ClaudeMcpServer } from "./claude"
export type CodexPrompt = {
name: string
content: string
}
export type CodexSkillDir = {
name: string
sourceDir: string
}
export type CodexGeneratedSkill = {
name: string
content: string
}
export type CodexBundle = {
prompts: CodexPrompt[]
skillDirs: CodexSkillDir[]
generatedSkills: CodexGeneratedSkill[]
mcpServers?: Record<string, ClaudeMcpServer>
}

54
src/types/opencode.ts Normal file
View File

@@ -0,0 +1,54 @@
export type OpenCodePermission = "allow" | "ask" | "deny"
export type OpenCodeConfig = {
$schema?: string
model?: string
default_agent?: string
tools?: Record<string, boolean>
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
agent?: Record<string, OpenCodeAgentConfig>
command?: Record<string, OpenCodeCommandConfig>
mcp?: Record<string, OpenCodeMcpServer>
}
export type OpenCodeAgentConfig = {
description?: string
mode?: "primary" | "subagent"
model?: string
temperature?: number
tools?: Record<string, boolean>
permission?: Record<string, OpenCodePermission>
}
export type OpenCodeCommandConfig = {
description?: string
model?: string
agent?: string
template: string
}
export type OpenCodeMcpServer = {
type: "local" | "remote"
command?: string[]
url?: string
environment?: Record<string, string>
headers?: Record<string, string>
enabled?: boolean
}
export type OpenCodeAgentFile = {
name: string
content: string
}
export type OpenCodePluginFile = {
name: string
content: string
}
export type OpenCodeBundle = {
config: OpenCodeConfig
agents: OpenCodeAgentFile[]
plugins: OpenCodePluginFile[]
skillDirs: { sourceDir: string; name: string }[]
}

64
src/utils/codex-agents.ts Normal file
View File

@@ -0,0 +1,64 @@
import path from "path"
import { ensureDir, pathExists, readText, writeText } from "./files"
export const CODEX_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND CODEX TOOL MAP -->"
export const CODEX_AGENTS_BLOCK_END = "<!-- END COMPOUND CODEX TOOL MAP -->"
const CODEX_AGENTS_BLOCK_BODY = `## Compound Codex Tool Mapping (Claude Compatibility)
This section maps Claude Code plugin tool references to Codex behavior.
Only this block is managed automatically.
Tool mapping:
- Read: use shell reads (cat/sed) or rg
- Write: create files via shell redirection or apply_patch
- Edit/MultiEdit: use apply_patch
- Bash: use shell_command
- Grep: use rg (fallback: grep)
- Glob: use rg --files or find
- LS: use ls via shell_command
- WebFetch/WebSearch: use curl or Context7 for library docs
- AskUserQuestion/Question: ask the user in chat
- Task/Subagent/Parallel: run sequentially in main thread; use multi_tool_use.parallel for tool calls
- TodoWrite/TodoRead: use file-based todos in todos/ with file-todos skill
- Skill: open the referenced SKILL.md and follow it
- ExitPlanMode: ignore
`
export async function ensureCodexAgentsFile(codexHome: string): Promise<void> {
await ensureDir(codexHome)
const filePath = path.join(codexHome, "AGENTS.md")
const block = buildCodexAgentsBlock()
if (!(await pathExists(filePath))) {
await writeText(filePath, block + "\n")
return
}
const existing = await readText(filePath)
const updated = upsertBlock(existing, block)
if (updated !== existing) {
await writeText(filePath, updated)
}
}
function buildCodexAgentsBlock(): string {
return [CODEX_AGENTS_BLOCK_START, CODEX_AGENTS_BLOCK_BODY.trim(), CODEX_AGENTS_BLOCK_END].join("\n")
}
function upsertBlock(existing: string, block: string): string {
const startIndex = existing.indexOf(CODEX_AGENTS_BLOCK_START)
const endIndex = existing.indexOf(CODEX_AGENTS_BLOCK_END)
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
const before = existing.slice(0, startIndex).trimEnd()
const after = existing.slice(endIndex + CODEX_AGENTS_BLOCK_END.length).trimStart()
return [before, block, after].filter(Boolean).join("\n\n") + "\n"
}
if (existing.trim().length === 0) {
return block + "\n"
}
return existing.trimEnd() + "\n\n" + block + "\n"
}

64
src/utils/files.ts Normal file
View File

@@ -0,0 +1,64 @@
import { promises as fs } from "fs"
import path from "path"
export async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
export async function ensureDir(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true })
}
export async function readText(filePath: string): Promise<string> {
return fs.readFile(filePath, "utf8")
}
export async function readJson<T>(filePath: string): Promise<T> {
const raw = await readText(filePath)
return JSON.parse(raw) as T
}
export async function writeText(filePath: string, content: string): Promise<void> {
await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content, "utf8")
}
export async function writeJson(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2)
await writeText(filePath, content + "\n")
}
export async function walkFiles(root: string): Promise<string[]> {
const entries = await fs.readdir(root, { withFileTypes: true })
const results: string[] = []
for (const entry of entries) {
const fullPath = path.join(root, entry.name)
if (entry.isDirectory()) {
const nested = await walkFiles(fullPath)
results.push(...nested)
} else if (entry.isFile()) {
results.push(fullPath)
}
}
return results
}
export async function copyDir(sourceDir: string, targetDir: string): Promise<void> {
await ensureDir(targetDir)
const entries = await fs.readdir(sourceDir, { withFileTypes: true })
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name)
const targetPath = path.join(targetDir, entry.name)
if (entry.isDirectory()) {
await copyDir(sourcePath, targetPath)
} else if (entry.isFile()) {
await ensureDir(path.dirname(targetPath))
await fs.copyFile(sourcePath, targetPath)
}
}
}

65
src/utils/frontmatter.ts Normal file
View File

@@ -0,0 +1,65 @@
import { load } from "js-yaml"
export type FrontmatterResult = {
data: Record<string, unknown>
body: string
}
export function parseFrontmatter(raw: string): FrontmatterResult {
const lines = raw.split(/\r?\n/)
if (lines.length === 0 || lines[0].trim() !== "---") {
return { data: {}, body: raw }
}
let endIndex = -1
for (let i = 1; i < lines.length; i += 1) {
if (lines[i].trim() === "---") {
endIndex = i
break
}
}
if (endIndex === -1) {
return { data: {}, body: raw }
}
const yamlText = lines.slice(1, endIndex).join("\n")
const body = lines.slice(endIndex + 1).join("\n")
const parsed = load(yamlText)
const data = (parsed && typeof parsed === "object") ? (parsed as Record<string, unknown>) : {}
return { data, body }
}
export function formatFrontmatter(data: Record<string, unknown>, body: string): string {
const yaml = Object.entries(data)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => formatYamlLine(key, value))
.join("\n")
if (yaml.trim().length === 0) {
return body
}
return [`---`, yaml, `---`, "", body].join("\n")
}
function formatYamlLine(key: string, value: unknown): string {
if (Array.isArray(value)) {
const items = value.map((item) => ` - ${formatYamlValue(item)}`)
return [key + ":", ...items].join("\n")
}
return `${key}: ${formatYamlValue(value)}`
}
function formatYamlValue(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "number" || typeof value === "boolean") return String(value)
const raw = String(value)
if (raw.includes("\n")) {
return `|\n${raw.split("\n").map((line) => ` ${line}`).join("\n")}`
}
if (raw.includes(":") || raw.startsWith("[") || raw.startsWith("{")) {
return JSON.stringify(raw)
}
return raw
}