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:
156
src/commands/convert.ts
Normal file
156
src/commands/convert.ts
Normal 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
221
src/commands/install.ts
Normal 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
37
src/commands/list.ts
Normal 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"))
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user