chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:20:01 +08:00
commit e65605f012
669 changed files with 128771 additions and 0 deletions
+739
View File
@@ -0,0 +1,739 @@
import { defineCommand } from "citty"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { loadClaudePlugin } from "../parsers/claude"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToKiro } from "../converters/claude-to-kiro"
import { convertClaudeToOpenCode } from "../converters/claude-to-opencode"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import {
getLegacyCodexArtifacts,
getLegacyCopilotArtifacts,
getLegacyDroidArtifacts,
getLegacyKiroArtifacts,
getLegacyOpenCodeArtifacts,
getLegacyPiArtifacts,
getLegacyPluginArtifacts,
getLegacyWindsurfArtifacts,
} from "../data/plugin-legacy-artifacts"
import { moveLegacyArtifactToBackup } from "../targets/managed-artifacts"
import { isManagedCodexAgentsSymlink, readCodexInstallManifest, resolveCodexManagedRoots } from "../targets/codex"
import { classifyCodexLegacyPromptOwnership, isLegacyAgentArtifactOwned, isLegacySkillArtifactOwned } from "../utils/legacy-cleanup"
import { commandNameToRelativePath, isSafeManagedPath, pathExists, readJson, sanitizePathName } from "../utils/files"
import { resolveOpenCodeGlobalRoot } from "../utils/opencode-config"
import { expandHome, resolveCodexHome, resolveTargetHome } from "../utils/resolve-home"
const cleanupTargets = ["codex", "opencode", "pi", "kiro", "copilot", "droid", "qwen", "windsurf"] as const
type CleanupTarget = typeof cleanupTargets[number]
type CleanupResult = {
target: CleanupTarget
root: string
moved: number
}
export default defineCommand({
meta: {
name: "cleanup",
description: "Back up stale compound-engineering artifacts from previous installs",
},
args: {
plugin: {
type: "positional",
required: false,
description: "Plugin name or local plugin path (default: compound-engineering)",
},
target: {
type: "string",
default: "all",
description: "Target to clean: codex | opencode | pi | kiro | copilot | droid | qwen | windsurf | all",
},
output: {
type: "string",
alias: "o",
description: "Workspace/project root for workspace-scoped legacy installs",
},
codexHome: {
type: "string",
alias: "codex-home",
description: "Codex root to clean (default: $CODEX_HOME or ~/.codex)",
},
piHome: {
type: "string",
alias: "pi-home",
description: "Pi root to clean (default: ~/.pi/agent)",
},
opencodeHome: {
type: "string",
alias: "opencode-home",
description: "OpenCode root to clean (default: $OPENCODE_CONFIG_DIR or ~/.config/opencode)",
},
kiroHome: {
type: "string",
alias: "kiro-home",
description: "Kiro root to clean (default: ./.kiro)",
},
copilotHome: {
type: "string",
alias: "copilot-home",
description: "Copilot root to clean (default: ~/.copilot)",
},
droidHome: {
type: "string",
alias: "droid-home",
description: "Droid root to clean (default: ~/.factory)",
},
qwenHome: {
type: "string",
alias: "qwen-home",
description: "Qwen root to clean for legacy Bun installs (default: ~/.qwen)",
},
windsurfHome: {
type: "string",
alias: "windsurf-home",
description: "Deprecated Windsurf root to clean (default: ~/.codeium/windsurf)",
},
agentsHome: {
type: "string",
alias: "agents-home",
description: "Shared .agents root to clean for shadowing skills (default: ~/.agents)",
},
},
async run({ args }) {
const pluginPath = await resolveCleanupPluginPath(args.plugin ? String(args.plugin) : "compound-engineering")
const plugin = await loadClaudePlugin(pluginPath)
if (plugin.manifest.name !== "compound-engineering") {
throw new Error("Cleanup currently supports only the compound-engineering plugin.")
}
const targetNames = resolveCleanupTargets(String(args.target))
const outputRoot = resolveWorkspaceRoot(args.output)
const hasExplicitOpenCodeHome = hasExplicitValue(args.opencodeHome)
const roots = {
codexHome: resolveCodexHome(args.codexHome),
piHome: resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")),
// Mirror install: respect OPENCODE_CONFIG_DIR before falling back to the
// XDG default so cleanup scans the same directory install wrote to.
opencodeHome: resolveTargetHome(args.opencodeHome, resolveOpenCodeGlobalRoot()),
kiroHome: resolveTargetHome(args.kiroHome, path.join(outputRoot, ".kiro")),
copilotHome: resolveTargetHome(args.copilotHome, path.join(os.homedir(), ".copilot")),
droidHome: resolveTargetHome(args.droidHome, path.join(os.homedir(), ".factory")),
qwenHome: resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen")),
windsurfHome: resolveTargetHome(args.windsurfHome, path.join(os.homedir(), ".codeium", "windsurf")),
agentsHome: resolveTargetHome(args.agentsHome, path.join(os.homedir(), ".agents")),
workspaceRoot: outputRoot,
hasExplicitOutput: hasExplicitValue(args.output),
hasExplicitOpenCodeHome,
}
const results: CleanupResult[] = []
for (const target of targetNames) {
results.push(...await cleanupTarget(target, plugin, roots))
}
const total = results.reduce((sum, result) => sum + result.moved, 0)
for (const result of results) {
console.log(`Cleaned ${result.target} at ${result.root}: backed up ${result.moved} artifact(s)`)
}
console.log(`Cleanup complete for ${plugin.manifest.name}: backed up ${total} artifact(s).`)
},
})
async function cleanupTarget(
target: CleanupTarget,
plugin: Awaited<ReturnType<typeof loadClaudePlugin>>,
roots: {
codexHome: string
piHome: string
opencodeHome: string
kiroHome: string
copilotHome: string
droidHome: string
qwenHome: string
windsurfHome: string
agentsHome: string
workspaceRoot: string
hasExplicitOutput: boolean
hasExplicitOpenCodeHome: boolean
},
): Promise<CleanupResult[]> {
switch (target) {
case "codex":
return [
await cleanupCodex(plugin, roots.codexHome),
await cleanupCodexSharedAgents(plugin, roots.agentsHome, roots.codexHome),
]
case "opencode": {
// Mirror install: when `--output <workspace>` is passed (without an
// explicit `--opencode-home`), install writes managed artifacts under
// `<workspace>/.opencode/{agents,skills,commands,plugins}`. Cleanup must
// scan the same directory or stale workspace artifacts get left behind.
// An explicit `--opencode-home` remains authoritative so users can still
// target a specific global-style root. When neither is set, fall back to
// the OpenCode global root (OPENCODE_CONFIG_DIR / XDG default).
if (roots.hasExplicitOpenCodeHome) {
return [await cleanupOpenCode(plugin, roots.opencodeHome)]
}
if (roots.hasExplicitOutput) {
return [await cleanupOpenCode(plugin, resolveOpenCodeWorkspaceRoot(roots.workspaceRoot))]
}
return [await cleanupOpenCode(plugin, roots.opencodeHome)]
}
case "pi":
return [await cleanupPi(plugin, roots.piHome)]
case "kiro":
return [await cleanupKiro(plugin, roots.kiroHome)]
case "copilot": {
// Same race-prevention as Copilot below: if a user points `--copilot-home`,
// `--output`, or `--agents-home` at the same directory these parallel
// passes collide on renames. Default values are distinct so the dedup
// is mostly defensive, but keep the shape consistent across targets
// that fan out with `Promise.all`.
const rootsToClean = roots.hasExplicitOutput
? [resolveCopilotWorkspaceRoot(roots.workspaceRoot)]
: await dedupeRoots([roots.copilotHome, resolveCopilotWorkspaceRoot(roots.workspaceRoot), roots.agentsHome])
return await Promise.all(rootsToClean.map((root) => cleanupCopilot(plugin, root)))
}
case "droid":
return [await cleanupDroid(plugin, roots.hasExplicitOutput ? resolveDroidWorkspaceRoot(roots.workspaceRoot) : roots.droidHome)]
case "qwen":
return [await cleanupQwen(plugin, roots.qwenHome)]
case "windsurf": {
// Same race-prevention as Copilot: dedup after path resolution
// so overlapping overrides can't produce concurrent renames on the
// same directory.
const rootsToClean = roots.hasExplicitOutput
? [resolveWindsurfWorkspaceRoot(roots.workspaceRoot)]
: await dedupeRoots([roots.windsurfHome, resolveWindsurfWorkspaceRoot(roots.workspaceRoot)])
return await Promise.all(rootsToClean.map((root) => cleanupWindsurf(plugin, root)))
}
}
}
async function cleanupCodex(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, codexRoot: string): Promise<CleanupResult> {
const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
// Cleanup needs the FULL bundle (skills, command-skills, agents) to know
// what's "current" vs "legacy." The agents-only default of `--to codex`
// is wrong here; it would make cleanup think every existing skill is
// legacy and remove them.
codexIncludeSkills: true,
})
const artifacts = getLegacyCodexArtifacts(bundle)
const currentNamespacedSkills = new Set([
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
])
const currentPrompts = new Set(bundle.prompts.map((prompt) => `${sanitizePathName(prompt.name)}.md`))
const currentAgents = new Set((bundle.agents ?? []).map((agent) => `${sanitizePathName(agent.name)}.toml`))
const managedDir = path.join(codexRoot, plugin.manifest.name)
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(codexRoot, "skills"), skillName, "Codex")
if (!currentNamespacedSkills.has(skillName)) {
moved += await moveIfExists(
managedDir,
"skills",
path.join(codexRoot, "skills", plugin.manifest.name),
skillName,
"Codex",
)
}
}
for (const promptFile of artifacts.prompts) {
// Ownership gate: `~/.codex/prompts/` is a shared directory across plugins
// and user-authored prompts. A filename match against the historical CE
// allow-list is not a strong enough signal — a user who creates
// `~/.codex/prompts/ce-plan.md` for their own workflow would otherwise see
// it swept into `compound-engineering/legacy-backup/` on every cleanup run.
// Mirror the body + frontmatter check used by `cleanupStalePrompts` so
// install-time and standalone cleanup paths treat ownership identically.
// "unknown" (no fingerprint on record) falls through so fully-retired
// historical wrappers still get cleaned up. Manifest-driven migration
// below is already safe because it only touches files CE recorded writing.
const promptPath = path.join(codexRoot, "prompts", promptFile)
const ownership = await classifyCodexLegacyPromptOwnership(promptPath)
if (ownership === "foreign") continue
moved += await moveIfExists(managedDir, "prompts", path.join(codexRoot, "prompts"), promptFile, "Codex")
}
for (const agentFile of artifacts.agents ?? []) {
moved += await moveIfExists(
managedDir,
"agents",
path.join(codexRoot, "agents", plugin.manifest.name),
agentFile,
"Codex",
)
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(codexRoot, "agents"), agentFile, "Codex", ".toml")
}
// Manifest-driven migration: read the previous install's manifest and
// migrate any entries that are no longer in the current bundle. This
// catches artifacts whose *type or emission format* has changed between
// CE versions (e.g., agents that were previously emitted as generated
// skills under `skills/<plugin>/<agent-name>/` but are now emitted as
// TOML custom agents under `agents/<plugin>/<name>.toml`). The historical
// allow-list only covers renamed/removed names — it does not cover
// current-named artifacts that moved locations.
const installedManifest = await readCodexInstallManifest(codexRoot, plugin.manifest.name)
if (installedManifest) {
for (const skillName of installedManifest.skills) {
if (currentNamespacedSkills.has(skillName)) continue
moved += await moveIfExists(
managedDir,
"skills",
path.join(codexRoot, "skills", plugin.manifest.name),
skillName,
"Codex",
)
}
for (const promptFile of installedManifest.prompts) {
if (currentPrompts.has(promptFile)) continue
moved += await moveIfExists(managedDir, "prompts", path.join(codexRoot, "prompts"), promptFile, "Codex")
}
for (const agentFile of installedManifest.agents) {
if (currentAgents.has(agentFile)) continue
moved += await moveIfExists(
managedDir,
"agents",
path.join(codexRoot, "agents", plugin.manifest.name),
agentFile,
"Codex",
)
}
}
return { target: "codex", root: codexRoot, moved }
}
async function cleanupCodexSharedAgents(
plugin: Awaited<ReturnType<typeof loadClaudePlugin>>,
agentsRoot: string,
codexRoot: string,
): Promise<CleanupResult> {
// Ownership check: `~/.agents/skills/` is a cross-plugin shared store, so a
// name collision alone is not a strong enough signal to move an entry. CE
// only ever emitted symlinks into this tree pointing at skill directories
// inside its own Codex install root, so we restrict cleanup to symlinks
// whose resolved target lives inside a CE-managed Codex root. Plain files
// or directories at colliding names are user-authored by definition and
// left alone; symlinks pointing elsewhere (another plugin, a user's own
// skill checkout) are similarly skipped. Mirrors
// `cleanupLegacyAgentsSkillSymlinks` in `src/targets/codex.ts`, which uses
// the same ownership gate at install time.
const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
// Same reason as cleanupCodex: cleanup needs the full bundle to make
// current-vs-legacy decisions correctly.
codexIncludeSkills: true,
})
const artifacts = getLegacyCodexArtifacts(bundle)
const managedDir = path.join(agentsRoot, "compound-engineering")
const agentsSkillsDir = path.join(agentsRoot, "skills")
const managedRoots = await resolveCodexManagedRoots(codexRoot, plugin.manifest.name)
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveIfSymlinkManaged(
managedDir,
"skills",
agentsSkillsDir,
skillName,
".agents",
managedRoots,
)
}
return { target: "codex", root: agentsRoot, moved }
}
async function moveIfSymlinkManaged(
managedDir: string,
kind: string,
artifactRoot: string,
relativePath: string,
label: string,
managedRoots: string[],
): Promise<number> {
// Defense in depth — same guard as `moveIfExists`: even though legacy
// allow-list names are safe by construction, re-check the join so a future
// caller can't issue an out-of-tree rename via `moveLegacyArtifactToBackup`.
if (!isSafeManagedPath(artifactRoot, relativePath)) return 0
const artifactPath = path.join(artifactRoot, ...relativePath.split("/"))
if (!(await isManagedCodexAgentsSymlink(artifactPath, managedRoots))) return 0
// `isManagedCodexAgentsSymlink` already verified this symlink's resolved
// target lives inside a CE-managed Codex root -- that is stronger proof of
// CE ownership than the generic "is a symlink" preservation guard, and the
// whole point of this sweep is to relocate the symlink node itself. Skip
// the guard so it doesn't block moving a confirmed CE-owned symlink.
await moveLegacyArtifactToBackup(managedDir, kind, artifactRoot, relativePath, label, { skipSymlinkGuard: true })
return 1
}
async function cleanupOpenCode(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, opencodeRoot: string): Promise<CleanupResult> {
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyOpenCodeArtifacts(bundle)
const managedDir = path.join(opencodeRoot, "compound-engineering")
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(opencodeRoot, "skills"), skillName, "OpenCode")
}
for (const agentPath of artifacts.agents) {
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(opencodeRoot, "agents"), agentPath, "OpenCode", ".md")
}
for (const commandPath of artifacts.commands) {
moved += await moveIfExists(managedDir, "commands", path.join(opencodeRoot, "commands"), commandPath, "OpenCode")
}
return { target: "opencode", root: opencodeRoot, moved }
}
async function cleanupPi(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, piRoot: string): Promise<CleanupResult> {
const bundle = convertClaudeToPi(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyPiArtifacts(bundle)
const managedDir = path.join(piRoot, "compound-engineering")
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(piRoot, "skills"), skillName, "Pi")
}
for (const promptFile of artifacts.prompts) {
moved += await moveIfExists(managedDir, "prompts", path.join(piRoot, "prompts"), promptFile, "Pi")
}
for (const agentPath of artifacts.agents ?? []) {
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(piRoot, "agents"), agentPath, "Pi", ".md")
}
return { target: "pi", root: piRoot, moved }
}
async function cleanupKiro(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, kiroRoot: string): Promise<CleanupResult> {
const bundle = convertClaudeToKiro(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyKiroArtifacts(bundle)
const skillNames = new Set([
...artifacts.skills,
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
])
const agentNames = new Set([
...artifacts.agents,
...bundle.agents.map((agent) => sanitizePathName(agent.name)),
])
const managedDir = path.join(kiroRoot, "compound-engineering")
let moved = 0
for (const skillName of skillNames) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(kiroRoot, "skills"), skillName, "Kiro")
}
for (const agentName of agentNames) {
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(kiroRoot, "agents", "prompts"), `${agentName}.md`, "Kiro", ".md")
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(kiroRoot, "agents"), `${agentName}.json`, "Kiro", ".json")
}
return { target: "kiro", root: kiroRoot, moved }
}
async function cleanupCopilot(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, copilotRoot: string): Promise<CleanupResult> {
// IMPORTANT: legacy detection for Copilot roots must be driven exclusively
// by the historical allow-list returned from `getLegacyCopilotArtifacts`
// (see EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN). Mirrors the Codex/Droid/Windsurf
// cleanup fixes: seeding candidates from the current plugin bundle would
// sweep up user-authored files at workspace paths like
// `.github/skills/ce-plan/SKILL.md` or `.github/agents/<name>.agent.md` that
// happen to share a name with a current CE artifact but were never
// installed by this plugin. The Copilot writer has been removed — users now
// install via `copilot plugin install` — so this cleanup exists solely to
// back up stale files from past manual installs, which means the current
// bundle was never a valid candidate source.
const bundle = convertClaudeToCopilot(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyCopilotArtifacts(bundle)
const managedDir = path.join(copilotRoot, "compound-engineering")
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(copilotRoot, "skills"), skillName, "Copilot")
}
for (const agentPath of artifacts.agents) {
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(copilotRoot, "agents"), agentPath, "Copilot", ".agent.md")
}
return { target: "copilot", root: copilotRoot, moved }
}
async function cleanupDroid(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, droidRoot: string): Promise<CleanupResult> {
// IMPORTANT: legacy detection for `~/.factory/{skills,droids,commands}` must
// be driven exclusively by the historical allow-list returned from
// `getLegacyDroidArtifacts` (see EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN). Mirrors
// the Codex cleanup fix: seeding candidates from the current plugin bundle
// would sweep up user-authored files at `~/.factory/commands/<name>.md`
// (or the skills/droids equivalents) that happen to share a name with a
// current CE artifact but were never installed by this plugin.
const bundle = convertClaudeToDroid(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyDroidArtifacts(bundle)
const managedDir = path.join(droidRoot, "compound-engineering")
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(droidRoot, "skills"), skillName, "Droid")
}
for (const droidPath of artifacts.droids) {
moved += await moveLegacyAgentIfOwned(managedDir, "droids", path.join(droidRoot, "droids"), droidPath, "Droid", ".md")
}
for (const commandPath of artifacts.commands) {
moved += await moveIfExists(managedDir, "commands", path.join(droidRoot, "commands"), commandPath, "Droid")
}
return { target: "droid", root: droidRoot, moved }
}
async function cleanupQwen(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, qwenRoot: string): Promise<CleanupResult> {
// IMPORTANT: legacy detection for `~/.qwen/{skills,agents,commands}` must be
// driven exclusively by the historical allow-list in
// `EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN`. Mirrors the Codex/Droid/Windsurf/
// Copilot cleanup fixes: the Bun-based Qwen writer was replaced by native
// `qwen extensions install`, so this cleanup exists solely to back up stale
// files from legacy manual installs. Seeding from the current plugin bundle
// (`plugin.skills`, `plugin.agents`, `plugin.commands`) would sweep up
// user-authored files at paths like `~/.qwen/skills/ce-debug/SKILL.md` or
// `~/.qwen/agents/ce-correctness-reviewer.md` that happen to share a name
// with a current CE artifact but were never installed by this plugin.
const managedDir = path.join(qwenRoot, plugin.manifest.name)
const extras = getLegacyPluginArtifacts(plugin.manifest.name)
const skillNames = new Set((extras.skills ?? []).map(sanitizePathName))
const agentNames = new Set((extras.agents ?? []).map(sanitizePathName))
// The old Bun-based Qwen writer wrote commands via `resolveCommandPath`,
// which split colon-namespaced names into nested directories (e.g.
// `compound:plan` -> `commands/compound/plan.md`). We also probe the flat
// sanitized form (`commands/compound-plan.md`) in case a historical install
// landed commands there. Both shapes need cleanup so stale files can't
// shadow native plugin commands after migration. Candidates come exclusively
// from the historical allow-list, not from the current plugin bundle.
const commandPaths = new Set<string>()
for (const name of extras.commands ?? []) {
commandPaths.add(`${sanitizePathName(name)}.md`)
if (name.includes(":")) {
commandPaths.add(`${commandNameToRelativePath(name)}.md`)
}
}
let moved = 0
if (await isLegacyQwenExtensionInstall(qwenRoot, plugin.manifest.name)) {
moved += await moveIfExists(
managedDir,
"extensions",
path.join(qwenRoot, "extensions"),
plugin.manifest.name,
"Qwen",
)
}
for (const skillName of skillNames) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(qwenRoot, "skills"), skillName, "Qwen")
}
for (const agentName of agentNames) {
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(qwenRoot, "agents"), `${agentName}.yaml`, "Qwen", ".yaml")
moved += await moveLegacyAgentIfOwned(managedDir, "agents", path.join(qwenRoot, "agents"), `${agentName}.md`, "Qwen", ".md")
}
for (const commandPath of commandPaths) {
moved += await moveIfExists(managedDir, "commands", path.join(qwenRoot, "commands"), commandPath, "Qwen")
}
return { target: "qwen", root: qwenRoot, moved }
}
async function isLegacyQwenExtensionInstall(qwenRoot: string, pluginName: string): Promise<boolean> {
const configPath = path.join(qwenRoot, "extensions", pluginName, "qwen-extension.json")
if (!(await pathExists(configPath))) return false
try {
const config = await readJson<Record<string, unknown>>(configPath)
return "_compound_managed_mcp" in config || "_compound_managed_keys" in config
} catch {
return false
}
}
async function cleanupWindsurf(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, windsurfRoot: string): Promise<CleanupResult> {
const artifacts = getLegacyWindsurfArtifacts(plugin)
const managedDir = path.join(windsurfRoot, "compound-engineering")
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveLegacySkillIfOwned(managedDir, "skills", path.join(windsurfRoot, "skills"), skillName, "Windsurf")
}
for (const workflowPath of artifacts.workflows) {
moved += await moveIfExists(managedDir, "global_workflows", path.join(windsurfRoot, "global_workflows"), workflowPath, "Windsurf")
moved += await moveIfExists(managedDir, "workflows", path.join(windsurfRoot, "workflows"), workflowPath, "Windsurf")
}
return { target: "windsurf", root: windsurfRoot, moved }
}
async function moveIfExists(
managedDir: string,
kind: string,
artifactRoot: string,
relativePath: string,
label: string,
): Promise<number> {
// Defense in depth: relativePath comes from either the historical legacy
// allow-list (safe by construction) or an install-manifest entry that
// `readManagedInstallManifest` / `readInstallManifest` already filtered.
// Re-check here so any future caller that skips the read layer cannot
// issue an out-of-tree rename via `moveLegacyArtifactToBackup`.
if (!isSafeManagedPath(artifactRoot, relativePath)) return 0
const artifactPath = path.join(artifactRoot, ...relativePath.split("/"))
if (!(await pathExists(artifactPath))) return 0
await moveLegacyArtifactToBackup(managedDir, kind, artifactRoot, relativePath, label)
return 1
}
async function moveLegacySkillIfOwned(
managedDir: string,
kind: string,
artifactRoot: string,
relativePath: string,
label: string,
): Promise<number> {
if (!isSafeManagedPath(artifactRoot, relativePath)) return 0
const artifactPath = path.join(artifactRoot, ...relativePath.split("/"))
if (!(await pathExists(artifactPath))) return 0
if (!(await isLegacySkillArtifactOwned(artifactPath, path.basename(relativePath)))) return 0
await moveLegacyArtifactToBackup(managedDir, kind, artifactRoot, relativePath, label)
return 1
}
async function moveLegacyAgentIfOwned(
managedDir: string,
kind: string,
artifactRoot: string,
relativePath: string,
label: string,
extension: string | null,
): Promise<number> {
if (!isSafeManagedPath(artifactRoot, relativePath)) return 0
const artifactPath = path.join(artifactRoot, ...relativePath.split("/"))
if (!(await pathExists(artifactPath))) return 0
const legacyName = legacyAgentNameFromPath(relativePath, extension)
if (!(await isLegacyAgentArtifactOwned(artifactPath, legacyName, extension))) return 0
await moveLegacyArtifactToBackup(managedDir, kind, artifactRoot, relativePath, label)
return 1
}
function legacyAgentNameFromPath(relativePath: string, extension: string | null): string {
const baseName = path.basename(relativePath)
if (!extension) return baseName
return baseName.endsWith(extension)
? baseName.slice(0, -extension.length)
: path.basename(baseName, path.extname(baseName))
}
function resolveCleanupTargets(targetArg: string): CleanupTarget[] {
if (targetArg === "all") return [...cleanupTargets]
const targets = targetArg.split(",").map((entry) => entry.trim()).filter(Boolean)
for (const target of targets) {
if (!cleanupTargets.includes(target as CleanupTarget)) {
throw new Error(`Unknown cleanup target: ${target}. Use one of: ${cleanupTargets.join(", ")}, all`)
}
}
return targets as CleanupTarget[]
}
async function resolveCleanupPluginPath(input: string): Promise<string> {
if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) {
const expanded = expandHome(input)
const directPath = path.resolve(expanded)
if (await pathExists(directPath)) return directPath
throw new Error(`Local plugin path not found: ${directPath}`)
}
const repoRoot = fileURLToPath(new URL("../..", import.meta.url))
const rootManifestPath = path.join(repoRoot, ".claude-plugin", "plugin.json")
if (await pathExists(rootManifestPath)) {
try {
const raw = await fs.readFile(rootManifestPath, "utf8")
const manifest = JSON.parse(raw) as { name?: string }
if (manifest.name === input) return repoRoot
} catch {
// Fall through to legacy multi-plugin layout.
}
}
const legacyPluginPath = path.join(repoRoot, "plugins", input)
const legacyManifestPath = path.join(legacyPluginPath, ".claude-plugin", "plugin.json")
if (await pathExists(legacyManifestPath)) return legacyPluginPath
throw new Error(`Unknown bundled plugin: ${input}`)
}
function resolveWorkspaceRoot(value: unknown): string {
if (value && String(value).trim()) {
return path.resolve(expandHome(String(value).trim()))
}
return process.cwd()
}
function resolveCopilotWorkspaceRoot(outputRoot: string): string {
return path.basename(outputRoot) === ".github" ? outputRoot : path.join(outputRoot, ".github")
}
function resolveOpenCodeWorkspaceRoot(outputRoot: string): string {
return path.basename(outputRoot) === ".opencode" ? outputRoot : path.join(outputRoot, ".opencode")
}
function hasExplicitValue(value: unknown): boolean {
return Boolean(value && String(value).trim())
}
async function dedupeRoots(roots: string[]): Promise<string[]> {
const seen = new Set<string>()
const result: string[] = []
for (const root of roots) {
// Resolve symlinks before comparing. Plain string equality is not enough
// on macOS where `$HOME` is typically `/Users/<name>` but `process.cwd()`
// on a directory under `/var/folders` resolves to `/private/var/folders`,
// and similar per-user tmpdir setups produce two strings that point at
// the same inode. Falling back to `path.normalize` on the raw string when
// the directory doesn't yet exist (e.g. the first `install` ever) keeps
// the pre-realpath behavior as a safety net.
const key = await resolveCanonicalPath(root)
if (seen.has(key)) continue
seen.add(key)
result.push(root)
}
return result
}
async function resolveCanonicalPath(target: string): Promise<string> {
const normalized = path.normalize(target)
try {
return await fs.realpath(normalized)
} catch {
// Directory does not exist yet — fall back to the normalized string. This
// is fine because a non-existent path has no filesystem aliases to race
// against.
return normalized
}
}
function resolveDroidWorkspaceRoot(outputRoot: string): string {
return path.basename(outputRoot) === ".factory" ? outputRoot : path.join(outputRoot, ".factory")
}
function resolveWindsurfWorkspaceRoot(outputRoot: string): string {
return path.basename(outputRoot) === ".windsurf" ? outputRoot : path.join(outputRoot, ".windsurf")
}
+229
View File
@@ -0,0 +1,229 @@
import { defineCommand } from "citty"
import os from "os"
import path from "path"
import { loadClaudePlugin } from "../parsers/claude"
import { targets, validateScope } from "../targets"
import type { ClaudeToOpenCodeOptions, PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
import { expandHome, resolveCodexHome, resolveTargetHome } from "../utils/resolve-home"
import { resolveOpenCodeWriteScope, resolveTargetOutputRoot } from "../utils/resolve-output"
import { detectInstalledTools } from "../utils/detect-tools"
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 | pi | antigravity | all)",
},
output: {
type: "string",
alias: "o",
description: "Output directory (project root)",
},
codexHome: {
type: "string",
alias: "codex-home",
description: "Write Codex output to this Codex root (default: $CODEX_HOME or ~/.codex)",
},
piHome: {
type: "string",
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
scope: {
type: "string",
description: "Scope level: global | workspace (default varies by target)",
},
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",
},
includeSkills: {
type: "boolean",
default: false,
alias: "include-skills",
description: "For --to codex only: also emit skills and commands. Default is agents-only, the recommended pairing with `codex plugin install`. Set this flag for a legacy / standalone install without Codex native plugin install. Ignored by other targets.",
},
},
async run({ args }) {
const targetName = String(args.to)
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 hasExplicitOutput = Boolean(args.output && String(args.output).trim())
const codexHome = resolveCodexHome(args.codexHome)
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
const options: ClaudeToOpenCodeOptions = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
inferTemperature: Boolean(args.inferTemperature),
permissions: permissions as PermissionMode,
codexIncludeSkills: Boolean(args.includeSkills),
}
if (targetName === "all") {
const detected = await detectInstalledTools()
const activeTargets = detected.filter((t) => t.detected && targets[t.name]?.implemented)
if (activeTargets.length === 0) {
console.log("No installable AI coding tools detected. Use native plugin install for Claude Code, Copilot, Droid, OpenCode, Pi, and Qwen.")
return
}
console.log(`Detected ${activeTargets.length} installable tool(s):`)
for (const tool of detected) {
if (tool.detected && !targets[tool.name]?.implemented) {
console.log(` - ${tool.name} — native plugin install; skipped`)
continue
}
console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name}${tool.reason}`)
}
for (const tool of activeTargets) {
const handler = targets[tool.name]
if (!handler || !handler.implemented) {
console.warn(`Skipping ${tool.name}: not implemented.`)
continue
}
const bundle = handler.convert(plugin, options)
if (!bundle) {
console.warn(`Skipping ${tool.name}: no output returned.`)
continue
}
const root = resolveTargetOutputRoot({
targetName: tool.name,
outputRoot,
codexHome,
piHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
})
const writeScope =
tool.name === "opencode" ? resolveOpenCodeWriteScope(hasExplicitOutput, undefined) : undefined
await handler.write(root, bundle, writeScope)
console.log(`Converted ${plugin.manifest.name} to ${tool.name} at ${root}`)
}
if (activeTargets.some((t) => t.name === "codex")) {
await ensureCodexAgentsFile(codexHome)
}
return
}
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 resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
const primaryOutputRoot = resolveTargetOutputRoot({
targetName,
outputRoot,
codexHome,
piHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: resolvedScope,
})
const bundle = target.convert(plugin, options)
if (!bundle) {
throw new Error(`Target ${targetName} did not return a bundle.`)
}
const effectiveScope =
targetName === "opencode" ? resolveOpenCodeWriteScope(hasExplicitOutput, resolvedScope) : resolvedScope
await target.write(primaryOutputRoot, bundle, effectiveScope)
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 = resolveTargetOutputRoot({
targetName: extra,
outputRoot,
codexHome,
piHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: handler.defaultScope,
})
const extraScope =
extra === "opencode"
? resolveOpenCodeWriteScope(hasExplicitOutput, handler.defaultScope)
: handler.defaultScope
await handler.write(extraRoot, extraBundle, extraScope)
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 resolveOutputRoot(value: unknown): string {
if (value && String(value).trim()) {
const expanded = expandHome(String(value).trim())
return path.resolve(expanded)
}
return process.cwd()
}
+343
View File
@@ -0,0 +1,343 @@
import { defineCommand } from "citty"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { loadClaudePlugin } from "../parsers/claude"
import { targets, validateScope } from "../targets"
import { pathExists } from "../utils/files"
import type { ClaudeToOpenCodeOptions, PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
import { expandHome, resolveCodexHome, resolveTargetHome } from "../utils/resolve-home"
import { resolveOpenCodeWriteScope, resolveTargetOutputRoot } from "../utils/resolve-output"
import { detectInstalledTools } from "../utils/detect-tools"
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 | pi | antigravity | all)",
},
output: {
type: "string",
alias: "o",
description: "Output directory (project root)",
},
codexHome: {
type: "string",
alias: "codex-home",
description: "Write Codex output to this Codex root (default: $CODEX_HOME or ~/.codex)",
},
piHome: {
type: "string",
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
scope: {
type: "string",
description: "Scope level: global | workspace (default varies by target)",
},
also: {
type: "string",
description: "Comma-separated extra targets to generate (ex: codex)",
},
permissions: {
type: "string",
default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003.
description: "Permission mapping written to opencode.json: none (default) | broad | from-command",
},
agentMode: {
type: "string",
default: "subagent",
description: "Default agent mode: primary | subagent",
},
inferTemperature: {
type: "boolean",
default: true,
description: "Infer agent temperature from name/description",
},
includeSkills: {
type: "boolean",
default: false,
alias: "include-skills",
description: "For --to codex only: also emit skills and commands. Default is agents-only, the recommended pairing with `codex plugin install`. Set this flag for a legacy / standalone install without Codex native plugin install. Ignored by other targets.",
},
branch: {
type: "string",
description: "Git branch to clone from (e.g. feat/new-agents)",
},
},
async run({ args }) {
const targetName = String(args.to)
const permissions = String(args.permissions)
if (!permissionModes.includes(permissions as PermissionMode)) {
throw new Error(`Unknown permissions mode: ${permissions}`)
}
const branch = args.branch ? String(args.branch) : undefined
const resolvedPlugin = await resolvePluginPath(String(args.plugin), branch)
try {
const plugin = await loadClaudePlugin(resolvedPlugin.path)
const outputRoot = resolveOutputRoot(args.output)
const codexHome = resolveCodexHome(args.codexHome)
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
const options: ClaudeToOpenCodeOptions = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
inferTemperature: Boolean(args.inferTemperature),
permissions: permissions as PermissionMode,
codexIncludeSkills: Boolean(args.includeSkills),
}
if (targetName === "all") {
const detected = await detectInstalledTools()
const activeTargets = detected.filter((t) => t.detected && targets[t.name]?.implemented)
if (activeTargets.length === 0) {
console.log("No installable AI coding tools detected. Use native plugin install for Claude Code, Copilot, Droid, OpenCode, Pi, and Qwen.")
return
}
console.log(`Detected ${activeTargets.length} installable tool(s):`)
for (const tool of detected) {
if (tool.detected && !targets[tool.name]?.implemented) {
console.log(` - ${tool.name} — native plugin install; skipped`)
continue
}
console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name}${tool.reason}`)
}
for (const tool of activeTargets) {
const handler = targets[tool.name]
if (!handler || !handler.implemented) {
console.warn(`Skipping ${tool.name}: not implemented.`)
continue
}
const bundle = handler.convert(plugin, options)
if (!bundle) {
console.warn(`Skipping ${tool.name}: no output returned.`)
continue
}
const root = resolveTargetOutputRoot({
targetName: tool.name,
outputRoot,
codexHome,
piHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
})
const writeScope =
tool.name === "opencode" ? resolveOpenCodeWriteScope(hasExplicitOutput, undefined) : undefined
await handler.write(root, bundle, writeScope)
console.log(`Installed ${plugin.manifest.name} to ${tool.name} at ${root}`)
}
if (activeTargets.some((t) => t.name === "codex")) {
await ensureCodexAgentsFile(codexHome)
}
return
}
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 resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
const bundle = target.convert(plugin, options)
if (!bundle) {
throw new Error(`Target ${targetName} did not return a bundle.`)
}
const primaryOutputRoot = resolveTargetOutputRoot({
targetName,
outputRoot,
codexHome,
piHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: resolvedScope,
})
const effectiveScope =
targetName === "opencode" ? resolveOpenCodeWriteScope(hasExplicitOutput, resolvedScope) : resolvedScope
await target.write(primaryOutputRoot, bundle, effectiveScope)
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 = resolveTargetOutputRoot({
targetName: extra,
outputRoot,
codexHome,
piHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: handler.defaultScope,
})
const extraScope =
extra === "opencode"
? resolveOpenCodeWriteScope(hasExplicitOutput, handler.defaultScope)
: handler.defaultScope
await handler.write(extraRoot, extraBundle, extraScope)
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, branch?: string): Promise<ResolvedPluginPath> {
// Only treat as a local path if it explicitly looks like one
if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) {
const expanded = expandHome(input)
const directPath = path.resolve(expanded)
if (await pathExists(directPath)) return { path: directPath }
throw new Error(`Local plugin path not found: ${directPath}`)
}
// Skip bundled plugins when a branch is specified — the user wants a specific remote version
if (!branch) {
const bundledPluginPath = await resolveBundledPluginPath(input)
if (bundledPluginPath) {
return { path: bundledPluginPath }
}
}
// Otherwise, fetch from GitHub (optionally from a specific branch)
return await resolveGitHubPluginPath(input, branch)
}
function parseExtraTargets(value: unknown): string[] {
if (!value) return []
return String(value)
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
}
function resolveOutputRoot(value: unknown): string {
if (value && String(value).trim()) {
const expanded = expandHome(String(value).trim())
return path.resolve(expanded)
}
// Per-target defaults are applied in `resolveTargetOutputRoot` -- e.g.,
// OpenCode falls back to `OPENCODE_CONFIG_DIR` / `~/.config/opencode`,
// Codex falls back to `~/.codex`. Falling through to `process.cwd()` keeps
// workspace-rooted targets (antigravity) using the user's project root
// when neither `--output` nor a target-specific home flag was supplied.
return process.cwd()
}
async function resolveBundledPluginPath(pluginName: string): Promise<string | null> {
const repoRoot = fileURLToPath(new URL("../..", import.meta.url))
return await resolvePluginRoot(repoRoot, pluginName)
}
async function resolveGitHubPluginPath(pluginName: string, branch?: string): Promise<ResolvedPluginPath> {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-"))
const source = resolveGitHubSource()
try {
await cloneGitHubRepo(source, tempRoot, branch)
} catch (error) {
await fs.rm(tempRoot, { recursive: true, force: true })
throw error
}
const pluginPath = await resolvePluginRoot(tempRoot, pluginName)
if (!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 })
},
}
}
async function resolvePluginRoot(repoRoot: string, pluginName: string): Promise<string | null> {
const rootManifest = path.join(repoRoot, ".claude-plugin", "plugin.json")
if (await pathExists(rootManifest)) {
try {
const raw = await fs.readFile(rootManifest, "utf8")
const manifest = JSON.parse(raw) as { name?: string }
if (manifest.name === pluginName) return repoRoot
} catch {
// Fall through to the legacy multi-plugin layout.
}
}
const legacyPluginPath = path.join(repoRoot, "plugins", pluginName)
const legacyManifest = path.join(legacyPluginPath, ".claude-plugin", "plugin.json")
if (await pathExists(legacyManifest)) return legacyPluginPath
return null
}
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, branch?: string): Promise<void> {
const args = ["git", "clone", "--depth", "1"]
if (branch) args.push("--branch", branch)
args.push(source, destination)
const proc = Bun.spawn(args, {
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()}`)
}
}
+41
View File
@@ -0,0 +1,41 @@
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 in this repository",
},
async run() {
const root = process.cwd()
const plugins: string[] = []
const rootManifestPath = path.join(root, ".claude-plugin", "plugin.json")
if (await pathExists(rootManifestPath)) {
const manifest = JSON.parse(await fs.readFile(rootManifestPath, "utf8")) as { name?: string }
plugins.push(manifest.name ?? path.basename(root))
}
const pluginsDir = path.join(root, "plugins")
if (await pathExists(pluginsDir)) {
const entries = await fs.readdir(pluginsDir, { withFileTypes: true })
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.")
return
}
const uniquePlugins = [...new Set(plugins)]
console.log(uniquePlugins.sort().join("\n"))
},
})
+122
View File
@@ -0,0 +1,122 @@
import { defineCommand } from "citty"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
export default defineCommand({
meta: {
name: "plugin-path",
description: "Checkout a plugin branch to a stable local path for use with claude --plugin-dir",
},
args: {
plugin: {
type: "positional",
required: true,
description: "Plugin name (e.g. compound-engineering)",
},
branch: {
type: "string",
required: true,
description: "Branch name (local or remote, e.g. feat/new-agents)",
},
},
async run({ args }) {
const pluginName = String(args.plugin)
const branch = String(args.branch)
// Reversible encoding: / -> ~ (safe because ~ is illegal in git branch names per
// git-check-ref-format), then percent-encode any remaining unsafe characters.
// This is injective — every distinct branch name maps to a distinct cache key.
const sanitized = branch
.replace(/\//g, "~")
.replace(/[^a-zA-Z0-9._~-]/g, (ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, "0")}`)
const dirName = `${pluginName}-${sanitized}`
const cacheRoot = path.join(os.homedir(), ".cache", "compound-engineering", "branches")
await fs.mkdir(cacheRoot, { recursive: true })
const targetDir = path.join(cacheRoot, dirName)
const source = resolveGitHubSource()
if (await dirExists(targetDir)) {
console.error(`Updating existing checkout at ${targetDir}`)
await fetchAndCheckout(targetDir, branch)
} else {
console.error(`Cloning ${branch} to ${targetDir}`)
await cloneBranch(source, targetDir, branch)
}
const pluginPath = await resolvePluginRoot(targetDir, pluginName)
if (!(await dirExists(pluginPath))) {
throw new Error(`Plugin directory not found: ${pluginPath}`)
}
// Plugin path goes to stdout (for scripting); usage hint goes to stderr
console.error(`\nReady. Use with:\n claude --plugin-dir ${pluginPath}\n`)
console.log(pluginPath)
},
})
async function dirExists(p: string): Promise<boolean> {
try {
const stat = await fs.stat(p)
return stat.isDirectory()
} catch {
return false
}
}
async function resolvePluginRoot(repoDir: string, pluginName: string): Promise<string> {
const rootManifest = path.join(repoDir, ".claude-plugin", "plugin.json")
if (await dirExists(path.dirname(rootManifest))) {
try {
const raw = await fs.readFile(rootManifest, "utf8")
const manifest = JSON.parse(raw) as { name?: string }
if (manifest.name === pluginName) return repoDir
} catch {
// Fall through to the legacy multi-plugin layout.
}
}
return path.join(repoDir, "plugins", pluginName)
}
async function cloneBranch(source: string, destination: string, branch: string): Promise<void> {
const proc = Bun.spawn(["git", "clone", "--depth", "1", "--branch", branch, 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 branch '${branch}' from ${source}. ${stderr.trim()}`)
}
}
async function fetchAndCheckout(repoDir: string, branch: string): Promise<void> {
const fetch = Bun.spawn(["git", "fetch", "origin", branch], {
cwd: repoDir,
stdout: "pipe",
stderr: "pipe",
})
const fetchExit = await fetch.exited
const fetchErr = await new Response(fetch.stderr).text()
if (fetchExit !== 0) {
throw new Error(`Failed to fetch branch '${branch}'. ${fetchErr.trim()}`)
}
const reset = Bun.spawn(["git", "reset", "--hard", `origin/${branch}`], {
cwd: repoDir,
stdout: "pipe",
stderr: "pipe",
})
const resetExit = await reset.exited
const resetErr = await new Response(reset.stderr).text()
if (resetExit !== 0) {
throw new Error(`Failed to reset to origin/${branch}. ${resetErr.trim()}`)
}
}
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"
}
+205
View File
@@ -0,0 +1,205 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type { AntigravityAgent, AntigravityBundle, AntigravityCommand, AntigravityMcpServer } from "../types/antigravity"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToAntigravityOptions = ClaudeToOpenCodeOptions
const ANTIGRAVITY_DESCRIPTION_MAX_LENGTH = 1024
export function convertClaudeToAntigravity(
plugin: ClaudePlugin,
_options: ClaudeToAntigravityOptions,
): AntigravityBundle {
const usedCommandNames = new Set<string>()
const platformSkills = filterSkillsByPlatform(plugin.skills, "antigravity")
const skillDirs = platformSkills.map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
}))
const usedAgentNames = new Set<string>()
const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
const mcpServers = convertMcpServers(plugin.mcpServers)
// agy accepts a hooks.json shaped { hooks: { ... } }. The per-event matcher /
// command schema and supported event names are not yet verified against agy,
// so pass the Claude hook map through structurally (container only) rather
// than reshaping it into an unverified per-event format.
const hooks = plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0
? plugin.hooks.hooks as Record<string, unknown>
: undefined
return {
pluginName: plugin.manifest.name,
version: plugin.manifest.version,
generatedSkills: [],
skillDirs,
agents,
commands,
mcpServers,
hooks,
}
}
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): AntigravityAgent {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = sanitizeDescription(
agent.description ?? `Use this agent for ${agent.name} tasks`,
)
const frontmatter: Record<string, unknown> = { name, description, kind: "local" }
let body = transformContentForAntigravity(agent.body.trim())
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((c) => `- ${c}`).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 convertCommand(command: ClaudeCommand, usedNames: Set<string>): AntigravityCommand {
// Preserve namespace structure: workflows:plan -> workflows/plan
const commandPath = resolveCommandPath(command.name)
const pathKey = commandPath.join("/")
uniqueName(pathKey, usedNames) // Track for dedup
const description = command.description ?? `Converted from Claude command ${command.name}`
const transformedBody = transformContentForAntigravity(command.body.trim())
let prompt = transformedBody
if (command.argumentHint) {
prompt += `\n\nUser request: {{args}}`
}
const content = toToml(description, prompt)
return { name: pathKey, content }
}
/**
* Transform Claude Code content to Antigravity-compatible content.
*
* 1. Task agent calls: Task agent-name(args) -> Use the @agent-name subagent to: args
* 2. Agent references: @agent-name -> @agent-name subagent
*
* Note: unlike the former Gemini converter, this does NOT rewrite `.claude/`
* paths. The agy path conventions for such a rewrite are not yet verified, so
* emitting one would risk producing incorrect paths (KTD7 in the plan).
*/
export function transformContentForAntigravity(body: string): string {
let result = body
// 1. Transform Task agent calls (supports namespaced names like compound-engineering:research:agent-name)
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
const normalizedAgentName = normalizeName(finalSegment)
const trimmedArgs = args.trim()
return trimmedArgs
? `${prefix}Use the @${normalizedAgentName} subagent to: ${trimmedArgs}`
: `${prefix}Use the @${normalizedAgentName} subagent`
})
// 2. Transform @agent-name references
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))(?!\s+subagent\b)/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
return `@${normalizeName(agentName)} subagent`
})
return result
}
function convertMcpServers(
servers?: Record<string, ClaudeMcpServer>,
): Record<string, AntigravityMcpServer> | undefined {
if (!servers || Object.keys(servers).length === 0) return undefined
const result: Record<string, AntigravityMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
const entry: AntigravityMcpServer = {}
if (server.command) {
entry.command = server.command
if (server.args && server.args.length > 0) entry.args = server.args
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
} else if (server.url) {
// Antigravity uses `serverUrl` for remote servers, not `url`.
entry.serverUrl = server.url
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
}
result[name] = entry
}
return result
}
/**
* Resolve command name to path segments.
* workflows:plan -> ["workflows", "plan"]
* plan -> ["plan"]
*/
function resolveCommandPath(name: string): string[] {
return name.split(":").map((segment) => normalizeName(segment))
}
/**
* Serialize to TOML command format.
* Uses multi-line strings (""") for prompt field.
*/
export function toToml(description: string, prompt: string): string {
const lines: string[] = []
lines.push(`description = ${formatTomlString(description)}`)
// Multi-line basic string avoids escaping embedded newlines in prompt text
const escapedPrompt = prompt.replace(/\\/g, "\\\\").replace(/"""/g, '\\"\\"\\"')
lines.push(`prompt = """`)
lines.push(escapedPrompt)
lines.push(`"""`)
return lines.join("\n")
}
function formatTomlString(value: string): string {
return JSON.stringify(value)
}
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 = ANTIGRAVITY_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
}
+285
View File
@@ -0,0 +1,285 @@
import fs, { type Dirent } from "fs"
import path from "path"
import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type { CodexAgent, CodexBundle, CodexGeneratedSkill, CodexGeneratedSkillSidecarDir } from "../types/codex"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
import {
normalizeCodexName,
transformContentForCodex,
type CodexInvocationTargets,
} from "../utils/codex-content"
export type ClaudeToCodexOptions = ClaudeToOpenCodeOptions
const CODEX_DESCRIPTION_MAX_LENGTH = 1024
export function convertClaudeToCodex(
plugin: ClaudePlugin,
options: ClaudeToCodexOptions,
): CodexBundle {
// Agents-only is the default for --to codex. Skills and commands are
// expected to install via Codex's native plugin flow (`codex plugin install`)
// which reads the plugin's .codex-plugin/plugin.json manifest. The Bun
// converter fills the one gap Codex's native spec leaves open: custom
// agents. Emitting skills too would double-register them — once from native
// install, once from this converter.
const includeSkills = options.codexIncludeSkills ?? false
const platformSkills = filterSkillsByPlatform(plugin.skills, "codex")
const invocableCommands = plugin.commands.filter((command) => !command.disableModelInvocation)
const applyCompoundWorkflowModel = shouldApplyCompoundWorkflowModel(plugin)
const deprecatedWorkflowAliases = applyCompoundWorkflowModel
? platformSkills.filter((skill) => isDeprecatedCodexWorkflowAlias(skill.name))
: []
const copiedSkills = applyCompoundWorkflowModel
? platformSkills.filter((skill) => !isDeprecatedCodexWorkflowAlias(skill.name))
: platformSkills
const skillDirs = copiedSkills.map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
}))
const promptNames = new Set<string>()
const usedSkillNames = new Set<string>(skillDirs.map((skill) => normalizeCodexName(skill.name)))
const commandPromptNames = new Map<string, string>()
for (const command of invocableCommands) {
commandPromptNames.set(
command.name,
uniqueName(normalizeCodexName(command.name), promptNames),
)
}
const promptTargets: Record<string, string> = {}
for (const [commandName, promptName] of commandPromptNames) {
promptTargets[normalizeCodexName(commandName)] = promptName
}
const skillTargets: Record<string, string> = {}
for (const skill of copiedSkills) {
skillTargets[normalizeCodexName(skill.name)] = skill.name
}
for (const alias of deprecatedWorkflowAliases) {
const canonicalName = toCanonicalWorkflowSkillName(alias.name)
if (canonicalName) {
skillTargets[normalizeCodexName(alias.name)] = canonicalName
}
}
// Agents are always converted to TOML custom agents regardless of mode —
// that's the whole point of --to codex. invocationTargets is populated from
// the full plugin so agent bodies can reference skills correctly; native
// install makes those skills discoverable at runtime.
const agents = plugin.agents.map(convertAgent)
const agentTargets = buildAgentTargets(plugin, agents)
const invocationTargets: CodexInvocationTargets = { promptTargets, skillTargets, agentTargets }
if (!includeSkills) {
// Default: agents-only. Skills, prompts, command-skills, and MCP are
// suppressed so native plugin install is the sole source for those
// artifact types.
//
// Pass through current skill NAMES (not contents) so `writeCodexBundle`
// treats them as "current" and `cleanupLegacyAgentSkillDirs` doesn't
// move still-active skills under `.codex/skills/<plugin>/<name>/` into
// legacy-backup. Without this, re-running `install --to codex` after a
// native plugin install would sweep allow-listed names like `ce-plan`
// into backup because `currentSkills` (derived from skillDirs and
// generatedSkills) would be empty while the legacy allow-list still
// lists them.
// Mirror the skill-name set that full mode would emit via `skillDirs`:
// current skills plus the canonical rewrites of deprecated workflow
// aliases. Deduped via Set so the caller doesn't have to worry about
// overlap between `copiedSkills` names and `skillTargets` values.
const externallyManagedSkillNames = Array.from(new Set([
...copiedSkills.map((skill) => skill.name),
...deprecatedWorkflowAliases
.map((alias) => toCanonicalWorkflowSkillName(alias.name))
.filter((name): name is string => name !== null),
]))
return {
pluginName: plugin.manifest.name,
prompts: [],
skillDirs: [],
generatedSkills: [],
agents,
invocationTargets,
mcpServers: undefined,
hooks: plugin.hooks,
externallyManagedSkillNames,
}
}
// Full / legacy / standalone mode: everything goes through the converter.
const commandSkills: CodexGeneratedSkill[] = []
const prompts = invocableCommands.map((command) => {
const promptName = commandPromptNames.get(command.name)!
const commandSkill = convertCommandSkill(command, usedSkillNames, invocationTargets)
commandSkills.push(commandSkill)
const content = renderPrompt(command, commandSkill.name, invocationTargets)
return { name: promptName, content }
})
return {
pluginName: plugin.manifest.name,
prompts,
skillDirs,
generatedSkills: [...commandSkills],
agents,
invocationTargets,
mcpServers: plugin.mcpServers,
hooks: plugin.hooks,
}
}
function convertAgent(agent: ClaudeAgent): CodexAgent {
const name = buildCodexAgentName(agent)
const description = sanitizeDescription(
agent.description ?? `Converted from Claude agent ${agent.name}`,
)
let instructions = agent.body.trim()
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((capability) => `- ${capability}`).join("\n")
instructions = `## Capabilities\n${capabilities}\n\n${instructions}`.trim()
}
if (instructions.length === 0) {
instructions = `Instructions converted from the ${agent.name} agent.`
}
return { name, description, instructions, sidecarDirs: collectReferencedSidecarDirs(agent) }
}
function convertCommandSkill(
command: ClaudeCommand,
usedNames: Set<string>,
invocationTargets: CodexInvocationTargets,
): CodexGeneratedSkill {
const name = uniqueName(normalizeCodexName(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")}`)
}
const transformedBody = transformContentForCodex(command.body.trim(), invocationTargets)
sections.push(transformedBody)
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,
invocationTargets: CodexInvocationTargets,
): 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 transformedBody = transformContentForCodex(command.body, invocationTargets)
const body = [instructions, "", transformedBody].join("\n").trim()
return formatFrontmatter(frontmatter, body)
}
function isDeprecatedCodexWorkflowAlias(name: string): boolean {
return name.startsWith("workflows:")
}
const WORKFLOW_ALIAS_OVERRIDES: Record<string, string> = {
"workflows:review": "ce-code-review",
}
function toCanonicalWorkflowSkillName(name: string): string | null {
if (!isDeprecatedCodexWorkflowAlias(name)) return null
return WORKFLOW_ALIAS_OVERRIDES[name] ?? `ce-${name.slice("workflows:".length)}`
}
function shouldApplyCompoundWorkflowModel(plugin: ClaudePlugin): boolean {
return plugin.manifest.name === "compound-engineering"
}
function buildAgentTargets(plugin: ClaudePlugin, agents: CodexAgent[]): Record<string, string> {
const targets: Record<string, string> = {}
plugin.agents.forEach((agent, index) => {
const targetName = agents[index]?.name
if (!targetName) return
const category = getAgentCategory(agent)
const aliases = [
agent.name,
normalizeCodexName(agent.name),
agent.name.startsWith("ce-") ? agent.name.slice("ce-".length) : "",
category ? `${category}:${agent.name}` : "",
category && agent.name.startsWith("ce-") ? `${category}:${agent.name.slice("ce-".length)}` : "",
category ? `${plugin.manifest.name}:${category}:${agent.name}` : "",
category && agent.name.startsWith("ce-") ? `${plugin.manifest.name}:${category}:${agent.name.slice("ce-".length)}` : "",
].filter(Boolean)
for (const alias of aliases) {
targets[normalizeCodexName(alias)] = targetName
}
})
return targets
}
function buildCodexAgentName(agent: ClaudeAgent): string {
const category = getAgentCategory(agent)
const agentName = normalizeCodexName(agent.name)
return category ? `${normalizeCodexName(category)}-${agentName}` : agentName
}
function getAgentCategory(agent: ClaudeAgent): string | null {
const parts = agent.sourcePath.split(path.sep)
const agentsIndex = parts.lastIndexOf("agents")
if (agentsIndex === -1) return null
const next = parts[agentsIndex + 1]
if (!next || next.endsWith(".md")) return null
return next
}
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
}
function collectReferencedSidecarDirs(agent: ClaudeAgent): CodexGeneratedSkillSidecarDir[] {
const sourceDir = path.dirname(agent.sourcePath)
let entries: Dirent[]
try {
entries = fs.readdirSync(sourceDir, { withFileTypes: true })
} catch {
return []
}
return entries
.filter((entry) => entry.isDirectory())
.filter((entry) => agent.body.includes(`${entry.name}/`) || agent.body.includes(`\`${entry.name}\``))
.map((entry) => ({
sourceDir: path.join(sourceDir, entry.name),
targetName: entry.name,
}))
}
+218
View File
@@ -0,0 +1,218 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { sanitizePathName } from "../utils/files"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type {
CopilotAgent,
CopilotBundle,
CopilotGeneratedSkill,
CopilotMcpServer,
} from "../types/copilot"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions
const COPILOT_BODY_CHAR_LIMIT = 30_000
export function convertClaudeToCopilot(
plugin: ClaudePlugin,
_options: ClaudeToCopilotOptions,
): CopilotBundle {
const usedAgentNames = new Set<string>()
const usedSkillNames = new Set<string>()
const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
// Reserve sanitized skill names so generated skills (from commands) don't collide on disk
const skillDirs = filterSkillsByPlatform(plugin.skills, "copilot").map((skill) => {
usedSkillNames.add(sanitizePathName(skill.name))
return {
name: skill.name,
sourceDir: skill.sourceDir,
}
})
const generatedSkills = plugin.commands.map((command) =>
convertCommandToSkill(command, usedSkillNames),
)
const mcpConfig = convertMcpServers(plugin.mcpServers)
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.")
}
return { pluginName: plugin.manifest.name, agents, generatedSkills, skillDirs, mcpConfig }
}
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): CopilotAgent {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = agent.description ?? `Converted from Claude agent ${agent.name}`
const frontmatter: Record<string, unknown> = {
description,
"user-invocable": true,
}
let body = transformContentForCopilot(agent.body.trim())
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
}
if (body.length > COPILOT_BODY_CHAR_LIMIT) {
console.warn(
`Warning: Agent "${agent.name}" body exceeds ${COPILOT_BODY_CHAR_LIMIT} characters (${body.length}). Copilot may truncate it.`,
)
}
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}
function convertCommandToSkill(
command: ClaudeCommand,
usedNames: Set<string>,
): CopilotGeneratedSkill {
const name = uniqueName(flattenCommandName(command.name), usedNames)
const frontmatter: Record<string, unknown> = {
name,
}
if (command.description) {
frontmatter.description = command.description
}
const sections: string[] = []
if (command.argumentHint) {
sections.push(`## Arguments\n${command.argumentHint}`)
}
const transformedBody = transformContentForCopilot(command.body.trim())
sections.push(transformedBody)
const body = sections.filter(Boolean).join("\n\n").trim()
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}
export function transformContentForCopilot(body: string): string {
let result = body
// 1. Transform Task agent calls (supports namespaced names like compound-engineering:research:agent-name)
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
const skillName = normalizeName(finalSegment)
const trimmedArgs = args.trim()
return trimmedArgs
? `${prefix}Use the ${skillName} skill to: ${trimmedArgs}`
: `${prefix}Use the ${skillName} skill`
})
// 2. Transform slash command references (replace colons with hyphens)
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
if (commandName.includes("/")) return match
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
const normalized = flattenCommandName(commandName)
return `/${normalized}`
})
// 3. Replace plugin colon-namespaced command references (e.g. ce:plan → ce-plan, ce:* → ce-*)
// Scoped to `ce:` prefix which is the compound-engineering plugin namespace.
// The lookbehind ensures we only match at word boundaries or after common delimiters,
// avoiding corruption of URLs, code identifiers, or unrelated namespace:value patterns.
// Note: / is intentionally excluded — slash commands are already handled in step 2.
// Captures colons in the name segment so multi-colon refs like ce:foo:bar → ce-foo-bar.
result = result.replace(/(?<=^|[\s,.()`'"])ce:([a-z*][a-z0-9_*:-]*)/gim, (_, name: string) => `ce-${name.replace(/:/g, "-")}`)
// 4. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/
result = result
.replace(/~\/\.claude\//g, "~/.copilot/")
.replace(/\.claude\//g, ".github/")
// 5. Transform @agent-name references
const agentRefPattern =
/@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
return `the ${normalizeName(agentName)} agent`
})
return result
}
function convertMcpServers(
servers?: Record<string, ClaudeMcpServer>,
): Record<string, CopilotMcpServer> | undefined {
if (!servers || Object.keys(servers).length === 0) return undefined
const result: Record<string, CopilotMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
const entry: CopilotMcpServer = {
type: server.command ? "local" : "sse",
tools: ["*"],
}
if (server.command) {
entry.command = server.command
if (server.args && server.args.length > 0) entry.args = server.args
} else if (server.url) {
entry.url = server.url
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
}
if (server.env && Object.keys(server.env).length > 0) {
entry.env = prefixEnvVars(server.env)
}
result[name] = entry
}
return result
}
function prefixEnvVars(env: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (key.startsWith("COPILOT_MCP_")) {
result[key] = value
} else {
result[`COPILOT_MCP_${key}`] = value
}
}
return result
}
function flattenCommandName(name: string): string {
return normalizeName(name)
}
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 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
}
+181
View File
@@ -0,0 +1,181 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type { DroidBundle, DroidCommandFile, DroidAgentFile } from "../types/droid"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToDroidOptions = ClaudeToOpenCodeOptions
const CLAUDE_TO_DROID_TOOLS: Record<string, string> = {
read: "Read",
write: "Create",
edit: "Edit",
multiedit: "Edit",
bash: "Execute",
grep: "Grep",
glob: "Glob",
list: "LS",
ls: "LS",
webfetch: "FetchUrl",
websearch: "WebSearch",
task: "Task",
todowrite: "TodoWrite",
todoread: "TodoWrite",
question: "AskUser",
}
const VALID_DROID_TOOLS = new Set([
"Read",
"LS",
"Grep",
"Glob",
"Create",
"Edit",
"ApplyPatch",
"Execute",
"WebSearch",
"FetchUrl",
"TodoWrite",
"Task",
"AskUser",
])
export function convertClaudeToDroid(
plugin: ClaudePlugin,
_options: ClaudeToDroidOptions,
): DroidBundle {
const commands = plugin.commands.map((command) => convertCommand(command))
const droids = plugin.agents.map((agent) => convertAgent(agent))
const skillDirs = filterSkillsByPlatform(plugin.skills, "droid").map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
}))
return { pluginName: plugin.manifest.name, commands, droids, skillDirs }
}
function convertCommand(command: ClaudeCommand): DroidCommandFile {
const name = flattenCommandName(command.name)
const frontmatter: Record<string, unknown> = {
description: command.description,
}
if (command.argumentHint) {
frontmatter["argument-hint"] = command.argumentHint
}
if (command.disableModelInvocation) {
frontmatter["disable-model-invocation"] = true
}
const body = transformContentForDroid(command.body.trim())
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}
function convertAgent(agent: ClaudeAgent): DroidAgentFile {
const name = normalizeName(agent.name)
const frontmatter: Record<string, unknown> = {
name,
description: agent.description,
}
if (agent.model && agent.model !== "inherit") {
frontmatter.model = agent.model
}
const tools = mapAgentTools(agent)
if (tools) {
frontmatter.tools = tools
}
let body = agent.body.trim()
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
}
body = transformContentForDroid(body)
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}
function mapAgentTools(agent: ClaudeAgent): string[] | undefined {
const bodyLower = `${agent.name} ${agent.description ?? ""} ${agent.body}`.toLowerCase()
const mentionedTools = new Set<string>()
for (const [claudeTool, droidTool] of Object.entries(CLAUDE_TO_DROID_TOOLS)) {
if (bodyLower.includes(claudeTool)) {
mentionedTools.add(droidTool)
}
}
if (mentionedTools.size === 0) return undefined
return [...mentionedTools].filter((t) => VALID_DROID_TOOLS.has(t)).sort()
}
/**
* Transform Claude Code content to Factory Droid-compatible content.
*
* 1. Slash commands: /workflows:plan → /plan, /command-name stays as-is
* 2. Task agent calls: Task agent-name(args) → Task agent-name: args
* 3. Agent references: @agent-name → the agent-name droid
*/
export function transformContentForDroid(body: string): string {
let result = body
// 1. Transform Task agent calls
// Match: Task repo-research-analyst(args) or Task compound-engineering:research:repo-research-analyst(args)
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
const name = normalizeName(finalSegment)
const trimmedArgs = args.trim()
return trimmedArgs
? `${prefix}Task ${name}: ${trimmedArgs}`
: `${prefix}Task ${name}`
})
// 2. Transform slash command references
// /workflows:plan → /plan, /command-name stays as-is
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
if (commandName.includes('/')) return match
if (['dev', 'tmp', 'etc', 'usr', 'var', 'bin', 'home'].includes(commandName)) return match
const flattened = flattenCommandName(commandName)
return `/${flattened}`
})
// 3. Transform @agent-name references to droid references
const agentRefPattern = /@agent-([a-z][a-z0-9-]*)/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
return `the ${normalizeName(agentName)} droid`
})
return result
}
/**
* Flatten a command name by stripping the namespace prefix.
* "workflows:plan" → "plan"
* "plan_review" → "plan_review"
*/
function flattenCommandName(name: string): string {
const colonIndex = name.lastIndexOf(":")
const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name
return normalizeName(base)
}
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"
}
+278
View File
@@ -0,0 +1,278 @@
import { readFileSync, existsSync } from "fs"
import path from "path"
import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type {
KiroAgent,
KiroAgentConfig,
KiroBundle,
KiroMcpServer,
KiroSkill,
KiroSteeringFile,
} from "../types/kiro"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToKiroOptions = ClaudeToOpenCodeOptions
const KIRO_SKILL_NAME_MAX_LENGTH = 64
const KIRO_SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
const KIRO_DESCRIPTION_MAX_LENGTH = 1024
const CLAUDE_TO_KIRO_TOOLS: Record<string, string> = {
Bash: "shell",
Write: "write",
Read: "read",
Edit: "write", // NOTE: Kiro write is full-file, not surgical edit. Lossy mapping.
Glob: "glob",
Grep: "grep",
WebFetch: "web_fetch",
Task: "use_subagent",
}
export function convertClaudeToKiro(
plugin: ClaudePlugin,
_options: ClaudeToKiroOptions,
): KiroBundle {
const usedSkillNames = new Set<string>()
// Pass-through skills are processed first — they're the source of truth
const skillDirs = filterSkillsByPlatform(plugin.skills, "kiro").map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
}))
for (const skill of skillDirs) {
usedSkillNames.add(normalizeName(skill.name))
}
// Convert agents to Kiro custom agents
const agentNames = plugin.agents.map((a) => normalizeName(a.name))
const agents = plugin.agents.map((agent) => convertAgentToKiroAgent(agent, agentNames))
// Convert commands to skills (generated)
const generatedSkills = plugin.commands.map((command) =>
convertCommandToSkill(command, usedSkillNames, agentNames),
)
// Convert MCP servers (stdio and remote)
const mcpServers = convertMcpServers(plugin.mcpServers)
// Build steering files from repo instruction files, preferring AGENTS.md.
const steeringFiles = buildSteeringFiles(plugin, agentNames)
// Warn about hooks
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
console.warn(
"Warning: Kiro CLI hooks use a different format (preToolUse/postToolUse inside agent configs). Hooks were skipped during conversion.",
)
}
return { pluginName: plugin.manifest.name, agents, generatedSkills, skillDirs, steeringFiles, mcpServers }
}
function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]): KiroAgent {
const name = normalizeName(agent.name)
const description = sanitizeDescription(
agent.description ?? `Use this agent for ${agent.name} tasks`,
)
const config: KiroAgentConfig = {
name,
description,
prompt: `file://./prompts/${name}.md`,
tools: ["*"],
resources: [
"file://.kiro/steering/**/*.md",
"skill://.kiro/skills/**/SKILL.md",
],
includeMcpJson: true,
welcomeMessage: `Switching to the ${name} agent. ${description}`,
}
let body = transformContentForKiro(agent.body.trim(), knownAgentNames)
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
}
return { name, config, promptContent: body }
}
function convertCommandToSkill(
command: ClaudeCommand,
usedNames: Set<string>,
knownAgentNames: string[],
): KiroSkill {
const rawName = normalizeName(command.name)
const name = uniqueName(rawName, usedNames)
const description = sanitizeDescription(
command.description ?? `Converted from Claude command ${command.name}`,
)
const frontmatter: Record<string, unknown> = { name, description }
let body = transformContentForKiro(command.body.trim(), knownAgentNames)
if (body.length === 0) {
body = `Instructions converted from the ${command.name} command.`
}
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}
/**
* Transform Claude Code content to Kiro-compatible content.
*
* 1. Task agent calls: Task agent-name(args) -> Use the use_subagent tool ...
* 2. Path rewriting: .claude/ -> .kiro/, ~/.claude/ -> ~/.kiro/
* 3. Slash command refs: /workflows:plan -> use the workflows-plan skill
* 4. Claude tool names: Bash -> shell, Read -> read, etc.
* 5. Agent refs: @agent-name -> the agent-name agent (only for known agent names)
*/
export function transformContentForKiro(body: string, knownAgentNames: string[] = []): string {
let result = body
// 1. Transform Task agent calls (supports namespaced names like compound-engineering:research:agent-name)
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
const agentRef = normalizeName(finalSegment)
const trimmedArgs = args.trim()
return trimmedArgs
? `${prefix}Use the use_subagent tool to delegate to the ${agentRef} agent: ${trimmedArgs}`
: `${prefix}Use the use_subagent tool to delegate to the ${agentRef} agent`
})
// 2. Rewrite .claude/ paths to .kiro/ (with word-boundary-like lookbehind)
result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.kiro/")
result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".kiro/")
// 3. Slash command refs: /command-name -> skill activation language
result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => {
const skillName = normalizeName(cmdName)
return `the ${skillName} skill`
})
// 4. Claude tool names -> Kiro tool names
for (const [claudeTool, kiroTool] of Object.entries(CLAUDE_TO_KIRO_TOOLS)) {
// Match tool name references: "the X tool", "using X", "use X to"
const toolPattern = new RegExp(`\\b${claudeTool}\\b(?=\\s+tool|\\s+to\\s)`, "g")
result = result.replace(toolPattern, kiroTool)
}
// 5. Transform @agent-name references (only for known agent names)
if (knownAgentNames.length > 0) {
const escapedNames = knownAgentNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
const agentRefPattern = new RegExp(`@(${escapedNames.join("|")})\\b`, "g")
result = result.replace(agentRefPattern, (_match, agentName: string) => {
return `the ${normalizeName(agentName)} agent`
})
}
return result
}
function convertMcpServers(
servers?: Record<string, ClaudeMcpServer>,
): Record<string, KiroMcpServer> {
if (!servers || Object.keys(servers).length === 0) return {}
const result: Record<string, KiroMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
const entry: KiroMcpServer = { command: server.command }
if (server.args && server.args.length > 0) entry.args = server.args
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
result[name] = entry
} else if (server.url) {
const entry: KiroMcpServer = { url: server.url }
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
result[name] = entry
} else {
console.warn(
`Warning: MCP server "${name}" has no command or url. Skipping.`,
)
}
}
return result
}
function buildSteeringFiles(plugin: ClaudePlugin, knownAgentNames: string[]): KiroSteeringFile[] {
const instructionPath = resolveInstructionPath(plugin.root)
if (!instructionPath) return []
let content: string
try {
content = readFileSync(instructionPath, "utf8")
} catch {
return []
}
if (!content || content.trim().length === 0) return []
const transformed = transformContentForKiro(content, knownAgentNames)
return [{ name: "compound-engineering", content: transformed }]
}
function resolveInstructionPath(root: string): string | null {
const agentsPath = path.join(root, "AGENTS.md")
if (existsSync(agentsPath)) return agentsPath
const claudePath = path.join(root, "CLAUDE.md")
if (existsSync(claudePath)) return claudePath
return null
}
function normalizeName(value: string): string {
const trimmed = value.trim()
if (!trimmed) return "item"
let normalized = trimmed
.toLowerCase()
.replace(/[\\/]+/g, "-")
.replace(/[:\s]+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-") // Collapse consecutive hyphens (Agent Skills standard)
.replace(/^-+|-+$/g, "")
// Enforce max length (truncate at last hyphen boundary)
if (normalized.length > KIRO_SKILL_NAME_MAX_LENGTH) {
normalized = normalized.slice(0, KIRO_SKILL_NAME_MAX_LENGTH)
const lastHyphen = normalized.lastIndexOf("-")
if (lastHyphen > 0) {
normalized = normalized.slice(0, lastHyphen)
}
normalized = normalized.replace(/-+$/g, "")
}
// Ensure name starts with a letter
if (normalized.length === 0 || !/^[a-z]/.test(normalized)) {
return "item"
}
return normalized
}
function sanitizeDescription(value: string, maxLength = KIRO_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
}
+518
View File
@@ -0,0 +1,518 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { normalizeModelWithProvider, rejectsSamplingParams } from "../utils/model"
import { commandNameToRelativePath } from "../utils/files"
import {
type ClaudeAgent,
type ClaudeCommand,
type ClaudeHooks,
type ClaudePlugin,
type ClaudeMcpServer,
type ClaudeSkill,
filterSkillsByPlatform,
} from "../types/claude"
import type {
OpenCodeBundle,
OpenCodeCommandFile,
OpenCodeConfig,
OpenCodeMcpServer,
} from "../types/opencode"
export type PermissionMode = "none" | "broad" | "from-commands"
export type ClaudeToOpenCodeOptions = {
agentMode: "primary" | "subagent"
inferTemperature: boolean
permissions: PermissionMode
/**
* Codex-only option. Ignored by other targets.
*
* When false (default), `convertClaudeToCodex` emits only agent conversions.
* Skills and commands are expected to install via Codex's native plugin flow
* (`codex plugin install`), which the Bun converter complements rather than
* duplicates. Without this setting, running both native install and the Bun
* converter registers skills twice — once from the native plugin manifest,
* once from the converter output — creating conflicts.
*
* When true, the converter emits skills (copied as-is), commands (as prompts
* and generated skills), and agents together. Use when installing without
* Codex native plugin install (legacy / standalone flow).
*
* Obsolete once Codex's native plugin spec supports custom agents; at that
* point the entire `--to codex` converter path is expected to be deprecated.
*/
codexIncludeSkills?: boolean
}
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 openCodeSkills = filterSkillsByPlatform(plugin.skills, "opencode")
// Commands from the plugin's commands/ directory take priority; skill stubs
// are only appended for names that don't already have an explicit command.
// Dedup uses the normalized path key (colons -> slashes) to match the writer's
// on-disk layout -- "foo:bar" and a skill named "foo/bar" both resolve to
// commands/foo/bar.md, so they must be treated as the same command here.
const explicitCommands = convertCommands(plugin.commands)
const explicitCommandPaths = new Set(explicitCommands.map((c) => commandNameToRelativePath(c.name)))
// Also deduplicate stubs against each other by normalized path, in case two
// skills resolve to the same commands/<path>.md. Keep the first occurrence.
const seenStubPaths = new Set<string>()
const skillStubs = convertSkillsToCommands(openCodeSkills).filter((stub) => {
const normalizedPath = commandNameToRelativePath(stub.name)
if (explicitCommandPaths.has(normalizedPath) || seenStubPaths.has(normalizedPath)) return false
seenStubPaths.add(normalizedPath)
return true
})
const cmdFiles = [...explicitCommands, ...skillStubs]
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []
const config: OpenCodeConfig = {
$schema: "https://opencode.ai/config.json",
mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined,
}
applyPermissions(config, plugin.commands, options.permissions, skillStubs.length > 0)
return {
pluginName: plugin.manifest.name,
config,
agents: agentFiles,
commandFiles: cmdFiles,
plugins,
skillDirs: openCodeSkills.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,
}
// Only write model for primary agents. Subagents inherit from the parent
// session, making them provider-agnostic. Writing an explicit model like
// "anthropic/claude-haiku-4-5" on a subagent causes ProviderModelNotFoundError
// when the user's OpenCode env uses a different provider. See #477.
if (agent.model && agent.model !== "inherit" && options.agentMode === "primary") {
frontmatter.model = normalizeModelWithProvider(agent.model)
}
if (options.inferTemperature) {
const temperature = inferTemperature(agent)
// A written model that rejects non-default sampling params (Sonnet 5, Opus
// 4.7+) returns HTTP 400 if paired with a temperature. We only write model
// for primary agents, so suppression only applies there; subagents inherit
// the parent session's model and are out of scope.
const modelRejectsTemperature =
frontmatter.model !== undefined &&
typeof agent.model === "string" &&
rejectsSamplingParams(agent.model)
if (temperature !== undefined && !modelRejectsTemperature) {
frontmatter.temperature = temperature
}
}
const content = formatFrontmatter(frontmatter, rewriteClaudePaths(agent.body))
return {
name: agent.name,
content,
}
}
// Commands are written as individual .md files rather than entries in opencode.json.
// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001).
function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] {
const files: OpenCodeCommandFile[] = []
for (const command of commands) {
if (command.disableModelInvocation) continue
const frontmatter: Record<string, unknown> = {
description: command.description,
}
if (command.model && command.model !== "inherit") {
frontmatter.model = normalizeModelWithProvider(command.model)
}
const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body))
files.push({ name: command.name, content })
}
return files
}
// Generate a slash-command stub for each skill so OpenCode users can invoke
// /ce-work, /ce-plan, etc. just like Claude Code users do.
// The stub body delegates to the skill tool so the full skill content is loaded.
//
// Only `user-invocable: false` suppresses a stub. `disable-model-invocation`
// does NOT: that flag means the skill is user-invocation-only (e.g. ce-polish,
// whose description says "type /ce-polish to run it"), so a slash command is the
// only entry point it has -- exactly the skill that most needs a stub.
function convertSkillsToCommands(skills: ClaudeSkill[]): OpenCodeCommandFile[] {
const files: OpenCodeCommandFile[] = []
for (const skill of skills) {
if (skill.userInvocable === false) continue
const frontmatter: Record<string, unknown> = {
description: skill.description,
}
if (skill.argumentHint) {
frontmatter["argument-hint"] = skill.argumentHint
}
const body = `Load and execute the \`${skill.name}\` skill.\n\n$ARGUMENTS`
const content = formatFrontmatter(frontmatter, body)
files.push({ name: skill.name, content })
}
return files
}
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
// Wrap tool.execute.before handlers in try-catch to prevent a failing hook
// from crashing parallel tool call batches (causes API 400 errors).
// See: https://github.com/EveryInc/compound-engineering-plugin/issues/85
const isPreToolUse = event === "tool.execute.before"
const note = options.note ? ` // ${options.note}\n` : ""
if (isPreToolUse) {
return ` "${event}": async (input) => {\n${note} try {\n ${wrapped}\n } catch (err) {\n console.error("[hook] ${event} error (non-fatal):", err)\n }\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
? 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 rewriteClaudePaths(body: string): string {
return body
.replace(/~\/\.claude\//g, "~/.config/opencode/")
.replace(/\.claude\//g, ".opencode/")
}
/**
* Transform skill/agent content for OpenCode compatibility.
* Composes path rewriting with fully-qualified agent name flattening.
*
* OpenCode resolves agents by flat filename, so fully-qualified agent
* references must be flattened. Both 3-segment legacy refs
* (`compound-engineering:document-review:coherence-reviewer` -> `coherence-reviewer`)
* and 2-segment category-qualified refs (`review:ce-correctness-reviewer` ->
* `ce-correctness-reviewer`) are handled. 2-segment skill references without
* `ce-` prefix (e.g. `compound-engineering:document-review`) are left unchanged.
* See #477.
*/
export function transformSkillContentForOpenCode(body: string): string {
let result = rewriteClaudePaths(body)
// Rewrite 3-segment FQ agent refs: plugin:category:agent-name -> agent-name.
// Boundary assertions prevent partial matching on 4+ segment names
// (e.g. `a:b:c:d` would otherwise produce `c:d` or `a:d`).
// The `/` in the lookbehind prevents rewriting slash commands like
// `/team:ops:deploy` — agent names are never preceded by `/`.
result = result.replace(
/(?<![a-z0-9:/-])[a-z][a-z0-9-]*:[a-z][a-z0-9-]*:([a-z][a-z0-9-]*)(?![a-z0-9:-])/g,
"$1",
)
// Rewrite 2-segment category-qualified agent refs: category:ce-agent -> ce-agent.
// Only matches when the agent segment starts with `ce-` to avoid false positives
// on slash commands or other colon-separated patterns.
result = result.replace(
/(?<![a-z0-9:/-])[a-z][a-z0-9-]*:(ce-[a-z][a-z0-9-]*)(?![a-z0-9:-])/g,
"$1",
)
return result
}
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,
hasSkillStubs = false,
) {
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)
}
}
}
// Skill stubs require the `skill` tool to load the skill at invocation time.
// If we're emitting stubs, ensure `skill` is allowed even when no explicit
// command listed it in allowed-tools, and even when a command listed a
// patterned skill(foo-*) rule: a pattern-scoped permission would still
// block stubs for skills outside the pattern. Clear any skill patterns so
// the permission build emits a flat "allow" for the skill tool.
if (hasSkillStubs) {
enabled.add("skill")
delete patterns["skill"]
// `from-commands` only allows tools that explicit commands declare. A
// generated stub can load its skill, but the skill body may need tools
// (read/bash/edit/...) that no command declared, so this mode denies
// them and the skill stalls mid-run. We allow `skill` so the stub at
// least loads; warn so the user can pick `none`/`broad` if their skills
// need to act. (The default install mode is `none`, which writes no
// permission block and is unaffected.)
if (enabled.size === 1 && enabled.has("skill")) {
console.warn(
"Warning: --permissions from-commands restricts tools to those declared by explicit commands, " +
"but this plugin's slash commands are skill stubs with no declared tools. The stubs can load " +
"their skills, but the skills may be blocked from using read/bash/edit/etc. Use --permissions none " +
"(default) or broad if your skills need to act.",
)
}
}
}
const permission: Record<string, "allow" | "deny" | Record<string, "allow" | "deny">> = {}
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)[tool] = patternPermission
} else {
permission[tool] = enabled.has(tool) ? "allow" : "deny"
}
}
}
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).edit = combinedPermission
;(permission).write = combinedPermission
}
config.permission = permission
}
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
}
+194
View File
@@ -0,0 +1,194 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type {
PiBundle,
PiGeneratedAgent,
PiMcporterConfig,
PiMcporterServer,
} from "../types/pi"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToPiOptions = ClaudeToOpenCodeOptions
const PI_DESCRIPTION_MAX_LENGTH = 1024
export function convertClaudeToPi(
plugin: ClaudePlugin,
_options: ClaudeToPiOptions,
): PiBundle {
const platformSkills = filterSkillsByPlatform(plugin.skills, "pi")
const promptNames = new Set<string>()
// Pi agents and skills live in separate directories (.pi/agents/<name>.md vs
// .pi/skills/<name>/SKILL.md), so their names don't need to be deduplicated
// against each other.
const usedAgentNames = new Set<string>()
const prompts = plugin.commands
.filter((command) => !command.disableModelInvocation)
.map((command) => convertPrompt(command, promptNames))
const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
return {
pluginName: plugin.manifest.name,
prompts,
skillDirs: platformSkills.map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
})),
generatedSkills: [],
agents,
extensions: [],
mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined,
}
}
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcporterConfig {
const mcpServers: Record<string, PiMcporterServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
mcpServers[name] = {
command: server.command,
args: server.args,
env: server.env,
headers: server.headers,
}
continue
}
if (server.url) {
mcpServers[name] = {
baseUrl: server.url,
headers: server.headers,
}
}
}
return { mcpServers }
}
function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
const name = uniqueName(normalizeName(command.name), usedNames)
const frontmatter: Record<string, unknown> = {
description: command.description,
"argument-hint": command.argumentHint,
}
const body = transformContentForPi(command.body)
return {
name,
content: formatFrontmatter(frontmatter, body.trim()),
}
}
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedAgent {
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,
}
const sections: string[] = []
if (agent.capabilities && agent.capabilities.length > 0) {
sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`)
}
const body = [
...sections,
agent.body.trim().length > 0
? agent.body.trim()
: `Instructions converted from the ${agent.name} agent.`,
].join("\n\n")
return {
name,
content: formatFrontmatter(frontmatter, body),
}
}
export function transformContentForPi(body: string): string {
let result = body
// Task repo-research-analyst(feature_description) or Task compound-engineering:research:repo-research-analyst(args)
// -> Run subagent with agent="repo-research-analyst" and task="feature_description"
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
const skillName = normalizeName(finalSegment)
const trimmedArgs = args.trim().replace(/\s+/g, " ")
return trimmedArgs
? `${prefix}Run subagent with agent=\"${skillName}\" and task=\"${trimmedArgs}\".`
: `${prefix}Run subagent with agent=\"${skillName}\".`
})
// Claude Code task-tracking primitives: current Task* API (TaskCreate/TaskUpdate/TaskList/TaskGet/TaskStop/TaskOutput)
// plus the deprecated legacy pair (TodoWrite/TodoRead). All map to the platform's task-tracking primitive.
result = result.replace(
/\bTask(?:Create|Update|List|Get|Stop|Output)\b/g,
"the platform's task-tracking primitive",
)
result = result.replace(/\bTodoWrite\b/g, "the platform's task-tracking primitive")
result = result.replace(/\bTodoRead\b/g, "the platform's task-tracking primitive")
// /command-name or /workflows:command-name -> /workflows-command-name
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
if (commandName.includes("/")) return match
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) {
return match
}
if (commandName.startsWith("skill:")) {
const skillName = commandName.slice("skill:".length)
return `/skill:${normalizeName(skillName)}`
}
const withoutPrefix = commandName.startsWith("prompts:")
? commandName.slice("prompts:".length)
: commandName
return `/${normalizeName(withoutPrefix)}`
})
return result
}
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 = PI_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
}
+674
View File
@@ -0,0 +1,674 @@
import type { CodexBundle } from "../types/codex"
import type { CopilotBundle } from "../types/copilot"
import type { DroidBundle } from "../types/droid"
import type { ClaudePlugin } from "../types/claude"
import type { KiroBundle } from "../types/kiro"
import type { OpenCodeBundle } from "../types/opencode"
import type { PiBundle } from "../types/pi"
import { sanitizePathName } from "../utils/files"
import { normalizeCodexName } from "../utils/codex-content"
type LegacyPluginArtifacts = {
skills?: string[]
agents?: string[]
commands?: string[]
}
const EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN: Record<string, LegacyPluginArtifacts> = {
"compound-engineering": {
// Historical CE artifacts derived from git history. Keep these explicit so
// cleanup can remove stale flat installs without touching unrelated skills.
skills: [
"agent-browser",
"agent-native-architecture",
"agent-native-audit",
"andrew-kane-gem-writer",
"brainstorming",
"ce-andrew-kane-gem-writer",
"ce-changelog",
"ce-deploy-docs",
"ce-dspy-ruby",
"ce-every-style-editor",
"ce-onboarding",
"ce:brainstorm",
"ce:compound",
"ce:compound-refresh",
"ce:ideate",
"ce:plan",
"ce:plan-beta",
"ce:polish-beta",
"ce:release-notes",
"ce:review",
"ce:review-beta",
"ce:work",
"ce:work-beta",
"ce-agent-native-architecture",
"ce-agent-native-audit",
"ce-audit",
"ce-clean-gone-branches",
"ce-claude-permissions-optimizer",
"ce-demo-reel",
"ce-design",
"ce-dhh-rails-style",
"ce-doctor",
"ce-document-review",
"ce-dogfood-beta",
"ce-frontend-design",
"ce-gemini-imagegen",
"ce-feature-video",
"ce-orchestrating-swarms",
"ce-plan-beta",
"ce-polish-beta",
"ce-pr-description",
"ce-pr-stack",
"ce-release-notes",
"ce-report-bug",
"ce-reproduce-bug",
"ce-review",
"ce-review-beta",
"ce-sessions",
"ce-slack-research",
"ce-session-extract",
"ce-session-inventory",
"ce-update",
"ce-work-beta",
"changelog",
"claude-permissions-optimizer",
"compound-docs",
"compound-foundations",
"create-agent-skill",
"create-agent-skills",
"creating-agent-skills",
"deepen-plan",
"deepen-plan-beta",
"demo-reel",
"deploy-docs",
"dhh-rails-style",
"dhh-ruby-style",
"doctor",
"document-review",
"dspy-ruby",
"every-style-editor",
"evidence-capture",
"feature-video",
"file-todos",
"frontend-design",
"gemini-imagegen",
"generate_command",
"git-clean-gone-branches",
"git-commit",
"git-commit-push-pr",
"git-stack",
"git-worktree",
"heal-skill",
"onboarding",
"orchestrating-swarms",
"pr-resolve-feedback",
"proof",
"proofread",
"rclone",
"report-bug",
"report-bug-ce",
"reproduce-bug",
"resolve-pr-feedback",
"resolve-pr-parallel",
"resolve-todo-parallel",
"resolve_parallel",
"resolve_pr_parallel",
"resolve_todo_parallel",
"setup",
"skill-creator",
"slfg",
"test-browser",
"test-xcode",
"todo-create",
"todo-resolve",
"todo-triage",
"triage",
"workflows-brainstorm",
"workflows:brainstorm",
"workflows-compound",
"workflows:compound",
"workflows-plan",
"workflows:plan",
"workflows-review",
"workflows:review",
"workflows-work",
"workflows:work",
],
agents: [
"ce-adversarial-document-reviewer",
"ce-adversarial-reviewer",
"ce-agent-native-reviewer",
"ce-ankane-readme-writer",
"ce-api-contract-reviewer",
"ce-architecture-strategist",
"ce-best-practices-researcher",
"ce-code-simplicity-reviewer",
"ce-coherence-reviewer",
"ce-correctness-reviewer",
"ce-data-integrity-guardian",
"ce-data-migration-reviewer",
"ce-deployment-verification-agent",
"ce-design-implementation-reviewer",
"ce-design-iterator",
"ce-design-lens-reviewer",
"ce-feasibility-reviewer",
"ce-figma-design-sync",
"ce-framework-docs-researcher",
"ce-git-history-analyzer",
"ce-issue-intelligence-analyst",
"ce-julik-frontend-races-reviewer",
"ce-learnings-researcher",
"ce-maintainability-reviewer",
"ce-pattern-recognition-specialist",
"ce-performance-oracle",
"ce-performance-reviewer",
"ce-previous-comments-reviewer",
"ce-pr-comment-resolver",
"ce-product-lens-reviewer",
"ce-project-standards-reviewer",
"ce-reliability-reviewer",
"ce-repo-research-analyst",
"ce-scope-guardian-reviewer",
"ce-security-lens-reviewer",
"ce-security-reviewer",
"ce-security-sentinel",
"ce-session-historian",
"ce-slack-researcher",
"ce-spec-flow-analyzer",
"ce-swift-ios-reviewer",
"ce-testing-reviewer",
"ce-web-researcher",
"adversarial-document-reviewer",
"adversarial-reviewer",
"agent-native-reviewer",
"ankane-readme-writer",
"api-contract-reviewer",
"architecture-strategist",
"best-practices-researcher",
"bug-reproduction-validator",
"ce-bug-reproduction-validator",
"ce-cli-agent-readiness-reviewer",
"ce-cli-readiness-reviewer",
"ce-lint",
"cli-agent-readiness-reviewer",
"cli-readiness-reviewer",
"code-simplicity-reviewer",
"coherence-reviewer",
"correctness-reviewer",
"data-integrity-guardian",
"ce-data-migration-expert",
"ce-data-migrations-reviewer",
"data-migration-expert",
"data-migrations-reviewer",
"deployment-verification-agent",
"design-implementation-reviewer",
"design-iterator",
"design-lens-reviewer",
"ce-dhh-rails-reviewer",
"dhh-rails-reviewer",
"every-style-editor",
"feasibility-reviewer",
"figma-design-sync",
"framework-docs-researcher",
"git-history-analyzer",
"issue-intelligence-analyst",
"julik-frontend-races-reviewer",
"ce-kieran-python-reviewer",
"ce-kieran-rails-reviewer",
"ce-kieran-typescript-reviewer",
"kieran-python-reviewer",
"kieran-rails-reviewer",
"kieran-typescript-reviewer",
"learnings-researcher",
"lint",
"maintainability-reviewer",
"pattern-recognition-specialist",
"performance-oracle",
"performance-reviewer",
"pr-comment-resolver",
"pr-reviewability-analyst",
"previous-comments-reviewer",
"product-lens-reviewer",
"project-standards-reviewer",
"reliability-reviewer",
"repo-research-analyst",
"ce-schema-drift-detector",
"schema-drift-detector",
"scope-guardian-reviewer",
"security-lens-reviewer",
"security-reviewer",
"security-sentinel",
"session-historian",
"session-history-researcher",
"slack-researcher",
"spec-flow-analyzer",
"testing-reviewer",
"web-researcher",
],
commands: [
"agent-native-audit",
"build-website",
"ce:brainstorm",
"ce:compound",
"ce:plan",
"ce:review",
"ce:work",
"ce:work-beta",
"changelog",
"codify",
"compound",
"compound:codify",
"compound:plan",
"compound:review",
"compound:work",
"ce-dogfood-beta",
"ce-polish-beta",
"create-agent-skill",
"deepen-plan",
"deprecated:deepen-plan",
"deprecated:plan-review",
"deprecated:workflows-plan",
"deploy-docs",
"feature-video",
"generate_command",
"heal-skill",
"lfg",
"plan",
"plan_review",
"playwright-test",
"prime",
"release-docs",
"report-bug",
"reproduce-bug",
"review",
"resolve_parallel",
"resolve_pr_parallel",
"resolve_todo_parallel",
"setup",
"slfg",
"swarm-status",
"technical_review",
"test-browser",
"test-xcode",
"triage",
"work",
"workflows:brainstorm",
"workflows:codify",
"workflows:compound",
"workflows:plan",
"workflows:review",
"workflows:work",
"xcode-test",
],
},
}
export type LegacyTargetArtifacts = {
skills: string[]
prompts: string[]
agents?: string[]
}
export type LegacyTargetFileArtifacts = {
skills: string[]
agents: string[]
commands: string[]
}
export type LegacyDroidArtifacts = {
skills: string[]
commands: string[]
droids: string[]
}
export type LegacyOpenCodeArtifacts = {
skills: string[]
commands: string[]
agents: string[]
}
export type LegacyKiroArtifacts = {
skills: string[]
agents: string[]
}
export type LegacyCopilotArtifacts = {
skills: string[]
agents: string[]
}
export type LegacyWindsurfArtifacts = {
skills: string[]
workflows: string[]
}
export function getLegacyPluginArtifacts(pluginName?: string): LegacyPluginArtifacts {
if (!pluginName) return {}
return EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN[pluginName] ?? {}
}
export function getLegacyCodexArtifacts(bundle: CodexBundle): LegacyTargetArtifacts {
// IMPORTANT: legacy detection for the flat `~/.codex/skills/<name>` and
// `~/.codex/prompts/<name>.md` paths must be driven exclusively by the
// explicit historical allow-list in `EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN`.
//
// Earlier versions of this function also seeded candidates from the current
// plugin bundle (`bundle.skillDirs`, `bundle.generatedSkills`, `bundle.agents`).
// That was unsafe: on a first install, any user-authored skill at a flat
// `~/.codex/skills/<name>` path that happened to share a name with a current
// CE skill or agent would be swept into `compound-engineering/legacy-backup`
// even though it was never part of CE.
//
// The historical allow-list already enumerates every skill/agent/command name
// CE has ever shipped (including names that are still current), so restricting
// detection to that list still cleans up real legacy installs without
// touching unrelated user skills.
const skills = new Set<string>()
const prompts = new Set<string>()
const agents = new Set<string>()
const currentPromptFiles = new Set<string>()
const currentAgentFiles = new Set<string>((bundle.agents ?? []).map((agent) => `${sanitizePathName(agent.name)}.toml`))
for (const prompt of bundle.prompts) {
currentPromptFiles.add(`${sanitizePathName(prompt.name)}.md`)
}
const extras = getLegacyPluginArtifacts(bundle.pluginName)
for (const name of extras.skills ?? []) {
addLegacySkillVariants(skills, name, { includeRawColon: true })
}
for (const name of extras.agents ?? []) {
const normalized = normalizeCodexName(name)
skills.add(normalized)
const agentFile = `${normalized}.toml`
if (!currentAgentFiles.has(agentFile)) {
agents.add(agentFile)
}
}
for (const name of extras.commands ?? []) {
const normalized = normalizeCodexName(name)
skills.add(normalized)
const promptFile = `${normalized}.md`
if (!currentPromptFiles.has(promptFile)) {
prompts.add(promptFile)
}
}
return {
skills: [...skills].sort(),
prompts: [...prompts].sort(),
agents: [...agents].sort(),
}
}
export function getLegacyPiArtifacts(bundle: PiBundle): LegacyTargetArtifacts {
const skills = new Set<string>()
const prompts = new Set<string>()
const agents = new Set<string>()
const currentSkills = new Set<string>([
...bundle.generatedSkills.map((skill) => normalizePiName(skill.name)),
...bundle.skillDirs.map((skill) => normalizePiName(skill.name)),
])
const currentAgentFiles = new Set<string>(bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.md`))
const currentPromptFiles = new Set<string>()
for (const prompt of bundle.prompts) {
currentPromptFiles.add(`${sanitizePathName(prompt.name)}.md`)
}
const extras = getLegacyPluginArtifacts(bundle.pluginName)
for (const name of extras.skills ?? []) {
addLegacySkillVariants(skills, name, { currentSkills })
}
for (const name of extras.agents ?? []) {
const skillName = normalizePiName(name)
if (!currentSkills.has(skillName)) {
skills.add(skillName)
}
const agentFile = `${sanitizePathName(name)}.md`
if (!currentAgentFiles.has(agentFile)) {
agents.add(agentFile)
}
}
for (const name of extras.commands ?? []) {
const promptFile = `${normalizePiName(name)}.md`
if (!currentPromptFiles.has(promptFile)) {
prompts.add(promptFile)
}
}
return {
skills: [...skills].sort(),
prompts: [...prompts].sort(),
agents: [...agents].sort(),
}
}
export function getLegacyDroidArtifacts(bundle: DroidBundle): LegacyDroidArtifacts {
const skills = new Set<string>()
const commands = new Set<string>()
const droids = new Set<string>()
const currentSkills = new Set<string>(bundle.skillDirs.map((skill) => sanitizePathName(skill.name)))
const currentCommands = new Set<string>(bundle.commands.map((command) => `${command.name}.md`))
const currentDroids = new Set<string>(bundle.droids.map((droid) => `${sanitizePathName(droid.name)}.md`))
const extras = getLegacyPluginArtifacts(bundle.pluginName)
for (const name of extras.skills ?? []) {
addLegacySkillVariants(skills, name, { currentSkills })
}
for (const name of extras.agents ?? []) {
const droidPath = `${normalizeLegacyName(name)}.md`
if (!currentDroids.has(droidPath)) {
droids.add(droidPath)
}
}
for (const name of extras.commands ?? []) {
const commandPath = `${flattenLegacyCommandName(name)}.md`
if (!currentCommands.has(commandPath)) {
commands.add(commandPath)
}
}
return {
skills: [...skills].sort(),
commands: [...commands].sort(),
droids: [...droids].sort(),
}
}
export function getLegacyOpenCodeArtifacts(bundle: OpenCodeBundle): LegacyOpenCodeArtifacts {
const skills = new Set<string>()
const commands = new Set<string>()
const agents = new Set<string>()
const currentSkills = new Set<string>(bundle.skillDirs.map((skill) => sanitizePathName(skill.name)))
const currentCommands = new Set<string>(bundle.commandFiles.map((command) => toRawCommandRelativePath(command.name, ".md")))
const currentAgents = new Set<string>(bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.md`))
const extras = getLegacyPluginArtifacts(bundle.pluginName)
for (const name of extras.skills ?? []) {
addLegacySkillVariants(skills, name, { currentSkills })
}
for (const name of extras.agents ?? []) {
const agentPath = `${sanitizePathName(name)}.md`
if (!currentAgents.has(agentPath)) {
agents.add(agentPath)
}
}
for (const name of extras.commands ?? []) {
const commandPath = toRawCommandRelativePath(name, ".md")
if (!currentCommands.has(commandPath)) {
commands.add(commandPath)
}
}
return {
skills: [...skills].sort(),
commands: [...commands].sort(),
agents: [...agents].sort(),
}
}
export function getLegacyKiroArtifacts(bundle: KiroBundle): LegacyKiroArtifacts {
const skills = new Set<string>()
const agents = new Set<string>()
const currentSkills = new Set<string>([
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
])
const currentAgents = new Set<string>(bundle.agents.map((agent) => sanitizePathName(agent.name)))
const extras = getLegacyPluginArtifacts(bundle.pluginName)
for (const name of extras.skills ?? []) {
addLegacySkillVariants(skills, name, { currentSkills })
}
for (const name of extras.agents ?? []) {
const skillName = normalizeLegacyName(name)
if (!currentSkills.has(skillName)) {
skills.add(skillName)
}
const agentName = normalizeLegacyName(name)
if (!currentAgents.has(agentName)) {
agents.add(agentName)
}
}
for (const name of extras.commands ?? []) {
for (const skillName of legacyCommandSkillNames(name)) {
if (!currentSkills.has(skillName)) {
skills.add(skillName)
}
}
}
return {
skills: [...skills].sort(),
agents: [...agents].sort(),
}
}
export function getLegacyCopilotArtifacts(bundle: CopilotBundle): LegacyCopilotArtifacts {
const skills = new Set<string>()
const agents = new Set<string>()
const currentSkills = new Set<string>([
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
])
const currentAgents = new Set<string>(bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.agent.md`))
const extras = getLegacyPluginArtifacts(bundle.pluginName)
for (const name of extras.skills ?? []) {
addLegacySkillVariants(skills, name, { currentSkills })
}
for (const name of extras.agents ?? []) {
const agentPath = `${normalizeLegacyName(name)}.agent.md`
if (!currentAgents.has(agentPath)) {
agents.add(agentPath)
}
}
for (const name of extras.commands ?? []) {
for (const skillName of legacyCommandSkillNames(name)) {
if (!currentSkills.has(skillName)) {
skills.add(skillName)
}
}
}
return {
skills: [...skills].sort(),
agents: [...agents].sort(),
}
}
export function getLegacyWindsurfArtifacts(plugin: ClaudePlugin): LegacyWindsurfArtifacts {
// IMPORTANT: legacy detection for Windsurf roots must be driven exclusively
// by the explicit historical allow-list in `EXTRA_LEGACY_ARTIFACTS_BY_PLUGIN`.
//
// Earlier versions of this function also seeded candidates from the current
// plugin bundle (`plugin.skills`, `plugin.agents`, `plugin.commands`). That
// was unsafe: the Windsurf writer has since been removed, so the only
// purpose of this cleanup is backing up stale files from past installs.
// Any user-authored skill/workflow at a flat Windsurf path that happened to
// share a name with a current CE skill/agent/command (e.g.
// `skills/ce-debug` or `global_workflows/ce-plan.md`) would otherwise be
// swept into `compound-engineering/legacy-backup` even though it was never
// installed by CE.
//
// The historical allow-list already enumerates every skill/agent/command
// name CE has ever shipped (including names that are still current), so
// restricting detection to that list still cleans up real legacy installs
// without touching unrelated user content. If the allow-list is empty for
// this plugin, Windsurf cleanup is a no-op — the correct safety default.
const skills = new Set<string>()
const workflows = new Set<string>()
const extras = getLegacyPluginArtifacts(plugin.manifest.name)
for (const name of extras.skills ?? []) {
skills.add(sanitizePathName(name))
}
for (const name of extras.agents ?? []) {
skills.add(normalizeLegacyName(name))
}
for (const name of extras.commands ?? []) {
workflows.add(`${normalizeLegacyName(name)}.md`)
}
return {
skills: [...skills].sort(),
workflows: [...workflows].sort(),
}
}
function normalizePiName(value: string): string {
return normalizeLegacyName(value)
}
function addLegacySkillVariants(
skills: Set<string>,
name: string,
options: { currentSkills?: Set<string>; includeRawColon?: boolean } = {},
): void {
const { currentSkills, includeRawColon = false } = options
const sanitized = sanitizePathName(name)
if (!currentSkills?.has(sanitized)) {
skills.add(sanitized)
}
// Codex historically accepted raw colon directory names on macOS
// (for example ~/.codex/skills/ce:plan). Other targets generally sanitized
// these names, so raw-colon probing is target-specific.
if (includeRawColon && name.includes(":") && !currentSkills?.has(name)) {
skills.add(name)
}
}
function normalizeLegacyName(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 flattenLegacyCommandName(value: string): string {
const finalSegment = value.includes(":") ? value.split(":").pop()! : value
return normalizeLegacyName(finalSegment)
}
function legacyCommandSkillNames(value: string): string[] {
return [...new Set([normalizeLegacyName(value), flattenLegacyCommandName(value)])]
}
function toNestedCommandRelativePath(value: string, ext: string): string {
return `${value.split(":").map((segment) => normalizeLegacyName(segment)).join("/")}${ext}`
}
function toRawCommandRelativePath(value: string, ext: string): string {
const parts = value.split(":").map((segment) => sanitizePathName(segment))
return `${parts.join("/")}${ext}`
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bun
import { defineCommand, runMain } from "citty"
import packageJson from "../package.json"
import convert from "./commands/convert"
import cleanup from "./commands/cleanup"
import install from "./commands/install"
import listCommand from "./commands/list"
import pluginPath from "./commands/plugin-path"
const main = defineCommand({
meta: {
name: "compound-plugin",
version: packageJson.version,
description: "Convert Claude Code plugins into other agent formats",
},
subCommands: {
cleanup: () => cleanup,
convert: () => convert,
install: () => install,
list: () => listCommand,
"plugin-path": () => pluginPath,
},
})
runMain(main)
+270
View File
@@ -0,0 +1,270 @@
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, file)
const name = (data.name as string) ?? deriveMarkdownStem(file)
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, file)
const name = (data.name as string) ?? path.basename(file, ".md")
const allowedTools = parseAllowedTools(data["allowed-tools"])
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
commands.push({
name,
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
model: data.model as string | undefined,
allowedTools,
disableModelInvocation,
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, file)
const name = (data.name as string) ?? path.basename(path.dirname(file))
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
const userInvocable = data["user-invocable"] === false ? false : undefined
const ce_platforms = Array.isArray(data.ce_platforms) ? (data.ce_platforms as string[]) : undefined
skills.push({
name,
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
disableModelInvocation,
userInvocable,
ce_platforms,
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)) {
const raw = await readJson<Record<string, unknown>>(mcpPath)
return unwrapMcpServers(raw)
}
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"))
}
function deriveMarkdownStem(filePath: string): string {
return path.basename(filePath, ".md").replace(/\.agent$/, "")
}
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)) {
const raw = await readJson<Record<string, unknown>>(resolved)
configs.push(unwrapMcpServers(raw))
}
}
return configs
}
function unwrapMcpServers(raw: Record<string, unknown>): Record<string, ClaudeMcpServer> {
if (raw.mcpServers && typeof raw.mcpServers === "object") {
return raw.mcpServers as Record<string, ClaudeMcpServer>
}
return raw as Record<string, ClaudeMcpServer>
}
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.`)
}
+261
View File
@@ -0,0 +1,261 @@
import { readJson } from "../utils/files"
import type {
BumpLevel,
BumpOverride,
ComponentDecision,
ParsedReleaseIntent,
ReleaseComponent,
ReleasePreview,
} from "./types"
const RELEASE_COMPONENTS: ReleaseComponent[] = [
"compound-engineering",
"marketplace",
"cursor-marketplace",
]
const FILE_COMPONENT_MAP: Array<{ component: ReleaseComponent; prefixes: string[] }> = [
{
component: "compound-engineering",
prefixes: [
"skills/",
"plugin.json",
".claude-plugin/plugin.json",
".cursor-plugin/plugin.json",
".codex-plugin/",
".kimi-plugin/plugin.json",
".grok-plugin/",
".devin-plugin/plugin.json",
".opencode/",
".cline/",
".pi/",
"AGENTS.md",
"CLAUDE.md",
".agy/",
"GEMINI.md", // retained: agy still reads the Gemini-format context file
"README.md",
"package.json",
"src/",
"tests/",
],
},
{
component: "marketplace",
prefixes: [".claude-plugin/marketplace.json"],
},
{
component: "cursor-marketplace",
prefixes: [".cursor-plugin/marketplace.json"],
},
]
const SCOPES_TO_COMPONENTS: Record<string, ReleaseComponent> = {
compound: "compound-engineering",
"compound-engineering": "compound-engineering",
marketplace: "marketplace",
"cursor-marketplace": "cursor-marketplace",
}
const NON_RELEASABLE_TYPES = new Set(["docs", "chore", "test", "ci", "build", "style"])
const PATCH_TYPES = new Set(["fix", "perf", "revert"])
type VersionSources = Record<ReleaseComponent, string>
type RootPackageJson = {
version: string
}
type PluginManifest = {
version: string
}
type MarketplaceManifest = {
metadata: {
version: string
}
}
export function parseReleaseIntent(rawTitle: string): ParsedReleaseIntent {
const trimmed = rawTitle.trim()
const match = /^(?<type>[a-z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<description>.+)$/.exec(trimmed)
if (!match?.groups) {
return {
raw: rawTitle,
type: null,
scope: null,
description: null,
breaking: false,
}
}
return {
raw: rawTitle,
type: match.groups.type ?? null,
scope: match.groups.scope ?? null,
description: match.groups.description ?? null,
breaking: match.groups.bang === "!",
}
}
export function inferBumpFromIntent(intent: ParsedReleaseIntent): BumpLevel | null {
if (intent.breaking) return "major"
if (!intent.type) return null
if (intent.type === "feat") return "minor"
if (PATCH_TYPES.has(intent.type)) return "patch"
if (NON_RELEASABLE_TYPES.has(intent.type)) return null
return null
}
export function detectComponentsFromFiles(files: string[]): Map<ReleaseComponent, string[]> {
const componentFiles = new Map<ReleaseComponent, string[]>()
for (const component of RELEASE_COMPONENTS) {
componentFiles.set(component, [])
}
for (const file of files) {
for (const mapping of FILE_COMPONENT_MAP) {
if (mapping.prefixes.some((prefix) => file === prefix || file.startsWith(prefix))) {
componentFiles.get(mapping.component)!.push(file)
}
}
}
return componentFiles
}
export function resolveComponentWarnings(
intent: ParsedReleaseIntent,
detectedComponents: ReleaseComponent[],
): string[] {
const warnings: string[] = []
if (!intent.type) {
warnings.push("Title does not match the expected conventional format: <type>(optional-scope): description")
return warnings
}
if (intent.scope) {
const normalized = intent.scope.trim().toLowerCase()
const expected = SCOPES_TO_COMPONENTS[normalized]
if (expected && detectedComponents.length > 0 && !detectedComponents.includes(expected)) {
warnings.push(
`Optional scope "${intent.scope}" does not match the detected component set: ${detectedComponents.join(", ")}`,
)
}
}
if (detectedComponents.length === 0 && inferBumpFromIntent(intent) !== null) {
warnings.push("No releasable component files were detected for this change")
}
return warnings
}
export function applyOverride(
inferred: BumpLevel | null,
override: BumpOverride,
): BumpLevel | null {
if (override === "auto") return inferred
return override
}
export function bumpVersion(version: string, bump: BumpLevel | null): string | null {
if (!bump) return null
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
if (!match) {
throw new Error(`Unsupported version format: ${version}`)
}
const major = Number(match[1])
const minor = Number(match[2])
const patch = Number(match[3])
switch (bump) {
case "major":
return `${major + 1}.0.0`
case "minor":
return `${major}.${minor + 1}.0`
case "patch":
return `${major}.${minor}.${patch + 1}`
}
}
export async function loadCurrentVersions(cwd = process.cwd()): Promise<VersionSources> {
const root = await readJson<RootPackageJson>(`${cwd}/package.json`)
const ce = await readJson<PluginManifest>(`${cwd}/.claude-plugin/plugin.json`)
const antigravity = await readJson<PluginManifest>(`${cwd}/plugin.json`)
const kimi = await readJson<PluginManifest>(`${cwd}/.kimi-plugin/plugin.json`)
const grok = await readJson<PluginManifest>(`${cwd}/.grok-plugin/plugin.json`)
const devin = await readJson<PluginManifest>(`${cwd}/.devin-plugin/plugin.json`)
const marketplace = await readJson<MarketplaceManifest>(`${cwd}/.claude-plugin/marketplace.json`)
const cursorMarketplace = await readJson<MarketplaceManifest>(`${cwd}/.cursor-plugin/marketplace.json`)
if (root.version !== ce.version) {
throw new Error(`package.json version ${root.version} does not match .claude-plugin/plugin.json version ${ce.version}`)
}
if (root.version !== antigravity.version) {
throw new Error(`package.json version ${root.version} does not match plugin.json version ${antigravity.version}`)
}
if (root.version !== kimi.version) {
throw new Error(`package.json version ${root.version} does not match .kimi-plugin/plugin.json version ${kimi.version}`)
}
if (root.version !== grok.version) {
throw new Error(`package.json version ${root.version} does not match .grok-plugin/plugin.json version ${grok.version}`)
}
if (root.version !== devin.version) {
throw new Error(`package.json version ${root.version} does not match .devin-plugin/plugin.json version ${devin.version}`)
}
return {
"compound-engineering": ce.version,
marketplace: marketplace.metadata.version,
"cursor-marketplace": cursorMarketplace.metadata.version,
}
}
export async function buildReleasePreview(options: {
title: string
files: string[]
overrides?: Partial<Record<ReleaseComponent, BumpOverride>>
cwd?: string
}): Promise<ReleasePreview> {
const intent = parseReleaseIntent(options.title)
const inferredBump = inferBumpFromIntent(intent)
const componentFilesMap = detectComponentsFromFiles(options.files)
const currentVersions = await loadCurrentVersions(options.cwd)
const detectedComponents = RELEASE_COMPONENTS.filter(
(component) => (componentFilesMap.get(component) ?? []).length > 0,
)
const warnings = resolveComponentWarnings(intent, detectedComponents)
const components: ComponentDecision[] = detectedComponents.map((component) => {
const override = options.overrides?.[component] ?? "auto"
const effectiveBump = applyOverride(inferredBump, override)
const currentVersion = currentVersions[component]
return {
component,
files: componentFilesMap.get(component) ?? [],
currentVersion,
inferredBump,
effectiveBump,
override,
nextVersion: bumpVersion(currentVersion, effectiveBump),
}
})
return {
intent,
warnings,
components,
}
}
+78
View File
@@ -0,0 +1,78 @@
import path from "path"
type ReleasePleasePackageConfig = {
"changelog-path"?: string
"skip-changelog"?: boolean
"release-as"?: string
}
type ReleasePleaseConfig = {
packages: Record<string, ReleasePleasePackageConfig>
}
// Maps a release component path (e.g. ".claude-plugin") to its last-released
// version. Callers pass the manifest as it exists on the base branch (main),
// NOT the working tree -- on a release-please PR the working-tree manifest is
// already bumped to the proposed version, which would make a legitimate pin
// look stale. See validateReleasePleaseConfig and scripts/release/validate.ts.
type ReleasePleaseManifest = Record<string, string>
// Compares two plain "x.y.z" versions. Returns a negative number when `a` is
// lower than `b`, 0 when equal, positive when higher. Any pre-release suffix is
// ignored -- release-owned versions in this repo are plain semver.
function compareReleaseVersions(a: string, b: string): number {
const parse = (version: string) =>
version
.split("-")[0]
.split(".")
.map((part) => Number.parseInt(part, 10) || 0)
const left = parse(a)
const right = parse(b)
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const diff = (left[index] ?? 0) - (right[index] ?? 0)
if (diff !== 0) return diff
}
return 0
}
export function validateReleasePleaseConfig(
config: ReleasePleaseConfig,
manifest: ReleasePleaseManifest = {},
): string[] {
const errors: string[] = []
for (const [packagePath, packageConfig] of Object.entries(config.packages)) {
const releaseAs = packageConfig["release-as"]
if (releaseAs) {
// A release-as pin is only legitimate as a one-shot forward override: it
// must be strictly ahead of the released version so it drives exactly one
// release. Once that release ships, the released version catches up to the
// pin, and this check then fails on the next PR -- forcing cleanup. This
// is what bit the repo in #674: a pin left behind at-or-below the released
// version silently re-pins (freezes) every subsequent release.
//
// `released` is the base-branch (main) version. If it is unknown (e.g. the
// base manifest could not be read), we cannot prove the pin is stale, so
// we allow it rather than risk blocking a legitimate release.
const released = manifest[packagePath]
if (released !== undefined && compareReleaseVersions(releaseAs, released) <= 0) {
errors.push(
`Package "${packagePath}" uses a stale release-as pin "${releaseAs}" that is not ahead of the released version "${released}". Remove release-as after the pinned release ships so future releases can bump normally.`,
)
}
}
const changelogPath = packageConfig["changelog-path"]
if (!changelogPath) continue
const normalized = path.posix.normalize(changelogPath)
const segments = normalized.split("/")
if (segments.includes("..")) {
errors.push(
`Package "${packagePath}" uses an unsupported changelog-path "${changelogPath}". release-please does not allow upward-relative paths like "../".`,
)
}
}
return errors
}
+624
View File
@@ -0,0 +1,624 @@
import { promises as fs } from "fs"
import type { Dirent } from "fs"
import path from "path"
import { readJson, writeJson } from "../utils/files"
import type { ReleaseComponent } from "./types"
type ClaudePluginManifest = {
version: string
description?: string
mcpServers?: Record<string, unknown>
}
type CursorPluginManifest = {
version: string
description?: string
}
type RootPackageJson = {
version: string
}
type CodexPluginManifest = {
name: string
version: string
description?: string
skills?: string
}
type KimiPluginManifest = {
name: string
version: string
description?: string
skills?: string
}
// Devin CLI manifests have no `skills` path field — Devin loads root `skills/`
// by convention. See docs/specs/devin.md.
type DevinPluginManifest = {
name: string
version: string
description?: string
}
type AntigravityManifest = {
version: string
}
type MarketplaceManifest = {
metadata: {
version: string
description?: string
}
plugins: Array<{
name: string
version?: string
description?: string
}>
}
type CodexMarketplaceManifest = {
name: string
plugins: Array<{
name: string
source?: {
source?: string
path?: string
url?: string
}
}>
}
type KimiMarketplaceManifest = {
version: string
plugins: Array<{
id: string
displayName?: string
source?: string
}>
}
type GrokPluginManifest = {
name: string
version: string
description?: string
skills?: string
}
type GrokMarketplaceManifest = {
name?: string
owner?: { name?: string }
plugins: Array<{
name: string
source?: {
source?: string
type?: string
url?: string
path?: string
}
}>
}
type SyncOptions = {
root?: string
componentVersions?: Partial<Record<ReleaseComponent, string>>
write?: boolean
}
type FileUpdate = {
path: string
changed: boolean
}
export type MetadataSyncResult = {
updates: FileUpdate[]
errors: string[]
}
export type CompoundEngineeringCounts = {
agents: number
skills: number
mcpServers: number
}
const COMPOUND_ENGINEERING_DESCRIPTION =
"Brainstorm, plan, debug, review, and compound learnings with AI agents"
const COMPOUND_ENGINEERING_MARKETPLACE_DESCRIPTION =
"Brainstorm, plan, debug, review, and compound learnings with AI agents"
function resolveExpectedVersion(
explicitVersion: string | undefined,
fallbackVersion: string,
): string {
return explicitVersion ?? fallbackVersion
}
export async function countMarkdownFiles(root: string): Promise<number> {
let entries: Dirent[]
try {
entries = await fs.readdir(root, { withFileTypes: true })
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0
throw err
}
let total = 0
for (const entry of entries) {
const fullPath = path.join(root, entry.name)
if (entry.isDirectory()) {
total += await countMarkdownFiles(fullPath)
continue
}
if (entry.isFile() && entry.name.endsWith(".md")) {
total += 1
}
}
return total
}
export async function countSkillDirectories(root: string): Promise<number> {
const entries = await fs.readdir(root, { withFileTypes: true })
let total = 0
for (const entry of entries) {
if (!entry.isDirectory()) continue
const skillPath = path.join(root, entry.name, "SKILL.md")
try {
await fs.access(skillPath)
total += 1
} catch {
// Ignore non-skill directories.
}
}
return total
}
export async function countMcpServers(pluginRoot: string): Promise<number> {
const mcpPath = path.join(pluginRoot, ".mcp.json")
try {
const manifest = await readJson<{ mcpServers?: Record<string, unknown> }>(mcpPath)
return Object.keys(manifest.mcpServers ?? {}).length
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0
throw err
}
}
export async function getCompoundEngineeringCounts(root: string): Promise<CompoundEngineeringCounts> {
const pluginRoot = root
const [agents, skills, mcpServers] = await Promise.all([
countMarkdownFiles(path.join(pluginRoot, "agents")),
countSkillDirectories(path.join(pluginRoot, "skills")),
countMcpServers(pluginRoot),
])
return { agents, skills, mcpServers }
}
export async function buildCompoundEngineeringDescription(_root: string): Promise<string> {
return COMPOUND_ENGINEERING_DESCRIPTION
}
export async function buildCompoundEngineeringMarketplaceDescription(_root: string): Promise<string> {
return COMPOUND_ENGINEERING_MARKETPLACE_DESCRIPTION
}
async function validateDeclaredSkillsPath(
manifestPath: string,
pluginName: string,
platformName: string,
skills: string | undefined,
errors: string[],
): Promise<void> {
if (skills === undefined) {
errors.push(`${manifestPath} (${pluginName}): missing required field "skills". ${platformName} plugins must declare a skills path (e.g., "./skills/").`)
return
}
const pluginDir = path.dirname(path.dirname(manifestPath))
const skillsDir = path.resolve(pluginDir, skills)
try {
const stat = await fs.stat(skillsDir)
if (!stat.isDirectory()) {
errors.push(`${manifestPath} declares skills: "${skills}" but ${skillsDir} is not a directory`)
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${manifestPath} declares skills: "${skills}" but ${skillsDir} does not exist`)
} else {
throw err
}
}
}
export async function syncReleaseMetadata(options: SyncOptions = {}): Promise<MetadataSyncResult> {
const root = options.root ?? process.cwd()
const write = options.write ?? false
const versions = options.componentVersions ?? {}
const updates: FileUpdate[] = []
const errors: string[] = []
const compoundDescription = await buildCompoundEngineeringDescription(root)
const compoundMarketplaceDescription = await buildCompoundEngineeringMarketplaceDescription(root)
const compoundPackagePath = path.join(root, "package.json")
const compoundClaudePath = path.join(root, ".claude-plugin", "plugin.json")
const compoundCursorPath = path.join(root, ".cursor-plugin", "plugin.json")
const compoundAntigravityPath = path.join(root, "plugin.json")
const compoundKimiPath = path.join(root, ".kimi-plugin", "plugin.json")
const compoundDevinPath = path.join(root, ".devin-plugin", "plugin.json")
const marketplaceClaudePath = path.join(root, ".claude-plugin", "marketplace.json")
const marketplaceCursorPath = path.join(root, ".cursor-plugin", "marketplace.json")
const compoundPackage = await readJson<RootPackageJson>(compoundPackagePath)
const compoundClaude = await readJson<ClaudePluginManifest>(compoundClaudePath)
const compoundCursor = await readJson<CursorPluginManifest>(compoundCursorPath)
const marketplaceClaude = await readJson<MarketplaceManifest>(marketplaceClaudePath)
const marketplaceCursor = await readJson<MarketplaceManifest>(marketplaceCursorPath)
const expectedCompoundVersion = resolveExpectedVersion(
versions["compound-engineering"],
compoundClaude.version,
)
updates.push({
path: compoundPackagePath,
changed: compoundPackage.version !== expectedCompoundVersion,
})
let changed = false
if (compoundClaude.version !== expectedCompoundVersion) {
compoundClaude.version = expectedCompoundVersion
changed = true
}
if (compoundClaude.description !== compoundDescription) {
compoundClaude.description = compoundDescription
changed = true
}
updates.push({ path: compoundClaudePath, changed })
if (write && changed) await writeJson(compoundClaudePath, compoundClaude)
changed = false
if (compoundCursor.version !== expectedCompoundVersion) {
compoundCursor.version = expectedCompoundVersion
changed = true
}
if (compoundCursor.description !== compoundDescription) {
compoundCursor.description = compoundDescription
changed = true
}
updates.push({ path: compoundCursorPath, changed })
if (write && changed) await writeJson(compoundCursorPath, compoundCursor)
// Antigravity bundle version sync is detect-only. release-please owns the
// write via extra-files, same as the Codex native plugin manifest.
try {
const compoundAntigravity = await readJson<AntigravityManifest>(compoundAntigravityPath)
updates.push({
path: compoundAntigravityPath,
changed: compoundAntigravity.version !== expectedCompoundVersion,
})
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${compoundAntigravityPath} is missing but ${compoundClaudePath} exists. Antigravity plugin.json parity required.`)
updates.push({ path: compoundAntigravityPath, changed: false })
} else {
throw err
}
}
changed = false
if (versions.marketplace && marketplaceClaude.metadata.version !== versions.marketplace) {
marketplaceClaude.metadata.version = versions.marketplace
changed = true
}
for (const plugin of marketplaceClaude.plugins) {
if (plugin.name === "compound-engineering") {
if (plugin.description !== compoundMarketplaceDescription) {
plugin.description = compoundMarketplaceDescription
changed = true
}
}
// Plugin versions are not synced in marketplace.json -- the canonical
// version lives in each plugin's own plugin.json. Duplicating versions
// here creates drift that release-please can't maintain.
}
updates.push({ path: marketplaceClaudePath, changed })
if (write && changed) await writeJson(marketplaceClaudePath, marketplaceClaude)
changed = false
if (versions["cursor-marketplace"] && marketplaceCursor.metadata.version !== versions["cursor-marketplace"]) {
marketplaceCursor.metadata.version = versions["cursor-marketplace"]
changed = true
}
for (const plugin of marketplaceCursor.plugins) {
if (plugin.name === "compound-engineering") {
if (plugin.description !== compoundMarketplaceDescription) {
plugin.description = compoundMarketplaceDescription
changed = true
}
}
}
updates.push({ path: marketplaceCursorPath, changed })
if (write && changed) await writeJson(marketplaceCursorPath, marketplaceCursor)
// Codex manifests. Unlike Claude/Cursor, the Codex plugin.json is a
// different schema at `.codex-plugin/plugin.json` and the marketplace lives
// at `.agents/plugins/marketplace.json` (no metadata.version field). Plugin
// version sync is DETECT-ONLY here — release-please owns the bump via
// `extra-files` in `.github/release-please-config.json`. Duplicating the
// write would create a second authority for the same field.
const compoundCodexPath = path.join(root, ".codex-plugin", "plugin.json")
const marketplaceCodexPath = path.join(root, ".agents", "plugins", "marketplace.json")
const marketplaceKimiPath = path.join(root, ".kimi-plugin", "marketplace.json")
const codexPluginTargets: Array<{
claudePath: string
claude: ClaudePluginManifest
codexPath: string
expectedName: string
}> = [
{
claudePath: compoundClaudePath,
claude: compoundClaude,
codexPath: compoundCodexPath,
expectedName: "compound-engineering",
},
]
for (const { claudePath, claude, codexPath, expectedName } of codexPluginTargets) {
let codex: CodexPluginManifest
try {
codex = await readJson<CodexPluginManifest>(codexPath)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${codexPath} is missing but ${claudePath} exists. Codex manifest parity required.`)
updates.push({ path: codexPath, changed: false })
continue
}
throw err
}
if (codex.name !== expectedName) {
errors.push(`${codexPath}: name "${codex.name}" does not match expected "${expectedName}"`)
}
let codexChanged = false
// Version: detect-only (release-please owns the write via extra-files).
if (codex.version !== claude.version) {
codexChanged = true
}
// Description: write-enabled (same pattern as Claude/Cursor description sync).
if (claude.description !== undefined && codex.description !== claude.description) {
codex.description = claude.description
codexChanged = true
}
// Skills declaration: required. Codex native install is the source of
// skills for each plugin (and `--to codex` defaults to agents-only), so a
// missing `skills` field silently produces a broken install with no skills
// registered. Enforce presence, then verify the directory exists.
await validateDeclaredSkillsPath(codexPath, expectedName, "Codex", codex.skills, errors)
updates.push({ path: codexPath, changed: codexChanged })
if (write && codexChanged) await writeJson(codexPath, codex)
}
// Codex marketplace: plugin-list parity with Claude marketplace. The Codex
// marketplace has no metadata.version field and is treated as static content
// (no release-please entry). Plugin list must mirror Claude exactly.
try {
const marketplaceCodex = await readJson<CodexMarketplaceManifest>(marketplaceCodexPath)
const claudeNames = [...marketplaceClaude.plugins.map((p) => p.name)].sort()
const codexNames = [...marketplaceCodex.plugins.map((p) => p.name)].sort()
if (claudeNames.join("|") !== codexNames.join("|")) {
errors.push(
`${marketplaceCodexPath}: plugin list [${codexNames.join(", ")}] does not match ${marketplaceClaudePath} [${claudeNames.join(", ")}]`,
)
}
for (const plugin of marketplaceCodex.plugins) {
if (plugin.source?.source === "local" && plugin.source.path === "./") {
errors.push(
`${marketplaceCodexPath}: plugin "${plugin.name}" uses source.path "./"; Codex does not enumerate marketplace entries that point back at the marketplace root. Use a plugin subdirectory path or a Git URL source.`,
)
}
}
updates.push({ path: marketplaceCodexPath, changed: false })
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${marketplaceCodexPath} is missing but ${marketplaceClaudePath} exists. Codex marketplace parity required.`)
updates.push({ path: marketplaceCodexPath, changed: false })
} else {
throw err
}
}
// Kimi manifests. Kimi Code CLI supports root-native plugin manifests at
// `.kimi-plugin/plugin.json`; like Codex, its marketplace catalog has no
// release-owned metadata version, so the plugin version is detect-only here
// and release-please owns the write via the root component's extra-files.
let kimi: KimiPluginManifest
let kimiManifestMissing = false
try {
kimi = await readJson<KimiPluginManifest>(compoundKimiPath)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
kimiManifestMissing = true
errors.push(`${compoundKimiPath} is missing but ${compoundClaudePath} exists. Kimi manifest parity required.`)
updates.push({ path: compoundKimiPath, changed: false })
kimi = { name: "compound-engineering", version: compoundClaude.version }
} else {
throw err
}
}
if (kimi.name !== "compound-engineering") {
errors.push(`${compoundKimiPath}: name "${kimi.name}" does not match expected "compound-engineering"`)
}
let kimiChanged = false
if (kimi.version !== compoundClaude.version) {
kimiChanged = true
}
if (compoundClaude.description !== undefined && kimi.description !== compoundClaude.description) {
kimi.description = compoundClaude.description
kimiChanged = true
}
await validateDeclaredSkillsPath(compoundKimiPath, "compound-engineering", "Kimi", kimi.skills, errors)
updates.push({ path: compoundKimiPath, changed: kimiChanged })
if (write && kimiChanged && !kimiManifestMissing) await writeJson(compoundKimiPath, kimi)
try {
const marketplaceKimi = await readJson<KimiMarketplaceManifest>(marketplaceKimiPath)
if (marketplaceKimi.version !== "2") {
errors.push(`${marketplaceKimiPath}: version "${marketplaceKimi.version}" does not match expected Kimi marketplace schema version "2"`)
}
const claudeNames = [...marketplaceClaude.plugins.map((p) => p.name)].sort()
const kimiIds = [...marketplaceKimi.plugins.map((p) => p.id)].sort()
if (claudeNames.join("|") !== kimiIds.join("|")) {
errors.push(
`${marketplaceKimiPath}: plugin list [${kimiIds.join(", ")}] does not match ${marketplaceClaudePath} [${claudeNames.join(", ")}]`,
)
}
for (const plugin of marketplaceKimi.plugins) {
if (typeof plugin.source !== "string" || plugin.source.trim() === "") {
errors.push(
`${marketplaceKimiPath}: plugin "${plugin.id}" is missing required field "source". Kimi marketplace entries must point to a local path, zip URL, or GitHub URL.`,
)
} else if (plugin.source === "./" || plugin.source === ".") {
errors.push(
`${marketplaceKimiPath}: plugin "${plugin.id}" uses source "${plugin.source}". Use a GitHub URL so Kimi can install the root-native plugin from a published marketplace catalog.`,
)
}
}
updates.push({ path: marketplaceKimiPath, changed: false })
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${marketplaceKimiPath} is missing but ${marketplaceClaudePath} exists. Kimi marketplace parity required.`)
updates.push({ path: marketplaceKimiPath, changed: false })
} else {
throw err
}
}
// Grok manifests. Grok Build supports a native plugin manifest at
// `.grok-plugin/plugin.json` (shadowed by root plugin.json at runtime in this
// multi-platform repo, but the required native surface for xai-org submission).
// Like Codex/Kimi, its marketplace catalog has no release-owned metadata
// version, so the plugin version is detect-only here and release-please owns
// the write via the root component's extra-files.
const compoundGrokPath = path.join(root, ".grok-plugin", "plugin.json")
const marketplaceGrokPath = path.join(root, ".grok-plugin", "marketplace.json")
let grok: GrokPluginManifest
let grokManifestMissing = false
try {
grok = await readJson<GrokPluginManifest>(compoundGrokPath)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
grokManifestMissing = true
errors.push(`${compoundGrokPath} is missing but ${compoundClaudePath} exists. Grok manifest parity required.`)
updates.push({ path: compoundGrokPath, changed: false })
grok = { name: "compound-engineering", version: compoundClaude.version }
} else {
throw err
}
}
if (grok.name !== "compound-engineering") {
errors.push(`${compoundGrokPath}: name "${grok.name}" does not match expected "compound-engineering"`)
}
let grokChanged = false
if (grok.version !== compoundClaude.version) {
grokChanged = true
}
if (compoundClaude.description !== undefined && grok.description !== compoundClaude.description) {
grok.description = compoundClaude.description
grokChanged = true
}
await validateDeclaredSkillsPath(compoundGrokPath, "compound-engineering", "Grok", grok.skills, errors)
updates.push({ path: compoundGrokPath, changed: grokChanged })
if (write && grokChanged && !grokManifestMissing) await writeJson(compoundGrokPath, grok)
// Grok marketplace: plugin-list parity with Claude, and a valid non-self-
// referential source per plugin. Grok (like Codex/Kimi) does not enumerate
// marketplace entries that point back at the marketplace root, so a bare Git
// URL source is required; the catalog has no release-owned version field.
try {
const marketplaceGrok = await readJson<GrokMarketplaceManifest>(marketplaceGrokPath)
const claudeNames = [...marketplaceClaude.plugins.map((p) => p.name)].sort()
const grokNames = [...marketplaceGrok.plugins.map((p) => p.name)].sort()
if (claudeNames.join("|") !== grokNames.join("|")) {
errors.push(
`${marketplaceGrokPath}: plugin list [${grokNames.join(", ")}] does not match ${marketplaceClaudePath} [${claudeNames.join(", ")}]`,
)
}
for (const plugin of marketplaceGrok.plugins) {
const src = plugin.source
if (!src || (typeof src.url !== "string" && typeof src.path !== "string")) {
errors.push(
`${marketplaceGrokPath}: plugin "${plugin.name}" is missing a valid "source". Grok marketplace entries need a URL source ({ "source": "url", "url": ... }) or a local path source.`,
)
} else if (src.path === "./" || src.path === ".") {
errors.push(
`${marketplaceGrokPath}: plugin "${plugin.name}" uses a self-referential local source "${src.path}". Grok does not enumerate marketplace entries that point back at the marketplace root; use a Git URL source.`,
)
}
}
updates.push({ path: marketplaceGrokPath, changed: false })
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${marketplaceGrokPath} is missing but ${marketplaceClaudePath} exists. Grok marketplace parity required.`)
updates.push({ path: marketplaceGrokPath, changed: false })
} else {
throw err
}
}
// Devin manifest. Devin CLI installs the repo natively from
// `.devin-plugin/plugin.json` plus the root `skills/` directory. The manifest
// schema has no `skills` path field and Devin has no marketplace catalog, so
// unlike Kimi there is no declared-skills-path or marketplace parity check.
// Version sync is detect-only — release-please owns the write via extra-files.
// A missing manifest short-circuits after the parity error (same as Codex),
// so it reports exactly one unchanged update entry.
let devin: DevinPluginManifest | undefined
try {
devin = await readJson<DevinPluginManifest>(compoundDevinPath)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${compoundDevinPath} is missing but ${compoundClaudePath} exists. Devin manifest parity required.`)
updates.push({ path: compoundDevinPath, changed: false })
} else {
throw err
}
}
if (devin) {
if (devin.name !== "compound-engineering") {
errors.push(`${compoundDevinPath}: name "${devin.name}" does not match expected "compound-engineering"`)
}
let devinChanged = false
if (devin.version !== compoundClaude.version) {
devinChanged = true
}
if (compoundClaude.description !== undefined && devin.description !== compoundClaude.description) {
devin.description = compoundClaude.description
devinChanged = true
}
updates.push({ path: compoundDevinPath, changed: devinChanged })
if (write && devinChanged) await writeJson(compoundDevinPath, devin)
}
return { updates, errors }
}
+43
View File
@@ -0,0 +1,43 @@
export type ReleaseComponent = "compound-engineering" | "marketplace" | "cursor-marketplace"
export type BumpLevel = "patch" | "minor" | "major"
export type BumpOverride = BumpLevel | "auto"
export type ConventionalReleaseType =
| "feat"
| "fix"
| "perf"
| "refactor"
| "docs"
| "chore"
| "test"
| "ci"
| "build"
| "revert"
| "style"
| string
export type ParsedReleaseIntent = {
raw: string
type: ConventionalReleaseType | null
scope: string | null
description: string | null
breaking: boolean
}
export type ComponentDecision = {
component: ReleaseComponent
files: string[]
currentVersion: string
inferredBump: BumpLevel | null
effectiveBump: BumpLevel | null
override: BumpOverride
nextVersion: string | null
}
export type ReleasePreview = {
intent: ParsedReleaseIntent
warnings: string[]
components: ComponentDecision[]
}
+61
View File
@@ -0,0 +1,61 @@
import path from "path"
import { copySkillDir, ensureDir, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformContentForAntigravity } from "../converters/claude-to-antigravity"
import type { AntigravityBundle, AntigravityPluginManifest } from "../types/antigravity"
/**
* Write an Antigravity (`agy`) plugin bundle.
*
* Unlike the former Gemini writer, this does NOT write into a live install
* directory. `agy` ingests a plugin *directory* via `agy plugin install <dir>`
* into its own internal registry, so this emits a self-contained bundle (the
* `.agy/`-shaped layout) that the user then installs. See docs/specs/antigravity.md.
*/
export async function writeAntigravityBundle(outputRoot: string, bundle: AntigravityBundle): Promise<void> {
const paths = resolveAntigravityPaths(outputRoot)
await ensureDir(paths.bundleDir)
const manifest: AntigravityPluginManifest = {
name: bundle.pluginName ?? "plugin",
version: bundle.version,
}
await writeJson(path.join(paths.bundleDir, "plugin.json"), manifest)
for (const skill of bundle.generatedSkills) {
const skillName = sanitizePathName(skill.name)
await writeText(path.join(paths.skillsDir, skillName, "SKILL.md"), skill.content + "\n")
}
for (const skill of bundle.skillDirs) {
const skillName = sanitizePathName(skill.name)
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skillName), transformContentForAntigravity)
}
for (const agent of bundle.agents ?? []) {
const agentFile = `${sanitizePathName(agent.name)}.md`
await writeText(path.join(paths.agentsDir, agentFile), agent.content + "\n")
}
for (const command of bundle.commands) {
const dest = path.join(paths.commandsDir, ...command.name.split("/")) + ".toml"
await writeText(dest, command.content + "\n")
}
if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
await writeJson(path.join(paths.bundleDir, "mcp_config.json"), { mcpServers: bundle.mcpServers })
}
if (bundle.hooks && Object.keys(bundle.hooks).length > 0) {
await writeJson(path.join(paths.bundleDir, "hooks.json"), { hooks: bundle.hooks })
}
}
function resolveAntigravityPaths(outputRoot: string) {
const bundleDir = path.basename(outputRoot) === ".agy" ? outputRoot : path.join(outputRoot, ".agy")
return {
bundleDir,
skillsDir: path.join(bundleDir, "skills"),
agentsDir: path.join(bundleDir, "agents"),
commandsDir: path.join(bundleDir, "commands"),
}
}
+881
View File
@@ -0,0 +1,881 @@
import fs from "fs/promises"
import path from "path"
import { backupFile, copyDir, copySkillDir, ensureDir, isSafeManagedPath, pathExists, sanitizePathName, writeJson, writeText, writeTextSecure } from "../utils/files"
import type { CodexBundle } from "../types/codex"
import type { ClaudeMcpServer } from "../types/claude"
import { transformContentForCodex } from "../utils/codex-content"
import { getLegacyCodexArtifacts } from "../data/plugin-legacy-artifacts"
import { classifyCodexLegacyPromptOwnership, isLegacyAgentArtifactOwned, isLegacySkillArtifactOwned } from "../utils/legacy-cleanup"
import { isContainedPath, isPathWithinRoot, isPreservedSymlink, lstatOrNull, storeRootEscapesManagedRoot } from "./managed-artifacts"
const MANAGED_START_MARKER = "# BEGIN Compound Engineering plugin MCP -- do not edit this block"
const MANAGED_END_MARKER = "# END Compound Engineering plugin MCP"
const PREV_START_MARKER = "# BEGIN compound-plugin Claude Code MCP"
const PREV_END_MARKER = "# END compound-plugin Claude Code MCP"
const LEGACY_MARKER = "# MCP servers synced from Claude Code"
const UNMARKED_LEGACY_MARKER = "# Generated by compound-plugin"
const MANAGED_INSTALL_MANIFEST = "install-manifest.json"
export type CodexInstallManifest = {
version: 1
pluginName: string
skills: string[]
prompts: string[]
agents: string[]
}
export type CodexWriteOptions = {
outputIsCodexRoot?: boolean
}
export async function writeCodexBundle(
outputRoot: string,
bundle: CodexBundle,
options: CodexWriteOptions = {},
): Promise<void> {
const codexRoot = resolveCodexRoot(outputRoot, options)
await ensureDir(codexRoot)
const pluginName = bundle.pluginName ? sanitizeCodexPathComponent(bundle.pluginName) : undefined
const manifest = pluginName ? await readInstallManifest(codexRoot, pluginName) : null
const currentPrompts = bundle.prompts.map((prompt) => `${sanitizePathName(prompt.name)}.md`)
const agents = bundle.agents ?? []
const agentsRoot = pluginName
? path.join(codexRoot, "agents", pluginName)
: path.join(codexRoot, "agents")
const currentAgents = agents.map((agent) => `${sanitizePathName(agent.name)}.toml`)
assertNoCodexAgentFilenameCollisions(agents)
// Ancestor-symlink containment: the per-entry guards below inspect only the
// leaf node, so a symlinked store dir (or a symlinked ancestor of it) pointed
// at a user fork would otherwise have every cleanup/write act through the link
// into the fork. When a store escapes the codex root we skip it entirely and
// record nothing as owned.
const promptsDir = path.join(codexRoot, "prompts")
const promptsEscaped = await storeRootEscapesManagedRoot(codexRoot, promptsDir)
if (!promptsEscaped) {
if (bundle.prompts.length > 0) {
await cleanupRemovedPrompts(promptsDir, manifest, currentPrompts)
for (const prompt of bundle.prompts) {
await writeText(path.join(promptsDir, `${sanitizePathName(prompt.name)}.md`), prompt.content + "\n")
}
} else if (pluginName) {
await cleanupRemovedPrompts(promptsDir, manifest, [])
}
}
const skillsRoot = pluginName
? path.join(codexRoot, "skills", pluginName)
: path.join(codexRoot, "skills")
// Include `externallyManagedSkillNames` so agents-only installs (default
// `--to codex`) treat skills installed via Codex's native plugin flow as
// "current" for cleanup purposes. Without this, `cleanupLegacyAgentSkillDirs`
// would see an empty `currentSkills` set and sweep allow-listed names such
// as `ce-plan` out of `.codex/skills/<plugin>/` into legacy-backup on every
// re-run of `install --to codex`.
const currentSkills = Array.from(new Set([
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
...(bundle.externallyManagedSkillNames ?? []).map((name) => sanitizePathName(name)),
]))
const skillsEscaped = await storeRootEscapesManagedRoot(codexRoot, skillsRoot)
if (!skillsEscaped) await cleanupRemovedSkills(skillsRoot, manifest, currentSkills)
const preservedSkillNames = new Set<string>()
if (bundle.skillDirs.length > 0) {
for (const skill of bundle.skillDirs) {
const skillName = sanitizePathName(skill.name)
if (skillsEscaped) {
preservedSkillNames.add(skillName)
continue
}
const targetDir = path.join(skillsRoot, skillName)
const preserved = await cleanupCurrentManagedSkillDir(targetDir, manifest, skillName)
if (preserved) {
preservedSkillNames.add(skillName)
continue
}
await copySkillDir(
skill.sourceDir,
targetDir,
(content) => transformContentForCodex(content, bundle.invocationTargets, {
unknownSlashBehavior: "preserve",
}),
)
}
}
if (bundle.generatedSkills.length > 0) {
for (const skill of bundle.generatedSkills) {
const skillName = sanitizePathName(skill.name)
if (skillsEscaped) {
preservedSkillNames.add(skillName)
continue
}
const skillDir = path.join(skillsRoot, skillName)
const preserved = await cleanupCurrentManagedSkillDir(skillDir, manifest, skillName)
if (preserved) {
preservedSkillNames.add(skillName)
continue
}
await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n")
for (const sidecar of skill.sidecarDirs ?? []) {
await copyDir(sidecar.sourceDir, path.join(skillDir, sidecar.targetName))
}
}
}
const agentsEscaped = await storeRootEscapesManagedRoot(codexRoot, agentsRoot)
const preservedAgentNames = new Set<string>()
if (!agentsEscaped) await cleanupRemovedAgents(agentsRoot, manifest, currentAgents)
if (agents.length > 0) {
for (const agent of agents) {
const agentBaseName = sanitizePathName(agent.name)
const agentFile = `${agentBaseName}.toml`
if (agentsEscaped) {
preservedAgentNames.add(agentFile)
continue
}
const targetPath = path.join(agentsRoot, agentFile)
const preserved = await cleanupCurrentManagedAgentFile(targetPath, manifest, agentFile)
if (preserved) {
preservedAgentNames.add(agentFile)
continue
}
// If the agent declares no sidecars, remove any same-basename sibling
// directory left behind by a prior install that did. The manifest-driven
// cleanupRemovedAgents sweep above only removes the sibling dir when the
// TOML itself is being removed; a same-name agent that loses its sidecar
// would otherwise leave an orphan directory. The user may have replaced
// the sidecar dir with a symlink into a personal fork while keeping the
// managed TOML -- never delete through that symlink node.
if ((agent.sidecarDirs ?? []).length === 0 && isSafeManagedPath(agentsRoot, agentBaseName)) {
const sidecarDir = path.join(agentsRoot, agentBaseName)
if (!(await isPreservedSymlink(sidecarDir))) {
await fs.rm(sidecarDir, { recursive: true, force: true })
}
}
await writeText(targetPath, renderCodexAgentToml(agent) + "\n")
for (const sidecar of agent.sidecarDirs ?? []) {
await copyDir(sidecar.sourceDir, path.join(agentsRoot, agentBaseName, sidecar.targetName))
}
}
}
if (pluginName) {
if (!skillsEscaped) await ensureDir(skillsRoot)
// Preserved skills/agents (user symlinks or unmanaged dirs/files this
// install skipped) and any store that escaped the codex root via a
// symlinked ancestor must not be recorded as owned -- the plugin never
// claims a path it didn't write.
await writeInstallManifest(codexRoot, {
version: 1,
pluginName,
skills: currentSkills.filter((name) => !preservedSkillNames.has(name)),
prompts: promptsEscaped ? [] : currentPrompts,
agents: currentAgents.filter((name) => !preservedAgentNames.has(name)),
})
await cleanupKnownLegacyCodexArtifacts(codexRoot, bundle)
// Skip the skills-area sweeps when that store escaped so we don't traverse
// the symlink at all; their destructive ops are independently containment-
// guarded too (see moveLegacyArtifactToBackup / isManagedCodexAgentsSymlink).
if (!skillsEscaped) {
await cleanupLegacyAgentSkillDirs(codexRoot, pluginName, currentSkills, bundle)
await cleanupLegacyAgentsSkillSymlinks(codexRoot, pluginName, currentSkills, manifest)
}
await cleanupPreviousManagedCodexSkillStore(codexRoot, pluginName)
}
const configPath = path.join(codexRoot, "config.toml")
const existingConfig = await readFileSafe(configPath)
const mcpToml = renderCodexConfig(bundle.mcpServers)
const merged = mergeCodexConfig(existingConfig, mcpToml)
if (merged !== null) {
const backupPath = await backupFile(configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
await writeTextSecure(configPath, merged)
}
// Write hooks to .codex/hooks.json — Codex uses the same hooks format
// as Claude Code. Hooks are merged with any existing hooks file to avoid
// clobbering hooks from other plugins or manual configuration.
// Always run the merge (even with empty hooks) so previously installed
// managed entries are cleaned up on upgrade.
if (pluginName) {
const hooksPath = path.join(codexRoot, "hooks.json")
const existingHooks = await readJsonSafe(hooksPath)
const pluginHooks = bundle.hooks?.hooks ?? {}
const mergedHooks = mergeCodexHooks(existingHooks, pluginHooks, pluginName)
const mergedContent = JSON.stringify(mergedHooks, null, 2) + "\n"
const existingContent = existingHooks !== null
? JSON.stringify(existingHooks, null, 2) + "\n"
: null
const hasHooks = Object.keys((mergedHooks.hooks as Record<string, unknown>) ?? {}).length > 0
// Only write when content actually changes (avoids backup churn on idempotent re-installs)
if ((hasHooks || existingHooks !== null) && mergedContent !== existingContent) {
if (existingHooks !== null) {
const backupPath = await backupFile(hooksPath)
if (backupPath) {
console.log(`Backed up existing hooks to ${backupPath}`)
}
}
await writeTextSecure(hooksPath, mergedContent)
}
}
}
function resolveCodexRoot(outputRoot: string, options: CodexWriteOptions): string {
if (options.outputIsCodexRoot) return outputRoot
return path.basename(outputRoot) === ".codex" ? outputRoot : path.join(outputRoot, ".codex")
}
function sanitizeCodexPathComponent(name: string): string {
return sanitizePathName(name).replace(/[\\/]/g, "-")
}
export async function readCodexInstallManifest(codexRoot: string, pluginName: string): Promise<CodexInstallManifest | null> {
return readInstallManifest(codexRoot, pluginName)
}
async function readInstallManifest(codexRoot: string, pluginName: string): Promise<CodexInstallManifest | null> {
const manifestPath = path.join(codexRoot, pluginName, MANAGED_INSTALL_MANIFEST)
try {
const raw = await fs.readFile(manifestPath, "utf8")
const parsed = JSON.parse(raw) as Partial<CodexInstallManifest>
if (
parsed.version === 1 &&
parsed.pluginName === pluginName &&
Array.isArray(parsed.skills) &&
Array.isArray(parsed.prompts)
) {
// Filter manifest entries at read time. Cleanup functions join these
// strings into `fs.rm` paths, so a tampered or corrupted
// `install-manifest.json` could otherwise delete outside the Codex
// managed tree. Codex entries are bare leaf names joined against
// `skills/<plugin>`, `prompts/`, or `agents/<plugin>` — but the
// absolute-path and `..`-segment checks are root-independent, and we
// use `codexRoot` for the containment check as the outermost root
// that contains every possible destination.
const agents = Array.isArray(parsed.agents) ? parsed.agents : []
return {
version: 1,
pluginName,
skills: filterSafeCodexManifestEntries(parsed.skills, codexRoot, manifestPath, "skills"),
prompts: filterSafeCodexManifestEntries(parsed.prompts, codexRoot, manifestPath, "prompts"),
agents: filterSafeCodexManifestEntries(agents, codexRoot, manifestPath, "agents"),
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn(`Ignoring unreadable Codex install manifest at ${manifestPath}.`)
}
}
return null
}
function filterSafeCodexManifestEntries(
entries: unknown[],
codexRoot: string,
manifestPath: string,
group: string,
): string[] {
const safe: string[] = []
for (const entry of entries) {
if (isSafeManagedPath(codexRoot, entry)) {
safe.push(entry)
} else {
console.warn(
`Dropping unsafe Codex install-manifest entry in ${manifestPath} (group "${group}"): ${JSON.stringify(entry)}`,
)
}
}
return safe
}
async function writeInstallManifest(codexRoot: string, manifest: CodexInstallManifest): Promise<void> {
await writeJson(path.join(codexRoot, manifest.pluginName, MANAGED_INSTALL_MANIFEST), manifest)
}
async function cleanupRemovedSkills(
skillsRoot: string,
manifest: CodexInstallManifest | null,
currentSkills: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentSkills)
for (const skillName of manifest.skills) {
if (current.has(skillName)) continue
// Defense in depth: `readInstallManifest` already drops unsafe entries,
// but re-check before any out-of-tree fs.rm can be issued from a future
// caller that bypasses the read layer.
if (!isSafeManagedPath(skillsRoot, skillName)) continue
const targetDir = path.join(skillsRoot, skillName)
// The manifest can lag reality: a prior install owned this name, but the
// user has since replaced it with a symlink (e.g. into a personal fork).
// Never delete through a symlink node even when the stale manifest still
// claims ownership.
if (await isPreservedSymlink(targetDir)) continue
await fs.rm(targetDir, { recursive: true, force: true })
}
}
async function cleanupRemovedPrompts(
promptsDir: string,
manifest: CodexInstallManifest | null,
currentPrompts: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentPrompts)
for (const promptFile of manifest.prompts) {
if (current.has(promptFile)) continue
if (!isSafeManagedPath(promptsDir, promptFile)) continue
await fs.rm(path.join(promptsDir, promptFile), { force: true })
}
}
async function cleanupRemovedAgents(
agentsRoot: string,
manifest: CodexInstallManifest | null,
currentAgents: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentAgents)
for (const agentFile of manifest.agents) {
if (current.has(agentFile)) continue
if (!isSafeManagedPath(agentsRoot, agentFile)) continue
const targetPath = path.join(agentsRoot, agentFile)
if (!(await isPreservedSymlink(targetPath))) {
await fs.rm(targetPath, { force: true })
}
const sidecarDir = path.join(agentsRoot, path.basename(agentFile, ".toml"))
if (!(await isPreservedSymlink(sidecarDir))) {
await fs.rm(sidecarDir, { recursive: true, force: true })
}
}
}
// Returns true when the existing path was preserved (skip cleanup AND the
// subsequent copy/write -- writing through a preserved symlink would clobber
// the user's fork, which is worse than not overwriting at all).
async function cleanupCurrentManagedSkillDir(
targetDir: string,
manifest: CodexInstallManifest | null,
skillName: string,
): Promise<boolean> {
const stat = await lstatOrNull(targetDir)
if (!stat) return false
if (stat.isSymbolicLink()) {
console.warn(`Skipping ${targetDir}: existing user-managed symlink (not overwritten)`)
return true
}
if (!manifest?.skills.includes(skillName)) {
console.warn(`Skipping ${targetDir}: existing unmanaged directory (not overwritten)`)
return true
}
await fs.rm(targetDir, { recursive: true, force: true })
return false
}
// Returns true when the existing path was preserved (skip cleanup AND the
// subsequent write -- writing through a preserved symlink would clobber the
// user's fork, which is worse than not overwriting at all).
async function cleanupCurrentManagedAgentFile(
targetPath: string,
manifest: CodexInstallManifest | null,
agentFileName: string,
): Promise<boolean> {
const stat = await lstatOrNull(targetPath)
if (!stat) return false
if (stat.isSymbolicLink()) {
console.warn(`Skipping ${targetPath}: existing user-managed symlink (not overwritten)`)
return true
}
if (!manifest?.agents.includes(agentFileName)) {
console.warn(`Skipping ${targetPath}: existing unmanaged file (not overwritten)`)
return true
}
await fs.rm(targetPath, { force: true })
return false
}
async function cleanupKnownLegacyCodexArtifacts(codexRoot: string, bundle: CodexBundle): Promise<void> {
const pluginName = bundle.pluginName
if (!pluginName) return
const legacyArtifacts = getLegacyCodexArtifacts(bundle)
for (const skillName of legacyArtifacts.skills) {
const legacySkillPath = path.join(codexRoot, "skills", skillName)
if (!(await isLegacySkillArtifactOwned(legacySkillPath, skillName))) continue
await moveLegacyArtifactToBackup(codexRoot, pluginName, "skills", legacySkillPath)
}
for (const promptFile of legacyArtifacts.prompts) {
const legacyPromptPath = path.join(codexRoot, "prompts", promptFile)
// Ownership gate: `~/.codex/prompts/` is a shared directory across plugins
// and user-authored prompts. A filename match against the legacy allow-list
// is not a strong enough signal to move a file — a user who creates
// `~/.codex/prompts/ce-plan.md` for their own workflow would otherwise see
// it swept into `compound-engineering/legacy-backup/` on every install.
// Mirror the body + frontmatter check used by the standalone
// `cleanupStalePrompts` helper. "unknown" (no fingerprint on record, e.g.
// fully-retired wrappers like `reproduce-bug.md`) falls through to the
// historical allow-list behavior — user collisions at those names are
// unlikely and a strict gate would strand genuinely-owned orphans.
const ownership = await classifyCodexLegacyPromptOwnership(legacyPromptPath)
if (ownership === "foreign") continue
await moveLegacyArtifactToBackup(codexRoot, pluginName, "prompts", legacyPromptPath)
}
for (const agentFile of legacyArtifacts.agents ?? []) {
await moveLegacyArtifactToBackup(
codexRoot,
pluginName,
"agents",
path.join(codexRoot, "agents", pluginName, agentFile),
)
const flatAgentPath = path.join(codexRoot, "agents", agentFile)
if (await isLegacyAgentArtifactOwned(flatAgentPath, path.basename(agentFile, ".toml"), ".toml")) {
await moveLegacyArtifactToBackup(codexRoot, pluginName, "agents", flatAgentPath)
}
}
}
async function cleanupLegacyAgentSkillDirs(
codexRoot: string,
pluginName: string,
currentSkills: string[],
bundle: CodexBundle,
): Promise<void> {
const currentSkillSet = new Set(currentSkills)
const legacySkillNames = new Set<string>()
for (const agent of bundle.agents ?? []) {
legacySkillNames.add(sanitizePathName(agent.name))
// Cross-layout cleanup for plugins with nested agent directories: if the
// Codex-built agent name contains an embedded `-ce-` (e.g. `review-ce-foo`
// for `agents/review/ce-foo.md`), the same logical agent may have
// previously been installed under the flat-alias name (`ce-foo`) by an
// earlier plugin layout. Seeding the flat alias as a cleanup candidate
// sweeps that orphan skill dir on upgrade. For flat-only layouts (such as
// compound-engineering itself) `agent.name` has no embedded `-ce-` and
// this produces harmless non-matching candidates like `ce-ce-<name>`.
if (agent.name.includes("-ce-")) {
const finalSegment = agent.name.split("-ce-").pop()
if (finalSegment) legacySkillNames.add(`ce-${sanitizePathName(finalSegment)}`)
}
}
for (const name of getLegacyCodexArtifacts({
pluginName,
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [],
}).skills) {
legacySkillNames.add(name)
}
const skillsRoot = path.join(codexRoot, "skills", pluginName)
for (const skillName of legacySkillNames) {
if (currentSkillSet.has(skillName)) continue
await moveLegacyArtifactToBackup(codexRoot, pluginName, "skills", path.join(skillsRoot, skillName))
}
}
async function cleanupLegacyAgentsSkillSymlinks(
codexRoot: string,
pluginName: string,
currentSkills: string[],
manifest: CodexInstallManifest | null,
): Promise<void> {
// Symlink cleanup is safe for a broad candidate set because
// `removeAgentsSkillSymlinkIfManaged` only removes a symlink whose resolved
// target is inside a managed root. We probe:
// - current and manifest-tracked skills (in case stale symlinks point at
// still-current skill directories under a previous layout)
// - the explicit historical legacy allow-list (renamed/removed CE skills)
// Bundle-derived names that might collide with unrelated user skills are
// safe here because the managed-root check rejects symlinks pointing
// anywhere outside CE's own install tree.
const legacyArtifacts = getLegacyCodexArtifacts({
pluginName,
prompts: [],
skillDirs: [],
generatedSkills: [],
})
const candidateSkillNames = new Set<string>([
...currentSkills,
...(manifest?.skills ?? []),
...legacyArtifacts.skills,
])
const agentsSkillsDir = path.join(path.dirname(codexRoot), ".agents", "skills")
const managedRoots = await resolveCodexManagedRoots(codexRoot, pluginName)
await removeAgentsSkillSymlinkIfManaged(path.join(agentsSkillsDir, pluginName), managedRoots)
for (const skillName of candidateSkillNames) {
await removeAgentsSkillSymlinkIfManaged(path.join(agentsSkillsDir, skillName), managedRoots)
}
}
async function cleanupPreviousManagedCodexSkillStore(codexRoot: string, pluginName: string): Promise<void> {
const skillStoreDir = path.join(codexRoot, pluginName, "skills")
if (await isPreservedSymlink(skillStoreDir)) return
// Ancestor containment: never recursively delete through a managed dir that
// has been repointed at a user fork via a symlinked ancestor.
if (!(await isPathWithinRoot(codexRoot, skillStoreDir))) return
await fs.rm(skillStoreDir, { recursive: true, force: true })
}
async function removeAgentsSkillSymlinkIfManaged(symlinkPath: string, managedRoots: string[]): Promise<void> {
if (!(await isManagedCodexAgentsSymlink(symlinkPath, managedRoots))) return
try {
await fs.unlink(symlinkPath)
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
}
}
/**
* Ownership check for entries under the shared `~/.agents/skills/` store.
*
* Returns true only when `entryPath` is a symlink whose resolved target lives
* inside one of the supplied CE-managed Codex roots. Plain files, directories,
* and symlinks pointing elsewhere (user-created skills that happen to share a
* name with a CE skill) return false so callers can leave them alone.
*
* The shared `.agents` store is cross-plugin, so name-only matches are
* ambiguous. Only a symlink pointing into CE's own install tree is a strong
* signal that CE emitted it — use this guard before any mutation there.
*/
export async function isManagedCodexAgentsSymlink(
entryPath: string,
managedRoots: string[],
): Promise<boolean> {
let stats
try {
stats = await fs.lstat(entryPath)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false
throw err
}
if (!stats.isSymbolicLink()) return false
const resolvedTarget = await readResolvedSymlinkTarget(entryPath)
if (!resolvedTarget) return false
return managedRoots.some((root) => isPathInside(resolvedTarget, root))
}
/**
* Build the set of CE-managed Codex roots used as the ownership signal for
* entries under `~/.agents/skills/`. Returns both the raw and realpath-resolved
* forms so symlink-bearing paths on macOS (`/var/folders/...` -> `/private/...`)
* match regardless of which form the resolved symlink target takes.
*/
export async function resolveCodexManagedRoots(
codexRoot: string,
pluginName: string,
): Promise<string[]> {
const rawManagedRoots = [
path.join(codexRoot, pluginName),
path.join(codexRoot, "skills", pluginName),
]
return [
...rawManagedRoots,
...(await Promise.all(rawManagedRoots.map((root) => canonicalizePath(root)))),
]
}
async function readResolvedSymlinkTarget(symlinkPath: string): Promise<string | null> {
try {
return await fs.realpath(symlinkPath)
} catch {
try {
const linkTarget = await fs.readlink(symlinkPath)
return path.resolve(path.dirname(symlinkPath), linkTarget)
} catch {
return null
}
}
}
async function canonicalizePath(filePath: string): Promise<string> {
try {
return await fs.realpath(filePath)
} catch {
return path.resolve(filePath)
}
}
function isPathInside(candidatePath: string, rootPath: string): boolean {
return isContainedPath(path.resolve(rootPath), path.resolve(candidatePath))
}
async function moveLegacyArtifactToBackup(
codexRoot: string,
pluginName: string,
kind: "skills" | "prompts" | "agents",
artifactPath: string,
): Promise<void> {
// Ownership fingerprinting reads THROUGH a symlink, so a user fork of a
// legacy-named artifact still matches — never move the symlink node into
// legacy-backup, or the user's override is silently deactivated.
if (await isPreservedSymlink(artifactPath)) return
// Ancestor containment: skip when a symlinked ancestor (e.g. the whole store
// dir) resolves the artifact outside the codex root, into a user fork.
if (!(await isPathWithinRoot(codexRoot, artifactPath))) return
if (!(await pathExists(artifactPath))) return
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupDir = path.join(codexRoot, pluginName, "legacy-backup", timestamp, kind)
const backupPath = path.join(backupDir, path.basename(artifactPath))
await ensureDir(backupDir)
await fs.rename(artifactPath, backupPath)
console.warn(`Moved legacy Codex ${kind.slice(0, -1)} artifact to ${backupPath}`)
}
export function renderCodexConfig(mcpServers?: Record<string, ClaudeMcpServer>): string | null {
if (!mcpServers || Object.keys(mcpServers).length === 0) return null
const lines: string[] = []
for (const [name, server] of Object.entries(mcpServers)) {
if (!server.command && !server.url) continue
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.length > 0 ? lines.join("\n") : null
}
async function readFileSafe(filePath: string): Promise<string> {
try {
return await fs.readFile(filePath, "utf-8")
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}
return ""
}
}
export function mergeCodexConfig(existingContent: string, mcpToml: string | null): string | null {
// Strip current and previous managed blocks
let stripped = existingContent
let removedManagedBlock = false
for (const [start, end] of [[MANAGED_START_MARKER, MANAGED_END_MARKER], [PREV_START_MARKER, PREV_END_MARKER]]) {
const next = stripped.replace(
new RegExp(`${escapeForRegex(start)}[\\s\\S]*?${escapeForRegex(end)}\\n?`, "g"),
"",
)
if (next !== stripped) removedManagedBlock = true
stripped = next
}
// No MCP servers to write — only remove bounded managed blocks. Do not strip
// unmarked legacy markers here: old Codex config files may contain user
// settings after "# Generated by compound-plugin", and there is no safe
// boundary for deleting only plugin-owned TOML.
if (!mcpToml) {
if (!existingContent) return null
const legacyMarkerIndex = stripped.indexOf(LEGACY_MARKER)
if (legacyMarkerIndex !== -1) {
return stripped.slice(0, legacyMarkerIndex).trimEnd()
}
return removedManagedBlock ? stripped.trimEnd() : existingContent
}
stripped = stripped.trimEnd()
// Strip from legacy markers to end of content (old formats wrote everything after the marker)
let cleaned = stripped
for (const marker of [LEGACY_MARKER, UNMARKED_LEGACY_MARKER]) {
const idx = cleaned.indexOf(marker)
if (idx !== -1) {
cleaned = cleaned.slice(0, idx).trimEnd()
}
}
const managedBlock = [
MANAGED_START_MARKER,
mcpToml.trim(),
MANAGED_END_MARKER,
"",
].join("\n")
return cleaned
? `${cleaned}\n\n${managedBlock}`
: `${managedBlock}`
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function formatTomlString(value: string): string {
return JSON.stringify(value)
}
function assertNoCodexAgentFilenameCollisions(
agents: NonNullable<CodexBundle["agents"]>,
): void {
const seen = new Map<string, string>()
for (const agent of agents) {
const filename = `${sanitizePathName(agent.name)}.toml`
const prior = seen.get(filename)
if (prior !== undefined && prior !== agent.name) {
throw new Error(
`Codex agent filename collision: "${prior}" and "${agent.name}" both normalize to ` +
`"${filename}". Rename one of the source agents so their sanitized filenames differ. ` +
`A numeric suffix cannot be used here because the TOML filename must match the ` +
`agent name used for Task(subagent_type: ...) invocations.`,
)
}
seen.set(filename, agent.name)
}
}
function renderCodexAgentToml(agent: NonNullable<CodexBundle["agents"]>[number]): string {
const lines = [
`name = ${formatTomlString(agent.name)}`,
`description = ${formatTomlString(agent.description)}`,
`developer_instructions = ${formatTomlString(agent.instructions)}`,
]
return lines.join("\n")
}
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(", ")} }`
}
// ── Hooks ──────────────────────────────────────────────────
async function readJsonSafe(filePath: string): Promise<Record<string, unknown> | null> {
try {
const content = await fs.readFile(filePath, "utf8")
return JSON.parse(content)
} catch {
return null
}
}
type HookEntry = { matcher?: string; hooks: Array<{ type: string; command?: string; prompt?: string; agent?: string; timeout?: number }> }
/**
* Index tracking which hook entries are managed by which plugin.
* Stored as a sibling `_managed` block in hooks.json so hook entry
* objects stay schema-clean (no `_source` field injected into runtime data).
*
* Shape: `{ "<pluginName>": { "<event>": [<index>, ...] } }`
*/
type ManagedIndex = Record<string, Record<string, number[]>>
/**
* Merge plugin hooks into an existing .codex/hooks.json, preserving hooks
* from other sources. Uses a `_managed` index block to track which entries
* belong to which plugin, so re-installs can replace them cleanly without
* injecting bookkeeping fields into hook entry objects.
*/
export function mergeCodexHooks(
existing: Record<string, unknown> | null,
pluginHooks: Record<string, HookEntry[]>,
pluginName: string,
): Record<string, unknown> {
const result: Record<string, unknown[]> = {}
const managed: ManagedIndex = (existing?._managed as ManagedIndex) ?? {}
// Collect indices of entries managed by this plugin (to be removed)
const ownedIndices: Record<string, Set<number>> = {}
if (managed[pluginName]) {
for (const [event, indices] of Object.entries(managed[pluginName])) {
ownedIndices[event] = new Set(indices)
}
}
// Preserve existing hooks, filtering out this plugin's managed entries
const existingHooks = (existing?.hooks ?? {}) as Record<string, unknown[]>
for (const [event, matchers] of Object.entries(existingHooks)) {
if (!Array.isArray(matchers)) continue
const owned = ownedIndices[event]
result[event] = owned
? matchers.filter((_, idx) => !owned.has(idx))
: [...matchers]
}
// Also filter out entries with legacy `_source` tag from this plugin
// (migration path from the previous `_source`-in-entry format)
for (const [event, matchers] of Object.entries(result)) {
result[event] = (matchers as Array<Record<string, unknown>>).filter((m) => {
if (typeof m === "object" && m !== null && "_source" in m) {
return m._source !== pluginName
}
return true
})
}
// Build new managed index for this plugin
const newManagedForPlugin: Record<string, number[]> = {}
// Add this plugin's hooks (clean entries, no _source field)
for (const [event, matchers] of Object.entries(pluginHooks)) {
if (!result[event]) result[event] = []
const indices: number[] = []
for (const matcher of matchers) {
indices.push(result[event].length)
result[event].push({ ...matcher })
}
if (indices.length > 0) {
newManagedForPlugin[event] = indices
}
}
// Remove empty event arrays
for (const event of Object.keys(result)) {
if (result[event].length === 0) delete result[event]
}
// Update managed index
if (Object.keys(newManagedForPlugin).length > 0) {
managed[pluginName] = newManagedForPlugin
} else {
delete managed[pluginName]
}
const output: Record<string, unknown> = { hooks: result }
if (Object.keys(managed).length > 0) {
output._managed = managed
}
return output
}
+76
View File
@@ -0,0 +1,76 @@
import type { ClaudePlugin } from "../types/claude"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import { convertClaudeToAntigravity } from "../converters/claude-to-antigravity"
import { writeOpenCodeBundle } from "./opencode"
import { writeCodexBundle } from "./codex"
import { writePiBundle } from "./pi"
import { writeAntigravityBundle } from "./antigravity"
export type TargetScope = "global" | "workspace"
export function isTargetScope(value: string): value is TargetScope {
return value === "global" || value === "workspace"
}
/**
* Validate a --scope flag against a target's supported scopes.
* Returns the resolved scope (explicit or default) or throws on invalid input.
*/
export function validateScope(
targetName: string,
target: TargetHandler,
scopeArg: string | undefined,
): TargetScope | undefined {
if (scopeArg === undefined) return target.defaultScope
if (!target.supportedScopes) {
throw new Error(`Target "${targetName}" does not support the --scope flag.`)
}
if (!isTargetScope(scopeArg) || !target.supportedScopes.includes(scopeArg)) {
throw new Error(`Target "${targetName}" does not support --scope ${scopeArg}. Supported: ${target.supportedScopes.join(", ")}`)
}
return scopeArg
}
export type TargetHandler<TBundle = unknown> = {
name: string
implemented: boolean
/** Default scope when --scope is not provided. Only meaningful when supportedScopes is defined. */
defaultScope?: TargetScope
/** Valid scope values. If absent, the --scope flag is rejected for this target. */
supportedScopes?: TargetScope[]
convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null
write: (outputRoot: string, bundle: TBundle, scope?: TargetScope) => Promise<void>
}
export const targets: Record<string, TargetHandler> = {
opencode: {
name: "opencode",
implemented: true,
convert: convertClaudeToOpenCode,
write: writeOpenCodeBundle as TargetHandler["write"],
},
codex: {
name: "codex",
implemented: true,
convert: convertClaudeToCodex as TargetHandler["convert"],
write: ((outputRoot, bundle) =>
writeCodexBundle(outputRoot, bundle as Parameters<typeof writeCodexBundle>[1], {
outputIsCodexRoot: true,
})) as TargetHandler["write"],
},
pi: {
name: "pi",
implemented: true,
convert: convertClaudeToPi as TargetHandler["convert"],
write: writePiBundle as TargetHandler["write"],
},
antigravity: {
name: "antigravity",
implemented: true,
convert: convertClaudeToAntigravity as TargetHandler["convert"],
write: writeAntigravityBundle as TargetHandler["write"],
},
}
+165
View File
@@ -0,0 +1,165 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformContentForKiro } from "../converters/claude-to-kiro"
import type { KiroBundle } from "../types/kiro"
import { cleanupStaleSkillDirs, cleanupStaleAgents, isLegacyAgentArtifactOwned, isLegacySkillArtifactOwned } from "../utils/legacy-cleanup"
import { getLegacyKiroArtifacts } from "../data/plugin-legacy-artifacts"
import { moveLegacyArtifactToBackup, sanitizeManagedPluginName } from "./managed-artifacts"
export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): Promise<void> {
const paths = resolveKiroPaths(outputRoot)
const pluginName = bundle.pluginName ? sanitizeManagedPluginName(bundle.pluginName) : undefined
await ensureDir(paths.kiroDir)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
await cleanupStaleSkillDirs(paths.skillsDir)
await cleanupStaleAgents(path.join(paths.agentsDir, "prompts"), ".md")
await cleanupStaleAgents(paths.agentsDir, ".json")
// Write agents
if (bundle.agents.length > 0) {
for (const agent of bundle.agents) {
// Validate name doesn't escape agents directory
validatePathSafe(agent.name, "agent")
// Write agent JSON config
await writeJson(
path.join(paths.agentsDir, `${sanitizePathName(agent.name)}.json`),
agent.config,
)
// Write agent prompt file
await writeText(
path.join(paths.agentsDir, "prompts", `${sanitizePathName(agent.name)}.md`),
agent.promptContent + "\n",
)
}
}
// Write generated skills (from commands)
if (bundle.generatedSkills.length > 0) {
for (const skill of bundle.generatedSkills) {
validatePathSafe(skill.name, "skill")
await writeText(
path.join(paths.skillsDir, sanitizePathName(skill.name), "SKILL.md"),
skill.content + "\n",
)
}
}
// Copy skill directories (pass-through)
if (bundle.skillDirs.length > 0) {
for (const skill of bundle.skillDirs) {
validatePathSafe(skill.name, "skill directory")
const destDir = path.join(paths.skillsDir, sanitizePathName(skill.name))
// Validate destination doesn't escape skills directory
const resolvedDest = path.resolve(destDir)
if (!resolvedDest.startsWith(path.resolve(paths.skillsDir))) {
console.warn(`Warning: Skill name "${skill.name}" escapes .kiro/skills/. Skipping.`)
continue
}
const knownAgentNames = bundle.agents.map((a) => a.name)
await copySkillDir(skill.sourceDir, destDir, (content) =>
transformContentForKiro(content, knownAgentNames),
)
}
}
// Write steering files
if (bundle.steeringFiles.length > 0) {
for (const file of bundle.steeringFiles) {
validatePathSafe(file.name, "steering file")
await writeText(
path.join(paths.steeringDir, `${sanitizePathName(file.name)}.md`),
file.content + "\n",
)
}
}
// Write MCP servers to mcp.json
if (Object.keys(bundle.mcpServers).length > 0) {
const mcpPath = path.join(paths.settingsDir, "mcp.json")
const backupPath = await backupFile(mcpPath)
if (backupPath) {
console.log(`Backed up existing mcp.json to ${backupPath}`)
}
// Merge with existing mcp.json if present
let existingConfig: Record<string, unknown> = {}
if (await pathExists(mcpPath)) {
try {
existingConfig = await readJson<Record<string, unknown>>(mcpPath)
} catch {
console.warn("Warning: existing mcp.json could not be parsed and will be replaced.")
}
}
const existingServers =
existingConfig.mcpServers && typeof existingConfig.mcpServers === "object"
? (existingConfig.mcpServers as Record<string, unknown>)
: {}
const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpServers } }
await writeJson(mcpPath, merged)
}
if (pluginName) {
await cleanupKnownLegacyKiroArtifacts(paths, bundle)
}
}
function resolveKiroPaths(outputRoot: string) {
const base = path.basename(outputRoot)
// If already pointing at .kiro, write directly into it
if (base === ".kiro") {
return {
kiroDir: outputRoot,
managedDir: path.join(outputRoot, "compound-engineering"),
agentsDir: path.join(outputRoot, "agents"),
skillsDir: path.join(outputRoot, "skills"),
steeringDir: path.join(outputRoot, "steering"),
settingsDir: path.join(outputRoot, "settings"),
}
}
// Otherwise nest under .kiro
const kiroDir = path.join(outputRoot, ".kiro")
return {
kiroDir,
managedDir: path.join(kiroDir, "compound-engineering"),
agentsDir: path.join(kiroDir, "agents"),
skillsDir: path.join(kiroDir, "skills"),
steeringDir: path.join(kiroDir, "steering"),
settingsDir: path.join(kiroDir, "settings"),
}
}
async function cleanupKnownLegacyKiroArtifacts(
paths: ReturnType<typeof resolveKiroPaths>,
bundle: KiroBundle,
): Promise<void> {
const legacyArtifacts = getLegacyKiroArtifacts(bundle)
for (const skillName of legacyArtifacts.skills) {
const legacySkillPath = path.join(paths.skillsDir, skillName)
if (!(await isLegacySkillArtifactOwned(legacySkillPath, skillName))) continue
await moveLegacyArtifactToBackup(paths.managedDir, "skills", paths.skillsDir, skillName, "Kiro skill")
}
for (const agentName of legacyArtifacts.agents) {
const configPath = path.join(paths.agentsDir, `${agentName}.json`)
if (!(await isLegacyAgentArtifactOwned(configPath, agentName, ".json"))) continue
await moveLegacyArtifactToBackup(paths.managedDir, "agents", paths.agentsDir, `${agentName}.json`, "Kiro agent")
await moveLegacyArtifactToBackup(
paths.managedDir,
"agents",
path.join(paths.agentsDir, "prompts"),
`${agentName}.md`,
"Kiro agent prompt",
)
}
}
function validatePathSafe(name: string, label: string): void {
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
throw new Error(`${label} name contains unsafe path characters: ${name}`)
}
}
+325
View File
@@ -0,0 +1,325 @@
import fs from "fs/promises"
import type { Stats } from "fs"
import path from "path"
import { ensureDir, isSafeManagedPath, pathExists, readText, sanitizePathName, writeJson } from "../utils/files"
const MANAGED_INSTALL_MANIFEST = "install-manifest.json"
const LEGACY_MANAGED_SEGMENT = "compound-engineering"
export type ManagedInstallManifest = {
version: 1
pluginName: string
groups: Record<string, string[]>
}
export function sanitizeManagedPluginName(name: string): string {
return sanitizePathName(name).replace(/[\\/]/g, "-")
}
/**
* Returns the directory segment used to namespace managed install artifacts
* (manifest, legacy-backup) under a target's root. When a sanitized plugin
* name is supplied, it is used verbatim so multiple plugins installed into
* the same target root keep independent manifests. When no plugin name is
* supplied (legacy callers / bundles without `pluginName`), the historical
* `compound-engineering` segment is returned to preserve pre-existing paths.
*/
export function resolveManagedSegment(pluginName?: string): string {
return pluginName ?? LEGACY_MANAGED_SEGMENT
}
/**
* Resolves the legacy shared managed directory that lived next to the
* current plugin-scoped directory before the per-plugin namespacing fix.
* `managedDir` is the plugin-scoped path (e.g. `<root>/coding-tutor`);
* the legacy sibling is `<root>/compound-engineering`. When `pluginName`
* is the historical `compound-engineering`, the legacy path and the
* current path are the same, so there is nothing to migrate -- this
* returns null in that case.
*/
export function resolveLegacyManagedDir(managedDir: string, pluginName: string): string | null {
if (pluginName === LEGACY_MANAGED_SEGMENT) return null
return path.join(path.dirname(managedDir), LEGACY_MANAGED_SEGMENT)
}
/**
* Reads the plugin-scoped install manifest, falling back to the legacy
* shared manifest at `<root>/compound-engineering/install-manifest.json`
* when the plugin-scoped one is missing. The legacy manifest is only
* returned when its recorded `pluginName` matches the current plugin --
* `readManagedInstallManifest` enforces that match, so a legacy manifest
* belonging to a different plugin is left untouched for that plugin's
* own next install to migrate.
*/
export async function readManagedInstallManifestWithLegacyFallback(
managedDir: string,
pluginName: string,
): Promise<ManagedInstallManifest | null> {
const current = await readManagedInstallManifest(managedDir, pluginName)
if (current) return current
const legacyDir = resolveLegacyManagedDir(managedDir, pluginName)
if (!legacyDir) return null
return readManagedInstallManifest(legacyDir, pluginName)
}
/**
* After a plugin-scoped manifest has been written, archive the legacy
* shared manifest if it belongs to the current plugin, so the legacy
* path doesn't keep shadowing or misleading a future install. The
* legacy file is renamed into a timestamped backup under the new
* plugin-scoped managed dir rather than deleted outright, for parity
* with the `legacy-backup/` archival done for removed artifacts.
*
* If the legacy manifest does not exist, or it exists but is owned by
* a different plugin, this is a no-op.
*/
export async function archiveLegacyInstallManifestIfOwned(
managedDir: string,
pluginName: string,
): Promise<void> {
const legacyDir = resolveLegacyManagedDir(managedDir, pluginName)
if (!legacyDir) return
const legacyManifestPath = path.join(legacyDir, MANAGED_INSTALL_MANIFEST)
if (!(await pathExists(legacyManifestPath))) return
// Only archive when the legacy manifest belongs to the current plugin;
// `readManagedInstallManifest` validates `pluginName` and returns null
// otherwise, so a null result means "not ours, leave it alone."
const owned = await readManagedInstallManifest(legacyDir, pluginName)
if (!owned) return
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupPath = path.join(managedDir, "legacy-backup", timestamp, MANAGED_INSTALL_MANIFEST)
await ensureDir(path.dirname(backupPath))
await fs.rename(legacyManifestPath, backupPath)
console.warn(`Moved legacy install manifest to ${backupPath}`)
}
export async function readManagedInstallManifest(
managedDir: string,
pluginName: string,
): Promise<ManagedInstallManifest | null> {
const manifestPath = path.join(managedDir, MANAGED_INSTALL_MANIFEST)
try {
const raw = await readText(manifestPath)
const parsed = JSON.parse(raw) as Partial<ManagedInstallManifest>
if (
parsed.version === 1 &&
parsed.pluginName === pluginName &&
parsed.groups &&
typeof parsed.groups === "object" &&
!Array.isArray(parsed.groups) &&
Object.values(parsed.groups).every((entries) => Array.isArray(entries))
) {
// Filter manifest entries at read time: cleanup joins these strings
// into fs.rm paths, so a corrupted or tampered manifest with entries
// like `../../config.toml` could delete outside the managed root.
// We drop unsafe entries here (primary defense) and warn so operators
// see the corruption signal. Cleanup functions also re-check each
// entry (defense in depth).
const safeGroups: Record<string, string[]> = {}
for (const [group, entries] of Object.entries(parsed.groups)) {
const safe: string[] = []
for (const entry of entries as unknown[]) {
if (isSafeManagedPath(managedDir, entry)) {
safe.push(entry)
} else {
console.warn(
`Dropping unsafe install-manifest entry in ${manifestPath} (group "${group}"): ${JSON.stringify(entry)}`,
)
}
}
safeGroups[group] = safe
}
return { version: 1, pluginName, groups: safeGroups }
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn(`Ignoring unreadable install manifest at ${manifestPath}.`)
}
}
return null
}
export async function writeManagedInstallManifest(
managedDir: string,
manifest: ManagedInstallManifest,
): Promise<void> {
await writeJson(path.join(managedDir, MANAGED_INSTALL_MANIFEST), manifest)
}
export async function cleanupRemovedManagedDirectories(
rootDir: string,
manifest: ManagedInstallManifest | null,
group: string,
currentEntries: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentEntries)
for (const relativePath of manifest.groups[group] ?? []) {
if (current.has(relativePath)) continue
// Defense in depth: `readManagedInstallManifest` already drops unsafe
// entries, but re-check here so any future caller that bypasses the
// read layer cannot trigger out-of-tree deletes.
if (!isSafeManagedPath(rootDir, relativePath)) continue
const targetPath = resolveArtifactPath(rootDir, relativePath)
// The manifest can lag reality: a prior install owned this name, but the
// user has since replaced it with a symlink (e.g. into a personal fork).
// Never delete through a symlink node even when the stale manifest still
// claims ownership.
if (await isPreservedSymlink(targetPath)) continue
await fs.rm(targetPath, { recursive: true, force: true })
}
}
export async function cleanupRemovedManagedFiles(
rootDir: string,
manifest: ManagedInstallManifest | null,
group: string,
currentEntries: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentEntries)
for (const relativePath of manifest.groups[group] ?? []) {
if (current.has(relativePath)) continue
if (!isSafeManagedPath(rootDir, relativePath)) continue
const targetPath = resolveArtifactPath(rootDir, relativePath)
if (await isPreservedSymlink(targetPath)) continue
await fs.rm(targetPath, { force: true })
}
}
// Returns true when the existing path was preserved (skip cleanup AND the
// subsequent copy/write -- writing through a preserved symlink would clobber
// the user's fork, which is worse than not overwriting at all).
export async function cleanupCurrentManagedDirectory(
targetDir: string,
manifest: ManagedInstallManifest | null,
group: string,
entryName: string,
): Promise<boolean> {
const stat = await lstatOrNull(targetDir)
if (!stat) return false
if (stat.isSymbolicLink()) {
console.warn(`Skipping ${targetDir}: existing user-managed symlink (not overwritten)`)
return true
}
if (!manifest?.groups[group]?.includes(entryName)) {
console.warn(`Skipping ${targetDir}: existing unmanaged directory (not overwritten)`)
return true
}
await fs.rm(targetDir, { recursive: true, force: true })
return false
}
export async function moveLegacyArtifactToBackup(
managedDir: string,
kind: string,
artifactRoot: string,
relativePath: string,
label: string,
options: { skipSymlinkGuard?: boolean } = {},
): Promise<void> {
const artifactPath = resolveArtifactPath(artifactRoot, relativePath)
// Ownership fingerprinting reads THROUGH a symlink, so a user fork of a
// legacy-named artifact still matches — never move the symlink node into
// legacy-backup, or the user's override is silently deactivated.
// `skipSymlinkGuard` is for callers (e.g. the shared `~/.agents/skills/`
// sweep in `src/commands/cleanup.ts`) that have already independently
// verified via a stronger signal (the symlink's resolved target lives
// inside a CE-managed root) that this specific symlink node IS the
// CE-owned artifact to relocate, not a user override to preserve.
if (!options.skipSymlinkGuard && (await isPreservedSymlink(artifactPath))) return
// Ancestor-symlink containment: `isPreservedSymlink` only catches a symlinked
// leaf; block the rename when the artifact resolves outside the target root
// via a symlinked ancestor (e.g. the whole store dir repointed at a fork).
// Every caller passes a store dir that is a direct child of the target root,
// so its parent is that root. `skipSymlinkGuard` callers are exempt for the
// same reason as above.
if (!options.skipSymlinkGuard && !(await isPathWithinRoot(path.dirname(artifactRoot), artifactPath))) return
if (!(await pathExists(artifactPath))) return
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupPath = path.join(managedDir, "legacy-backup", timestamp, kind, ...relativePath.split("/"))
await ensureDir(path.dirname(backupPath))
await fs.rename(artifactPath, backupPath)
console.warn(`Moved legacy ${label} artifact to ${backupPath}`)
}
function resolveArtifactPath(rootDir: string, relativePath: string): string {
return path.join(rootDir, ...relativePath.split("/"))
}
export async function lstatOrNull(targetPath: string): Promise<Stats | null> {
try {
return await fs.lstat(targetPath)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null
throw err
}
}
export async function isPreservedSymlink(targetPath: string): Promise<boolean> {
const stat = await lstatOrNull(targetPath)
if (!stat?.isSymbolicLink()) return false
console.warn(`Skipping ${targetPath}: existing user-managed symlink (not overwritten)`)
return true
}
/**
* Realpath of the nearest existing ancestor of `targetPath` (the path itself
* when it exists). A not-yet-created descendant cannot introduce a new symlink
* hop, so resolving the nearest existing ancestor is enough to decide whether
* `targetPath` escapes a root -- and it lets the containment check run before a
* fresh store directory has been created.
*/
async function realpathNearestExisting(targetPath: string): Promise<string> {
let current = path.resolve(targetPath)
for (;;) {
try {
return await fs.realpath(current)
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
const parent = path.dirname(current)
if (parent === current) return current
current = parent
}
}
}
/**
* True when `targetPath`, after resolving symlinks on every existing ancestor,
* still lives inside `rootDir`. The leaf-node guards (`isPreservedSymlink`)
* only inspect the final path component, so a symlinked *ancestor* -- e.g. a
* whole store directory (`~/.codex/skills/<plugin>`) repointed at a personal
* fork -- slips past them, and the subsequent fs.rm / copy then operates
* THROUGH the link into the fork. Both sides are realpath'd so a config root
* that was legitimately relocated as a whole (its entire tree moved behind one
* symlink) still reads as contained.
*/
/**
* Pure containment predicate over two already-resolved absolute paths: true
* when `targetResolved` is `rootResolved` itself or a descendant of it.
*/
export function isContainedPath(rootResolved: string, targetResolved: string): boolean {
const rel = path.relative(rootResolved, targetResolved)
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
}
export async function isPathWithinRoot(rootDir: string, targetPath: string): Promise<boolean> {
return isContainedPath(await realpathNearestExisting(rootDir), await realpathNearestExisting(targetPath))
}
/**
* Guards a managed store directory against ancestor-symlink traversal. Returns
* true (and warns once) when `storeDir` escapes `rootDir` via a symlinked
* ancestor, meaning the caller must skip EVERY operation on that store --
* cleanup sweeps and writes alike -- since all of them would otherwise act
* through the link into whatever the user pointed it at. `rootDir` is the
* writer's target root (e.g. `~/.codex`), not the store dir itself.
*/
export async function storeRootEscapesManagedRoot(rootDir: string, storeDir: string): Promise<boolean> {
if (await isPathWithinRoot(rootDir, storeDir)) return false
console.warn(`Skipping ${storeDir}: resolves outside the managed root via a symlinked ancestor (not modified)`)
return true
}
+287
View File
@@ -0,0 +1,287 @@
import path from "path"
import { backupFile, commandNameToRelativePath, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformSkillContentForOpenCode } from "../converters/claude-to-opencode"
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
import { getLegacyOpenCodeArtifacts } from "../data/plugin-legacy-artifacts"
import { isLegacyAgentArtifactOwned, isLegacySkillArtifactOwned } from "../utils/legacy-cleanup"
import {
archiveLegacyInstallManifestIfOwned,
cleanupCurrentManagedDirectory,
cleanupRemovedManagedDirectories,
cleanupRemovedManagedFiles,
lstatOrNull,
type ManagedInstallManifest,
moveLegacyArtifactToBackup,
storeRootEscapesManagedRoot,
readManagedInstallManifestWithLegacyFallback,
resolveManagedSegment,
sanitizeManagedPluginName,
writeManagedInstallManifest,
} from "./managed-artifacts"
// Returns true when the existing path was preserved (skip cleanup AND the
// subsequent write -- writing through a preserved symlink would clobber the
// user's fork, which is worse than not overwriting at all). All four
// OpenCode artifact kinds (agents, commands, plugins, skills) are tracked in
// the install manifest's `groups` map, so the same ownership rule applies
// uniformly here rather than a symlink-only guard.
async function cleanupCurrentManagedFile(
targetPath: string,
manifest: ManagedInstallManifest | null,
group: string,
entryName: string,
): Promise<boolean> {
const stat = await lstatOrNull(targetPath)
if (!stat) return false
if (stat.isSymbolicLink()) {
console.warn(`Skipping ${targetPath}: existing user-managed symlink (not overwritten)`)
return true
}
if (!manifest?.groups[group]?.includes(entryName)) {
console.warn(`Skipping ${targetPath}: existing unmanaged file (not overwritten)`)
return true
}
return false
}
async function mergeOpenCodeConfig(
configPath: string,
incoming: OpenCodeConfig,
): Promise<OpenCodeConfig> {
if (!(await pathExists(configPath))) return incoming
let existing: OpenCodeConfig
try {
existing = await readJson<OpenCodeConfig>(configPath)
} catch {
console.warn(
`Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`
)
return incoming
}
const mergedMcp = {
...(incoming.mcp ?? {}),
...(existing.mcp ?? {}),
}
const mergedPermission = incoming.permission
? {
...(incoming.permission),
...(existing.permission ?? {}),
}
: existing.permission
const mergedTools = incoming.tools
? {
...(incoming.tools),
...(existing.tools ?? {}),
}
: existing.tools
return {
...existing,
$schema: incoming.$schema ?? existing.$schema,
mcp: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined,
permission: mergedPermission,
tools: mergedTools,
}
}
export async function writeOpenCodeBundle(
outputRoot: string,
bundle: OpenCodeBundle,
scope?: string,
): Promise<void> {
const pluginName = bundle.pluginName ? sanitizeManagedPluginName(bundle.pluginName) : undefined
const openCodePaths = resolveOpenCodePaths(outputRoot, pluginName, scope)
const manifest = pluginName
? await readManagedInstallManifestWithLegacyFallback(openCodePaths.managedDir, pluginName)
: null
const currentAgents = bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.md`)
const currentCommands = bundle.commandFiles.map((commandFile) => `${commandNameToRelativePath(commandFile.name)}.md`)
const currentPlugins = bundle.plugins.map((plugin) => plugin.name)
const currentSkills = bundle.skillDirs.map((skill) => sanitizePathName(skill.name))
// Ancestor-symlink containment: the per-entry guards below inspect only the
// leaf node, so a symlinked store dir (or a symlinked ancestor of it) pointed
// at a user fork would otherwise have every cleanup/write act through the link
// into the fork. A store that escapes the OpenCode root is skipped wholesale
// and recorded as owning nothing.
const agentsEscaped = await storeRootEscapesManagedRoot(openCodePaths.root, openCodePaths.agentsDir)
const commandsEscaped = await storeRootEscapesManagedRoot(openCodePaths.root, openCodePaths.commandDir)
const pluginsEscaped = await storeRootEscapesManagedRoot(openCodePaths.root, openCodePaths.pluginsDir)
const skillsEscaped = await storeRootEscapesManagedRoot(openCodePaths.root, openCodePaths.skillsDir)
await ensureDir(openCodePaths.root)
if (!agentsEscaped) await cleanupRemovedManagedFiles(openCodePaths.agentsDir, manifest, "agents", currentAgents)
if (!commandsEscaped) await cleanupRemovedManagedFiles(openCodePaths.commandDir, manifest, "commands", currentCommands)
if (!pluginsEscaped) await cleanupRemovedManagedFiles(openCodePaths.pluginsDir, manifest, "plugins", currentPlugins)
if (!skillsEscaped) await cleanupRemovedManagedDirectories(openCodePaths.skillsDir, manifest, "skills", currentSkills)
const hadExistingConfig = await pathExists(openCodePaths.configPath)
const backupPath = await backupFile(openCodePaths.configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
const merged = await mergeOpenCodeConfig(openCodePaths.configPath, bundle.config)
await writeJson(openCodePaths.configPath, merged)
if (hadExistingConfig) {
console.log("Merged plugin config into existing opencode.json (user settings preserved)")
}
const seenAgents = new Set<string>()
const preservedAgentNames = new Set<string>()
for (const agent of bundle.agents) {
const safeName = sanitizePathName(agent.name)
if (seenAgents.has(safeName)) {
console.warn(`Skipping agent "${agent.name}": sanitized name "${safeName}" collides with another agent`)
continue
}
seenAgents.add(safeName)
const agentFileName = `${safeName}.md`
if (agentsEscaped) {
preservedAgentNames.add(agentFileName)
continue
}
const targetPath = path.join(openCodePaths.agentsDir, agentFileName)
const preserved = await cleanupCurrentManagedFile(targetPath, manifest, "agents", agentFileName)
if (preserved) {
preservedAgentNames.add(agentFileName)
continue
}
await writeText(targetPath, agent.content + "\n")
}
const preservedCommandNames = new Set<string>()
for (const commandFile of bundle.commandFiles) {
const commandName = `${commandNameToRelativePath(commandFile.name)}.md`
if (commandsEscaped) {
preservedCommandNames.add(commandName)
continue
}
const dest = path.join(openCodePaths.commandDir, ...commandName.split("/"))
const preserved = await cleanupCurrentManagedFile(dest, manifest, "commands", commandName)
if (preserved) {
preservedCommandNames.add(commandName)
continue
}
const cmdBackupPath = await backupFile(dest)
if (cmdBackupPath) {
console.log(`Backed up existing command file to ${cmdBackupPath}`)
}
await writeText(dest, commandFile.content + "\n")
}
const preservedPluginNames = new Set<string>()
if (bundle.plugins.length > 0) {
for (const plugin of bundle.plugins) {
if (pluginsEscaped) {
preservedPluginNames.add(plugin.name)
continue
}
const targetPath = path.join(openCodePaths.pluginsDir, plugin.name)
const preserved = await cleanupCurrentManagedFile(targetPath, manifest, "plugins", plugin.name)
if (preserved) {
preservedPluginNames.add(plugin.name)
continue
}
await writeText(targetPath, plugin.content + "\n")
}
}
const preservedSkillNames = new Set<string>()
if (bundle.skillDirs.length > 0) {
for (const skill of bundle.skillDirs) {
const skillName = sanitizePathName(skill.name)
if (skillsEscaped) {
preservedSkillNames.add(skillName)
continue
}
const targetDir = path.join(openCodePaths.skillsDir, skillName)
const preserved = await cleanupCurrentManagedDirectory(targetDir, manifest, "skills", skillName)
if (preserved) {
preservedSkillNames.add(skillName)
continue
}
await copySkillDir(
skill.sourceDir,
targetDir,
transformSkillContentForOpenCode,
true,
)
}
}
if (pluginName) {
// Preserved agents/commands/plugins/skills (user symlinks or unmanaged
// dirs/files this install skipped) must not be recorded as owned -- the
// plugin never claims a path it didn't write.
await writeManagedInstallManifest(openCodePaths.managedDir, {
version: 1,
pluginName,
groups: {
agents: currentAgents.filter((name) => !preservedAgentNames.has(name)),
commands: currentCommands.filter((name) => !preservedCommandNames.has(name)),
plugins: currentPlugins.filter((name) => !preservedPluginNames.has(name)),
skills: currentSkills.filter((name) => !preservedSkillNames.has(name)),
},
})
await archiveLegacyInstallManifestIfOwned(openCodePaths.managedDir, pluginName)
await cleanupKnownLegacyOpenCodeArtifacts(openCodePaths, bundle)
}
}
function resolveOpenCodePaths(outputRoot: string, pluginName?: string, scope?: string) {
// Namespace the managed install directory per plugin so multiple plugins
// installed into the same OpenCode root do not share (and overwrite) each
// other's install manifests. `resolveManagedSegment` falls back to the
// legacy "compound-engineering" segment when no plugin name is supplied.
const managedSegment = resolveManagedSegment(pluginName)
const base = path.basename(outputRoot)
// Global layout: explicit scope="global" (from OPENCODE_CONFIG_DIR or the XDG
// default), or a basename that matches OpenCode's conventional roots.
// Project layout: nested under ".opencode/".
const isGlobal = scope === "global" || base === "opencode" || base === ".opencode"
if (isGlobal) {
return {
root: outputRoot,
managedDir: path.join(outputRoot, managedSegment),
configPath: path.join(outputRoot, "opencode.json"),
agentsDir: path.join(outputRoot, "agents"),
pluginsDir: path.join(outputRoot, "plugins"),
skillsDir: path.join(outputRoot, "skills"),
commandDir: path.join(outputRoot, "commands"),
}
}
return {
root: outputRoot,
managedDir: path.join(outputRoot, ".opencode", managedSegment),
configPath: path.join(outputRoot, "opencode.json"),
agentsDir: path.join(outputRoot, ".opencode", "agents"),
pluginsDir: path.join(outputRoot, ".opencode", "plugins"),
skillsDir: path.join(outputRoot, ".opencode", "skills"),
commandDir: path.join(outputRoot, ".opencode", "commands"),
}
}
async function cleanupKnownLegacyOpenCodeArtifacts(
paths: ReturnType<typeof resolveOpenCodePaths>,
bundle: OpenCodeBundle,
): Promise<void> {
const legacyArtifacts = getLegacyOpenCodeArtifacts(bundle)
for (const skillName of legacyArtifacts.skills) {
const legacySkillPath = path.join(paths.skillsDir, skillName)
if (!(await isLegacySkillArtifactOwned(legacySkillPath, skillName))) continue
await moveLegacyArtifactToBackup(paths.managedDir, "skills", paths.skillsDir, skillName, "OpenCode skill")
}
for (const commandPath of legacyArtifacts.commands) {
await moveLegacyArtifactToBackup(paths.managedDir, "commands", paths.commandDir, commandPath, "OpenCode command")
}
for (const agentPath of legacyArtifacts.agents) {
const legacyAgentPath = path.join(paths.agentsDir, agentPath)
if (!(await isLegacyAgentArtifactOwned(legacyAgentPath, path.basename(agentPath, ".md"), ".md"))) continue
await moveLegacyArtifactToBackup(paths.managedDir, "agents", paths.agentsDir, agentPath, "OpenCode agent")
}
}
+590
View File
@@ -0,0 +1,590 @@
import fs from "fs/promises"
import path from "path"
import {
backupFile,
copySkillDir,
ensureDir,
isSafeManagedPath,
pathExists,
readText,
sanitizePathName,
writeJson,
writeText,
} from "../utils/files"
import { transformContentForPi } from "../converters/claude-to-pi"
import type { PiBundle } from "../types/pi"
import { getLegacyPiArtifacts } from "../data/plugin-legacy-artifacts"
import { cleanupStaleAgents, isLegacyAgentArtifactOwned, isLegacySkillArtifactOwned } from "../utils/legacy-cleanup"
import { isPathWithinRoot, isPreservedSymlink, lstatOrNull, resolveLegacyManagedDir, resolveManagedSegment, storeRootEscapesManagedRoot } from "./managed-artifacts"
const PI_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND PI TOOL MAP -->"
const PI_AGENTS_BLOCK_END = "<!-- END COMPOUND PI TOOL MAP -->"
const PI_INSTALL_MANIFEST = "install-manifest.json"
const PI_AGENTS_BLOCK_BODY = `## Compound Engineering (Pi compatibility)
This block is managed by compound-plugin.
Required Pi companion for multi-agent CE workflows:
- \`pi-subagents\` provides the subagent primitive used by ce-code-review, ce-doc-review, ce-plan, and ce-work
Recommended Pi companion:
- \`pi-ask-user\` (by edlsh) provides the \`ask_user\` tool; skills fall back to numbered options in chat when it is missing
Install with:
pi install npm:pi-subagents
pi install npm:pi-ask-user
`
export type PiInstallManifest = {
version: 1
pluginName: string
skills: string[]
prompts: string[]
extensions: string[]
// Added in v2.69+. Older manifests omit this; reads default to [].
agents: string[]
}
type PiPaths = {
managedDir: string
skillsDir: string
promptsDir: string
extensionsDir: string
agentsDir: string
mcporterConfigPath: string
agentsPath: string
}
export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promise<void> {
const pluginName = bundle.pluginName ? sanitizeCodexPathComponent(bundle.pluginName) : undefined
const paths = resolvePiPaths(outputRoot, pluginName)
const manifest = pluginName
? await readInstallManifestWithLegacyFallback(paths, pluginName)
: null
const currentPrompts = bundle.prompts.map((prompt) => `${sanitizePathName(prompt.name)}.md`)
const currentSkills = [
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
]
const currentAgents = bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.md`)
const currentExtensions = bundle.extensions.map((extension) => extension.name)
// Ancestor-symlink containment: the per-entry guards below inspect only the
// leaf node, so a symlinked store dir (or a symlinked ancestor of it) pointed
// at a user fork would otherwise have every cleanup/write act through the link
// into the fork. The Pi stores are siblings under one parent, so that parent
// is the containment root; a store escaping it is skipped and owns nothing.
const piRoot = path.dirname(paths.skillsDir)
const skillsEscaped = await storeRootEscapesManagedRoot(piRoot, paths.skillsDir)
const promptsEscaped = await storeRootEscapesManagedRoot(piRoot, paths.promptsDir)
const extensionsEscaped = await storeRootEscapesManagedRoot(piRoot, paths.extensionsDir)
const agentsEscaped = await storeRootEscapesManagedRoot(piRoot, paths.agentsDir)
if (!skillsEscaped) await ensureDir(paths.skillsDir)
if (!promptsEscaped) await ensureDir(paths.promptsDir)
if (!extensionsEscaped) await ensureDir(paths.extensionsDir)
if (!agentsEscaped) await ensureDir(paths.agentsDir)
if (!skillsEscaped) await cleanupStaleAgents(paths.skillsDir, null)
if (!promptsEscaped) await cleanupRemovedPrompts(paths.promptsDir, manifest, currentPrompts)
if (!skillsEscaped) await cleanupRemovedSkills(paths.skillsDir, manifest, currentSkills)
if (!agentsEscaped) await cleanupRemovedAgents(paths.agentsDir, manifest, currentAgents)
if (!extensionsEscaped) await cleanupRemovedExtensions(paths.extensionsDir, manifest, currentExtensions)
if (!promptsEscaped) {
for (const prompt of bundle.prompts) {
await writeText(path.join(paths.promptsDir, `${sanitizePathName(prompt.name)}.md`), prompt.content + "\n")
}
}
const preservedSkillNames = new Set<string>()
for (const skill of bundle.skillDirs) {
const skillName = sanitizePathName(skill.name)
if (skillsEscaped) {
preservedSkillNames.add(skillName)
continue
}
const targetDir = path.join(paths.skillsDir, skillName)
const preserved = await cleanupCurrentManagedSkillDir(targetDir, manifest, skillName)
if (preserved) {
preservedSkillNames.add(skillName)
continue
}
await copySkillDir(skill.sourceDir, targetDir, transformContentForPi)
}
for (const skill of bundle.generatedSkills) {
const skillName = sanitizePathName(skill.name)
if (skillsEscaped) {
preservedSkillNames.add(skillName)
continue
}
const targetDir = path.join(paths.skillsDir, skillName)
const preserved = await cleanupCurrentManagedSkillDir(targetDir, manifest, skillName)
if (preserved) {
preservedSkillNames.add(skillName)
continue
}
await writeText(path.join(targetDir, "SKILL.md"), skill.content + "\n")
}
const preservedAgentNames = new Set<string>()
for (const agent of bundle.agents) {
const agentFileName = `${sanitizePathName(agent.name)}.md`
if (agentsEscaped) {
preservedAgentNames.add(agentFileName)
continue
}
const targetPath = path.join(paths.agentsDir, agentFileName)
const preserved = await cleanupCurrentManagedAgentFile(targetPath, manifest, agentFileName)
if (preserved) {
preservedAgentNames.add(agentFileName)
continue
}
await writeText(targetPath, agent.content + "\n")
}
if (!extensionsEscaped) {
for (const extension of bundle.extensions) {
await writeText(path.join(paths.extensionsDir, extension.name), extension.content + "\n")
}
}
if (bundle.mcporterConfig) {
const backupPath = await backupFile(paths.mcporterConfigPath)
if (backupPath) {
console.log(`Backed up existing MCPorter config to ${backupPath}`)
}
await writeJson(paths.mcporterConfigPath, bundle.mcporterConfig)
}
await ensurePiAgentsBlock(paths.agentsPath)
if (pluginName) {
// Preserved skills/agents (user symlinks or unmanaged dirs/files this
// install skipped) must not be recorded as owned -- the plugin never
// claims a path it didn't write.
await writeInstallManifest(paths.managedDir, {
version: 1,
pluginName,
skills: currentSkills.filter((name) => !preservedSkillNames.has(name)),
prompts: promptsEscaped ? [] : currentPrompts,
extensions: extensionsEscaped ? [] : currentExtensions,
agents: currentAgents.filter((name) => !preservedAgentNames.has(name)),
})
await archiveLegacyInstallManifestIfOwned(paths.managedDir, pluginName)
await cleanupKnownLegacyPiArtifacts(paths, bundle)
}
}
function resolvePiPaths(outputRoot: string, pluginName?: string): PiPaths {
// Namespace the managed install directory per plugin so multiple plugins
// installed into the same Pi root do not share (and overwrite) each other's
// install manifests. `resolveManagedSegment` falls back to the legacy
// "compound-engineering" segment when no plugin name is supplied.
const managedSegment = resolveManagedSegment(pluginName)
const base = path.basename(outputRoot)
if (base === "agent") {
return {
managedDir: path.join(outputRoot, managedSegment),
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
agentsDir: path.join(outputRoot, "agents"),
mcporterConfigPath: path.join(outputRoot, managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
if (base === ".pi") {
return {
managedDir: path.join(outputRoot, managedSegment),
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
agentsDir: path.join(outputRoot, "agents"),
mcporterConfigPath: path.join(outputRoot, managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
return {
managedDir: path.join(outputRoot, ".pi", managedSegment),
skillsDir: path.join(outputRoot, ".pi", "skills"),
promptsDir: path.join(outputRoot, ".pi", "prompts"),
extensionsDir: path.join(outputRoot, ".pi", "extensions"),
agentsDir: path.join(outputRoot, ".pi", "agents"),
mcporterConfigPath: path.join(outputRoot, ".pi", managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
async function ensurePiAgentsBlock(filePath: string): Promise<void> {
const block = buildPiAgentsBlock()
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 buildPiAgentsBlock(): string {
return [PI_AGENTS_BLOCK_START, PI_AGENTS_BLOCK_BODY.trim(), PI_AGENTS_BLOCK_END].join("\n")
}
function upsertBlock(existing: string, block: string): string {
const startIndex = existing.indexOf(PI_AGENTS_BLOCK_START)
const endIndex = existing.indexOf(PI_AGENTS_BLOCK_END)
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
const before = existing.slice(0, startIndex).trimEnd()
const after = existing.slice(endIndex + PI_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"
}
function sanitizeCodexPathComponent(name: string): string {
return sanitizePathName(name).replace(/[\\/]/g, "-")
}
export async function readPiInstallManifest(
managedDir: string,
pluginName: string,
paths?: PiPaths,
): Promise<PiInstallManifest | null> {
return readInstallManifest(managedDir, pluginName, paths)
}
async function readInstallManifestWithLegacyFallback(
paths: PiPaths,
pluginName: string,
): Promise<PiInstallManifest | null> {
const current = await readInstallManifest(paths.managedDir, pluginName, paths)
if (current) return current
const legacyDir = resolveLegacyManagedDir(paths.managedDir, pluginName)
if (!legacyDir) return null
return readInstallManifest(legacyDir, pluginName, paths)
}
/**
* After the plugin-scoped Pi manifest is written, archive the legacy
* shared Pi manifest if it belongs to the current plugin so the legacy
* path doesn't keep shadowing a future install. No-op when the legacy
* manifest is missing or owned by a different plugin (that plugin's
* own next install will migrate it).
*/
async function archiveLegacyInstallManifestIfOwned(
managedDir: string,
pluginName: string,
): Promise<void> {
const legacyDir = resolveLegacyManagedDir(managedDir, pluginName)
if (!legacyDir) return
const legacyManifestPath = path.join(legacyDir, PI_INSTALL_MANIFEST)
if (!(await pathExists(legacyManifestPath))) return
const owned = await readInstallManifest(legacyDir, pluginName)
if (!owned) return
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupPath = path.join(managedDir, "legacy-backup", timestamp, PI_INSTALL_MANIFEST)
await ensureDir(path.dirname(backupPath))
await fs.rename(legacyManifestPath, backupPath)
console.warn(`Moved legacy Pi install manifest to ${backupPath}`)
}
async function readInstallManifest(
managedDir: string,
pluginName: string,
paths?: PiPaths,
): Promise<PiInstallManifest | null> {
const manifestPath = path.join(managedDir, PI_INSTALL_MANIFEST)
try {
const raw = await readText(manifestPath)
const parsed = JSON.parse(raw) as Partial<PiInstallManifest>
if (
parsed.version === 1 &&
parsed.pluginName === pluginName &&
Array.isArray(parsed.skills) &&
Array.isArray(parsed.prompts) &&
Array.isArray(parsed.extensions)
) {
// Filter manifest entries at read time. Cleanup functions join these
// strings into `fs.rm` paths against the Pi skills/prompts/extensions/agents
// directories, so a tampered or corrupted `install-manifest.json` with
// entries like `../../config.toml` or `/etc/passwd` would otherwise
// delete outside the Pi managed tree. Validate each group against the
// specific cleanup root it will be joined with; fall back to
// `managedDir` when no `PiPaths` context is supplied (e.g. an
// ownership-only read), which still rejects absolute paths and `..`
// segments and provides containment against *some* root.
const skillsRoot = paths?.skillsDir ?? managedDir
const promptsRoot = paths?.promptsDir ?? managedDir
const extensionsRoot = paths?.extensionsDir ?? managedDir
const agentsRoot = paths?.agentsDir ?? managedDir
// `agents` was added in v2.69+; accept missing/omitted to stay
// backward-compatible with v2.x manifests that only tracked skills,
// prompts, and extensions. Drop non-array values defensively.
const rawAgents = Array.isArray(parsed.agents) ? parsed.agents : []
return {
version: 1,
pluginName,
skills: filterSafePiManifestEntries(parsed.skills, skillsRoot, manifestPath, "skills"),
prompts: filterSafePiManifestEntries(parsed.prompts, promptsRoot, manifestPath, "prompts"),
extensions: filterSafePiManifestEntries(parsed.extensions, extensionsRoot, manifestPath, "extensions"),
agents: filterSafePiManifestEntries(rawAgents, agentsRoot, manifestPath, "agents"),
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn(`Ignoring unreadable Pi install manifest at ${manifestPath}.`)
}
}
return null
}
function filterSafePiManifestEntries(
entries: unknown[],
rootDir: string,
manifestPath: string,
group: string,
): string[] {
const safe: string[] = []
for (const entry of entries) {
if (isSafeManagedPath(rootDir, entry)) {
safe.push(entry)
} else {
console.warn(
`Dropping unsafe Pi install-manifest entry in ${manifestPath} (group "${group}"): ${JSON.stringify(entry)}`,
)
}
}
return safe
}
async function writeInstallManifest(managedDir: string, manifest: PiInstallManifest): Promise<void> {
await writeJson(path.join(managedDir, PI_INSTALL_MANIFEST), manifest)
}
async function cleanupRemovedSkills(
skillsDir: string,
manifest: PiInstallManifest | null,
currentSkills: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentSkills)
for (const skillName of manifest.skills) {
if (current.has(skillName)) continue
// Defense in depth: `readInstallManifest` already drops unsafe entries,
// but re-check before any out-of-tree fs.rm can be issued from a future
// caller that bypasses the read layer.
if (!isSafeManagedPath(skillsDir, skillName)) continue
const targetDir = path.join(skillsDir, skillName)
// The manifest can lag reality: a prior install owned this name, but the
// user has since replaced it with a symlink (e.g. into a personal fork).
// Never delete through a symlink node even when the stale manifest still
// claims ownership.
if (await isPreservedSymlink(targetDir)) continue
await fs.rm(targetDir, { recursive: true, force: true })
}
}
async function cleanupRemovedPrompts(
promptsDir: string,
manifest: PiInstallManifest | null,
currentPrompts: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentPrompts)
for (const promptFile of manifest.prompts) {
if (current.has(promptFile)) continue
if (!isSafeManagedPath(promptsDir, promptFile)) continue
await fs.rm(path.join(promptsDir, promptFile), { force: true })
}
}
async function cleanupRemovedExtensions(
extensionsDir: string,
manifest: PiInstallManifest | null,
currentExtensions: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentExtensions)
for (const extensionFile of manifest.extensions) {
if (current.has(extensionFile)) continue
if (!isSafeManagedPath(extensionsDir, extensionFile)) continue
await fs.rm(path.join(extensionsDir, extensionFile), { force: true })
}
}
async function cleanupRemovedAgents(
agentsDir: string,
manifest: PiInstallManifest | null,
currentAgents: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentAgents)
for (const agentFile of manifest.agents) {
if (current.has(agentFile)) continue
if (!isSafeManagedPath(agentsDir, agentFile)) continue
const targetPath = path.join(agentsDir, agentFile)
if (await isPreservedSymlink(targetPath)) continue
await fs.rm(targetPath, { force: true })
}
}
// Returns true when the existing path was preserved (skip cleanup AND the
// subsequent copy/write -- writing through a preserved symlink would clobber
// the user's fork, which is worse than not cleaning up at all).
async function cleanupCurrentManagedSkillDir(
targetDir: string,
manifest: PiInstallManifest | null,
skillName: string,
): Promise<boolean> {
const stat = await lstatOrNull(targetDir)
if (!stat) return false
if (stat.isSymbolicLink()) {
console.warn(`Skipping ${targetDir}: existing user-managed symlink (not overwritten)`)
return true
}
if (!manifest?.skills.includes(skillName)) {
console.warn(`Skipping ${targetDir}: existing unmanaged directory (not overwritten)`)
return true
}
await fs.rm(targetDir, { recursive: true, force: true })
return false
}
async function cleanupCurrentManagedAgentFile(
targetPath: string,
manifest: PiInstallManifest | null,
agentFileName: string,
): Promise<boolean> {
const stat = await lstatOrNull(targetPath)
if (!stat) return false
if (stat.isSymbolicLink()) {
console.warn(`Skipping ${targetPath}: existing user-managed symlink (not overwritten)`)
return true
}
if (!manifest?.agents.includes(agentFileName)) {
console.warn(`Skipping ${targetPath}: existing unmanaged file (not overwritten)`)
return true
}
await fs.rm(targetPath, { force: true })
return false
}
// Explicit legacy Pi extension names this plugin has historically shipped and
// no longer does. The manifest-diff cleanup in cleanupRemovedExtensions handles
// post-manifest installs automatically, but pre-manifest installs return null
// from readInstallManifestWithLegacyFallback and would otherwise leak the file
// on upgrade. This list is the safety net for that case.
const LEGACY_PI_EXTENSIONS_BY_PLUGIN: Record<string, string[]> = {
"compound-engineering": ["compound-engineering-compat.ts"],
}
// Plugins that historically shipped an mcporter.json (via the now-removed
// compat extension) but no longer do when `bundle.mcporterConfig` is absent.
// The per-plugin guard keeps us from touching mcporter configs owned by
// plugins that still legitimately emit one.
const LEGACY_PI_MCPORTER_PLUGINS = new Set<string>(["compound-engineering"])
type LegacyArtifactKind = "skills" | "prompts" | "extensions" | "agents" | "mcporter"
// Display label used in the "Moved legacy Pi <label> artifact ..." log line.
// Most kinds are a simple plural→singular trim, but "mcporter" isn't a plural,
// so we special-case it instead of slicing off a character and logging
// "mcporte".
const LEGACY_ARTIFACT_LABELS: Record<LegacyArtifactKind, string> = {
skills: "skill",
prompts: "prompt",
extensions: "extension",
agents: "agent",
mcporter: "mcporter config",
}
async function cleanupKnownLegacyPiArtifacts(paths: PiPaths, bundle: PiBundle): Promise<void> {
const pluginName = bundle.pluginName
if (!pluginName) return
const legacyArtifacts = getLegacyPiArtifacts(bundle)
for (const skillName of legacyArtifacts.skills) {
const legacySkillPath = path.join(paths.skillsDir, skillName)
if (!(await isLegacySkillArtifactOwned(legacySkillPath, skillName))) continue
await moveLegacyArtifactToBackup(paths.managedDir, "skills", legacySkillPath)
}
for (const promptFile of legacyArtifacts.prompts) {
const legacyPromptPath = path.join(paths.promptsDir, promptFile)
await moveLegacyArtifactToBackup(paths.managedDir, "prompts", legacyPromptPath)
}
for (const agentFile of legacyArtifacts.agents ?? []) {
const legacyAgentPath = path.join(paths.agentsDir, agentFile)
if (await isLegacyAgentArtifactOwned(legacyAgentPath, path.basename(agentFile, ".md"), ".md")) {
await moveLegacyArtifactToBackup(paths.managedDir, "agents", legacyAgentPath)
}
}
// Only sweep legacy extensions the current bundle is not actively writing.
// A caller that explicitly ships an extension (e.g., tests or a future
// bundle that reintroduces one) must not have its write undone.
const currentExtensionNames = new Set(bundle.extensions.map((extension) => extension.name))
for (const extensionFile of LEGACY_PI_EXTENSIONS_BY_PLUGIN[pluginName] ?? []) {
if (currentExtensionNames.has(extensionFile)) continue
const legacyExtensionPath = path.join(paths.extensionsDir, extensionFile)
await moveLegacyArtifactToBackup(paths.managedDir, "extensions", legacyExtensionPath)
}
// Sweep the stale mcporter.json left behind by the removed compat extension.
// Only runs when the current bundle is NOT writing a fresh mcporter config —
// if it IS (e.g. a plugin with `mcpServers`), the existing write path backs
// up and overwrites the file and this sweep would undo that write.
if (!bundle.mcporterConfig && LEGACY_PI_MCPORTER_PLUGINS.has(pluginName)) {
await moveLegacyArtifactToBackup(paths.managedDir, "mcporter", paths.mcporterConfigPath)
}
}
async function moveLegacyArtifactToBackup(
managedDir: string,
kind: LegacyArtifactKind,
artifactPath: string,
): Promise<void> {
// Ownership fingerprinting reads THROUGH a symlink, so a user fork of a
// legacy-named artifact still matches — never move the symlink node into
// legacy-backup, or the user's override is silently deactivated.
if (await isPreservedSymlink(artifactPath)) return
// Ancestor containment: skip when a symlinked ancestor (e.g. the whole store
// dir) resolves the artifact outside the Pi root, into a user fork. The store
// dirs are siblings of managedDir, so managedDir's parent is that root.
if (!(await isPathWithinRoot(path.dirname(managedDir), artifactPath))) return
if (!(await pathExists(artifactPath))) return
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupDir = path.join(managedDir, "legacy-backup", timestamp, kind)
const backupPath = path.join(backupDir, path.basename(artifactPath))
await ensureDir(backupDir)
await fs.rename(artifactPath, backupPath)
console.warn(`Moved legacy Pi ${LEGACY_ARTIFACT_LABELS[kind]} artifact to ${backupPath}`)
}
export {
cleanupRemovedSkills as cleanupRemovedPiSkills,
cleanupRemovedPrompts as cleanupRemovedPiPrompts,
cleanupRemovedExtensions as cleanupRemovedPiExtensions,
cleanupRemovedAgents as cleanupRemovedPiAgents,
}
+50
View File
@@ -0,0 +1,50 @@
export type AntigravitySkill = {
name: string
content: string // Full SKILL.md with YAML frontmatter
}
export type AntigravitySkillDir = {
name: string
sourceDir: string
}
export type AntigravityCommand = {
name: string // e.g. "plan" or "workflows/plan"
content: string // Full TOML content (agy converts commands to skills on install)
}
export type AntigravityAgent = {
name: string
content: string // Full agent Markdown file with YAML frontmatter
}
// Antigravity MCP servers use `serverUrl` for remote servers, not `url`.
// Verified against agy v1.0.10: a remote server with `url` is rejected with
// "must have either command or serverUrl".
export type AntigravityMcpServer = {
command?: string
args?: string[]
env?: Record<string, string>
serverUrl?: string
headers?: Record<string, string>
}
// Minimal manifest agy requires at the bundle root. { name, version } is
// sufficient to validate; other fields are optional.
export type AntigravityPluginManifest = {
name: string
version: string
}
export type AntigravityBundle = {
pluginName?: string
version: string
generatedSkills: AntigravitySkill[] // Target-specific generated skills, if any
skillDirs: AntigravitySkillDir[] // From skills (pass-through)
agents?: AntigravityAgent[] // From Claude agents
commands: AntigravityCommand[]
mcpServers?: Record<string, AntigravityMcpServer>
// Container-only passthrough of Claude hooks. The per-event schema is not yet
// verified against agy, so callers must not assume per-event fidelity.
hooks?: Record<string, unknown>
}
+101
View File
@@ -0,0 +1,101 @@
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[]
disableModelInvocation?: boolean
body: string
sourcePath: string
}
export type ClaudeSkill = {
name: string
description?: string
argumentHint?: string
disableModelInvocation?: boolean
userInvocable?: boolean
ce_platforms?: string[]
sourceDir: string
skillPath: string
}
/**
* Filter skills to those available on a given platform.
* Skills without a `platforms` field are available everywhere.
*/
export function filterSkillsByPlatform(skills: ClaudeSkill[], platform: string): ClaudeSkill[] {
return skills.filter((skill) => !skill.ce_platforms || skill.ce_platforms.includes(platform))
}
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[]>
}
+50
View File
@@ -0,0 +1,50 @@
import type { ClaudeMcpServer, ClaudeHooks } from "./claude"
import type { CodexInvocationTargets } from "../utils/codex-content"
export type CodexPrompt = {
name: string
content: string
}
export type CodexSkillDir = {
name: string
sourceDir: string
}
export type CodexGeneratedSkill = {
name: string
content: string
sidecarDirs?: CodexGeneratedSkillSidecarDir[]
}
export type CodexGeneratedSkillSidecarDir = {
sourceDir: string
targetName: string
}
export type CodexAgent = {
name: string
description: string
instructions: string
sidecarDirs?: CodexGeneratedSkillSidecarDir[]
}
export type CodexBundle = {
pluginName?: string
prompts: CodexPrompt[]
skillDirs: CodexSkillDir[]
generatedSkills: CodexGeneratedSkill[]
agents?: CodexAgent[]
invocationTargets?: CodexInvocationTargets
mcpServers?: Record<string, ClaudeMcpServer>
hooks?: ClaudeHooks
/**
* Names of skills CE owns in the Codex managed tree that are NOT written by
* this bundle. Used in agents-only installs (default `--to codex`) where
* skill contents are installed via Codex's native plugin flow, but cleanup
* still needs to recognize those skill names as "current" (and therefore
* not legacy) when re-running the install. Entries are sanitized skill
* names (same shape as `skillDirs[].name` after `sanitizePathName`).
*/
externallyManagedSkillNames?: string[]
}
+32
View File
@@ -0,0 +1,32 @@
export type CopilotAgent = {
name: string
content: string
}
export type CopilotGeneratedSkill = {
name: string
content: string
}
export type CopilotSkillDir = {
name: string
sourceDir: string
}
export type CopilotMcpServer = {
type: string
command?: string
args?: string[]
url?: string
tools: string[]
env?: Record<string, string>
headers?: Record<string, string>
}
export type CopilotBundle = {
pluginName?: string
agents: CopilotAgent[]
generatedSkills: CopilotGeneratedSkill[]
skillDirs: CopilotSkillDir[]
mcpConfig?: Record<string, CopilotMcpServer>
}
+21
View File
@@ -0,0 +1,21 @@
export type DroidCommandFile = {
name: string
content: string
}
export type DroidAgentFile = {
name: string
content: string
}
export type DroidSkillDir = {
name: string
sourceDir: string
}
export type DroidBundle = {
pluginName?: string
commands: DroidCommandFile[]
droids: DroidAgentFile[]
skillDirs: DroidSkillDir[]
}
+47
View File
@@ -0,0 +1,47 @@
export type KiroAgent = {
name: string
config: KiroAgentConfig
promptContent: string
}
export type KiroAgentConfig = {
name: string
description: string
prompt: `file://${string}`
tools: ["*"]
resources: string[]
includeMcpJson: true
welcomeMessage?: string
}
export type KiroSkill = {
name: string
content: string // Full SKILL.md with YAML frontmatter
}
export type KiroSkillDir = {
name: string
sourceDir: string
}
export type KiroSteeringFile = {
name: string
content: string
}
export type KiroMcpServer = {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}
export type KiroBundle = {
pluginName?: string
agents: KiroAgent[]
generatedSkills: KiroSkill[]
skillDirs: KiroSkillDir[]
steeringFiles: KiroSteeringFile[]
mcpServers: Record<string, KiroMcpServer>
}
+72
View File
@@ -0,0 +1,72 @@
export type OpenCodePermission = "allow" | "ask" | "deny"
export type OpenCodeConfig = {
$schema?: string
model?: string
default_agent?: string
/** @deprecated OpenCode v1.1.1+ uses permission as the canonical control surface. */
tools?: Record<string, boolean>
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
agent?: Record<string, OpenCodeAgentConfig>
mcp?: Record<string, OpenCodeMcpServer>
skills?: OpenCodeSkillsConfig
}
export type OpenCodeAgentConfig = {
description?: string
mode?: "primary" | "subagent" | "all"
model?: string
variant?: string
temperature?: number
top_p?: number
prompt?: string
disable?: boolean
hidden?: boolean
color?: string
steps?: number
/** @deprecated Use steps instead. */
maxSteps?: number
options?: Record<string, unknown>
/** @deprecated OpenCode v1.1.1+ uses permission as the canonical control surface. */
tools?: Record<string, boolean>
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
}
export type OpenCodeSkillsConfig = {
paths?: string[]
urls?: 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 OpenCodeCommandFile = {
name: string
content: string
}
export type OpenCodeBundle = {
pluginName?: string
config: OpenCodeConfig
agents: OpenCodeAgentFile[]
// Commands are written as individual .md files, not in opencode.json. See ADR-001.
commandFiles: OpenCodeCommandFile[]
plugins: OpenCodePluginFile[]
skillDirs: { sourceDir: string; name: string }[]
}
+47
View File
@@ -0,0 +1,47 @@
export type PiPrompt = {
name: string
content: string
}
export type PiSkillDir = {
name: string
sourceDir: string
}
export type PiGeneratedSkill = {
name: string
content: string
}
export type PiGeneratedAgent = {
name: string
content: string
}
export type PiExtensionFile = {
name: string
content: string
}
export type PiMcporterServer = {
description?: string
baseUrl?: string
command?: string
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
}
export type PiMcporterConfig = {
mcpServers: Record<string, PiMcporterServer>
}
export type PiBundle = {
pluginName?: string
prompts: PiPrompt[]
skillDirs: PiSkillDir[]
generatedSkills: PiGeneratedSkill[]
agents: PiGeneratedAgent[]
extensions: PiExtensionFile[]
mcporterConfig?: PiMcporterConfig
}
+65
View File
@@ -0,0 +1,65 @@
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: present choices as a numbered list in chat and wait for a reply number. For multi-select (multiSelect: true), accept comma-separated numbers. Never skip or auto-configure — always wait for the user's response before proceeding.
- Task (subagent dispatch) / Subagent / Parallel: run sequentially in main thread; use multi_tool_use.parallel for tool calls
- TaskCreate/TaskUpdate/TaskList/TaskGet/TaskStop/TaskOutput (Claude Code task-tracking, current): use update_plan (Codex's task-tracking primitive)
- TodoWrite/TodoRead (Claude Code task-tracking, legacy — deprecated, replaced by Task* tools): use update_plan
- 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"
}
+118
View File
@@ -0,0 +1,118 @@
export type CodexInvocationTargets = {
promptTargets: Record<string, string>
skillTargets: Record<string, string>
agentTargets?: Record<string, string>
}
export type CodexTransformOptions = {
unknownSlashBehavior?: "prompt" | "preserve"
}
/**
* Transform Claude Code content to Codex-compatible content.
*
* Handles multiple syntax differences:
* 1. Task agent calls: Task agent-name(args) -> Use the $agent-name skill to: args
* 2. Slash command references:
* - known prompt entrypoints -> /prompts:prompt-name
* - known skills -> the exact skill name
* - unknown slash refs -> /prompts:command-name
* 3. Agent references: @agent-name -> $agent-name skill
* 4. Claude config paths: .claude/ -> .codex/
*/
export function transformContentForCodex(
body: string,
targets?: CodexInvocationTargets,
options: CodexTransformOptions = {},
): string {
let result = body
const promptTargets = targets?.promptTargets ?? {}
const skillTargets = targets?.skillTargets ?? {}
const agentTargets = targets?.agentTargets ?? {}
const unknownSlashBehavior = options.unknownSlashBehavior ?? "prompt"
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const agentTarget = resolveAgentTarget(agentName, agentTargets)
const trimmedArgs = args.trim()
if (agentTarget) {
return trimmedArgs
? `${prefix}Spawn the custom agent \`${agentTarget}\` with task: ${trimmedArgs}`
: `${prefix}Spawn the custom agent \`${agentTarget}\``
}
// For namespaced calls like "compound-engineering:research:repo-research-analyst",
// use only the final segment as the skill name when no custom agent target exists.
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
const skillName = normalizeCodexName(finalSegment)
return trimmedArgs
? `${prefix}Use the $${skillName} skill to: ${trimmedArgs}`
: `${prefix}Use the $${skillName} skill`
})
const backtickedAgentPattern = /`([a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*){1,2})`/gi
result = result.replace(backtickedAgentPattern, (match, agentName: string) => {
const agentTarget = resolveAgentTarget(agentName, agentTargets)
return agentTarget ? `custom agent \`${agentTarget}\`` : match
})
const slashCommandPattern = /(?<![:\w>}\]\)])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
if (commandName.includes("/")) return match
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
const normalizedName = normalizeCodexName(commandName)
if (promptTargets[normalizedName]) {
return `/prompts:${promptTargets[normalizedName]}`
}
if (skillTargets[normalizedName]) {
return `the ${skillTargets[normalizedName]} skill`
}
if (unknownSlashBehavior === "preserve") {
return match
}
return `/prompts:${normalizedName}`
})
result = result
.replace(/~\/\.claude\//g, "~/.codex/")
.replace(/\.claude\//g, ".codex/")
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
const agentTarget = resolveAgentTarget(agentName, agentTargets)
if (agentTarget) return `custom agent \`${agentTarget}\``
const skillName = normalizeCodexName(agentName)
return `$${skillName} skill`
})
return result
}
function resolveAgentTarget(value: string, agentTargets: Record<string, string>): string | null {
const parts = value.split(":").filter(Boolean)
const candidates = [
normalizeCodexName(value),
parts.length >= 2 ? normalizeCodexName(parts.slice(-2).join(":")) : "",
parts.length >= 1 ? normalizeCodexName(parts[parts.length - 1]) : "",
].filter(Boolean)
for (const candidate of candidates) {
const target = agentTargets[candidate]
if (target) return target
}
return null
}
export function normalizeCodexName(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"
}
+109
View File
@@ -0,0 +1,109 @@
import os from "os"
import path from "path"
import { pathExists } from "./files"
import { resolveOpenCodeGlobalRoot } from "./opencode-config"
import { resolveCodexHome } from "./resolve-home"
export type DetectedTool = {
name: string
detected: boolean
reason: string
}
type DetectableTool = {
name: string
detectPaths: (home: string, cwd: string, options: DetectPathOptions) => string[]
}
type DetectPathOptions = {
useCodexHomeEnv: boolean
}
const detectableTools: DetectableTool[] = [
{
name: "opencode",
detectPaths: (home, cwd) => {
// Resolve the OpenCode global root through the shared helper so that
// detection agrees with install/cleanup on `OPENCODE_CONFIG_DIR`. When
// the env var is unset, the helper falls back to `os.homedir()`, which
// may differ from the `home` arg threaded through for testability; in
// that case prefer the explicit `home` param so existing callers that
// override it keep working.
const envDir = process.env.OPENCODE_CONFIG_DIR?.trim()
const globalRoot = envDir
? resolveOpenCodeGlobalRoot()
: path.join(home, ".config", "opencode")
return [globalRoot, path.join(cwd, ".opencode")]
},
},
{
name: "codex",
detectPaths: (home, _cwd, options) => {
if (!options.useCodexHomeEnv) return [path.join(home, ".codex")]
const codexHome = resolveCodexHome(undefined)
const defaultCodexHome = path.join(home, ".codex")
return codexHome === defaultCodexHome ? [defaultCodexHome] : [codexHome, defaultCodexHome]
},
},
{
name: "pi",
detectPaths: (home) => [path.join(home, ".pi")],
},
{
name: "droid",
detectPaths: (home) => [path.join(home, ".factory")],
},
{
name: "copilot",
detectPaths: (home, cwd) => [
path.join(home, ".copilot"),
path.join(cwd, ".github", "skills"),
path.join(cwd, ".github", "agents"),
path.join(cwd, ".github", "copilot-instructions.md"),
],
},
{
name: "antigravity",
detectPaths: (home, cwd) => [
path.join(cwd, ".agy"),
path.join(home, ".gemini", "antigravity-cli"),
],
},
{
name: "qwen",
detectPaths: (home, cwd) => [
path.join(home, ".qwen"),
path.join(cwd, ".qwen"),
],
},
]
export async function detectInstalledTools(
home?: string,
cwd: string = process.cwd(),
): Promise<DetectedTool[]> {
const effectiveHome = home ?? os.homedir()
const options = { useCodexHomeEnv: home === undefined }
const results: DetectedTool[] = []
for (const target of detectableTools) {
let detected = false
let reason = "not found"
for (const p of target.detectPaths(effectiveHome, cwd, options)) {
if (await pathExists(p)) {
detected = true
reason = `found ${p}`
break
}
}
results.push({ name: target.name, detected, reason })
}
return results
}
export async function getDetectedTargetNames(
home?: string,
cwd: string = process.cwd(),
): Promise<string[]> {
const tools = await detectInstalledTools(home, cwd)
return tools.filter((t) => t.detected).map((t) => t.name)
}
+205
View File
@@ -0,0 +1,205 @@
import { promises as fs } from "fs"
import path from "path"
export async function backupFile(filePath: string): Promise<string | null> {
if (!(await pathExists(filePath))) return null
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupPath = `${filePath}.bak.${timestamp}`
await fs.copyFile(filePath, backupPath)
return backupPath
} catch {
return null
}
}
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 writeTextSecure(filePath: string, content: string): Promise<void> {
await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 })
await fs.chmod(filePath, 0o600)
}
export async function writeJson(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2)
await writeText(filePath, content + "\n")
}
/** Write JSON with restrictive permissions (0o600) for files containing secrets */
export async function writeJsonSecure(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2)
await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
await fs.chmod(filePath, 0o600)
}
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
}
/**
* Sanitize a name for use as a filesystem path component.
* Replaces colons with hyphens so colon-namespaced names
* (e.g. "ce:brainstorm") become flat directory names ("ce-brainstorm")
* instead of failing on Windows where colons are illegal in filenames.
*/
export function sanitizePathName(name: string): string {
return name.replace(/:/g, "-")
}
/**
* Validate that a manifest-supplied relative path is safe to join against a
* managed root before deleting or moving anything at that location.
*
* Install manifests (`install-manifest.json`) are read back from disk during
* reinstall/cleanup and fed into `fs.rm`/`fs.rename`. An attacker or a
* corrupted file could include entries like `../../config.toml` or
* `/etc/passwd` that would cause the cleanup to operate outside the intended
* managed tree. This helper rejects:
*
* - non-string values
* - empty strings
* - absolute paths (POSIX `/foo`, Windows `C:\foo`)
* - any `..` path segment (including `foo/../bar`)
* - paths that, when joined with `rootDir`, resolve outside `rootDir`
*
* The `rootDir` check is defense-in-depth against edge cases the first two
* checks miss (e.g. platform-specific separators or encoded traversal the
* split-based check didn't catch).
*/
export function isSafeManagedPath(rootDir: string, candidate: unknown): candidate is string {
if (typeof candidate !== "string" || candidate.length === 0) return false
if (path.isAbsolute(candidate)) return false
// Reject any traversal segment (`..`) split on either separator so the
// check is uniform across platforms.
const segments = candidate.split(/[\\/]/)
if (segments.some((segment) => segment === "..")) return false
// Final containment check: the fully-resolved candidate must stay inside
// the resolved root. This catches anything the above two checks missed.
const resolvedRoot = path.resolve(rootDir)
const resolvedCandidate = path.resolve(resolvedRoot, candidate)
if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + path.sep)) {
return false
}
return true
}
/**
* Normalizes a command name to the relative path key used by the OpenCode writer,
* without touching the filesystem, by applying the colon-to-slash convention.
* See also `resolveCommandPath`, which applies the same split but resolves to an
* absolute path and creates intermediate directories on disk.
*
* Example: `"foo:bar"` -> `"foo/bar"`
*/
export function commandNameToRelativePath(name: string): string {
return name.split(":").join("/")
}
/**
* Resolve a colon-separated command name into a filesystem path.
* e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md"
* Creates intermediate directories as needed.
*/
export async function resolveCommandPath(dir: string, name: string, ext: string): Promise<string> {
const parts = name.split(":")
if (parts.length > 1) {
const nestedDir = path.join(dir, ...parts.slice(0, -1))
await ensureDir(nestedDir)
return path.join(nestedDir, `${parts[parts.length - 1]}${ext}`)
}
return path.join(dir, `${name}${ext}`)
}
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)
}
}
}
/**
* Copy a skill directory, optionally transforming markdown content.
* Non-markdown files are copied verbatim. Used by target writers to apply
* platform-specific content transforms to pass-through skills.
*
* By default only SKILL.md is transformed (safe for slash-command rewrites
* that shouldn't touch reference files). Set `transformAllMarkdown` to also
* transform reference .md files — needed when the transform rewrites content
* that appears in reference files (e.g. fully-qualified agent names).
*/
export async function copySkillDir(
sourceDir: string,
targetDir: string,
transformSkillContent?: (content: string) => string,
transformAllMarkdown?: boolean,
): 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 copySkillDir(sourcePath, targetPath, transformSkillContent, transformAllMarkdown)
} else if (entry.isFile()) {
const shouldTransform = transformSkillContent && (
entry.name === "SKILL.md" || (transformAllMarkdown && entry.name.endsWith(".md"))
)
if (shouldTransform) {
const content = await readText(sourcePath)
await writeText(targetPath, transformSkillContent(content))
} else {
await ensureDir(path.dirname(targetPath))
await fs.copyFile(sourcePath, targetPath)
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import { load } from "js-yaml"
export type FrontmatterResult = {
data: Record<string, unknown>
body: string
}
export function parseFrontmatter(raw: string, sourcePath?: 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")
try {
const parsed = load(yamlText)
const data = (parsed && typeof parsed === "object") ? (parsed as Record<string, unknown>) : {}
return { data, body }
} catch (err) {
const location = sourcePath ? ` in ${sourcePath}` : ""
const hint = "Tip: quote frontmatter values containing colons (e.g. description: 'Use for X: Y')"
throw new Error(`Invalid YAML frontmatter${location}: ${err instanceof Error ? err.message : err}\n${hint}`)
}
}
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("{") || raw === "*") {
return JSON.stringify(raw)
}
return raw
}
+47
View File
@@ -0,0 +1,47 @@
import path from "path"
import { pathExists, readJson, writeJsonSecure } from "./files"
type JsonObject = Record<string, unknown>
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export async function mergeJsonConfigAtKey(options: {
configPath: string
key: string
incoming: Record<string, unknown>
}): Promise<void> {
const { configPath, key, incoming } = options
const existing = await readJsonObjectSafe(configPath)
const existingEntries = isJsonObject(existing[key]) ? existing[key] : {}
const merged = {
...existing,
[key]: {
...existingEntries,
...incoming,
},
}
await writeJsonSecure(configPath, merged)
}
async function readJsonObjectSafe(configPath: string): Promise<JsonObject> {
if (!(await pathExists(configPath))) {
return {}
}
try {
const parsed = await readJson<unknown>(configPath)
if (isJsonObject(parsed)) {
return parsed
}
} catch {
// Fall through to warning and replacement.
}
console.warn(
`Warning: existing ${path.basename(configPath)} could not be parsed and will be replaced.`,
)
return {}
}
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
/**
* Shared model normalization utilities for cross-platform conversion.
*
* Claude Code uses bare family aliases (`model: sonnet`) that must be
* resolved differently depending on the target platform.
*/
/**
* Bare Claude family aliases used in Claude Code (e.g. `model: haiku`).
* Maps alias -> canonical model name (without provider prefix).
* Update these when new model generations are released.
*/
export const CLAUDE_FAMILY_ALIASES: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-5",
opus: "claude-opus-4-8",
}
/**
* Canonical Claude model IDs that reject non-default sampling params
* (`temperature`/`top_p`/`top_k`) with HTTP 400. Emitting an inferred
* temperature alongside one of these produces a config that fails at runtime.
* Keep in sync with CLAUDE_FAMILY_ALIASES when new generations are released.
* See the Sonnet 5 and Opus 4.7/4.8 migration notes.
*/
const SAMPLING_PARAM_REJECTING_MODELS: ReadonlySet<string> = new Set([
"claude-sonnet-5",
"claude-opus-4-7",
"claude-opus-4-8",
])
/**
* Resolve a bare Claude family alias to its canonical model name.
* Returns the input unchanged if not a recognized alias.
*
* "sonnet" -> "claude-sonnet-5"
* "claude-sonnet-4-20250514" -> "claude-sonnet-4-20250514" (unchanged)
*/
export function resolveClaudeFamilyAlias(model: string): string {
return CLAUDE_FAMILY_ALIASES[model] ?? model
}
/**
* Add a provider prefix based on model naming conventions.
* Returns the input unchanged if already prefixed (contains "/").
*
* "claude-sonnet-5" -> "anthropic/claude-sonnet-5"
* "gpt-5.6-sol" -> "openai/gpt-5.6-sol"
* "gemini-2.0" -> "google/gemini-2.0"
* "minimax-m3" -> "minimax/minimax-m3"
* "anthropic/foo" -> "anthropic/foo" (unchanged)
*/
export function addProviderPrefix(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}`
if (/^qwen-/.test(model)) return `qwen/${model}`
if (/^minimax-/i.test(model)) return `minimax/${model}`
return `anthropic/${model}`
}
/**
* Normalize a model for targets that use provider-prefixed IDs.
* Resolves bare aliases and adds provider prefix.
*
* "sonnet" -> "anthropic/claude-sonnet-5"
* "claude-sonnet-4-20250514" -> "anthropic/claude-sonnet-4-20250514"
* "anthropic/claude-opus" -> "anthropic/claude-opus" (unchanged)
*/
export function normalizeModelWithProvider(model: string): string {
if (model.includes("/")) return model
const resolved = resolveClaudeFamilyAlias(model)
if (resolved !== model) {
console.warn(
`Warning: bare model alias "${model}" mapped to "anthropic/${resolved}". ` +
`Update CLAUDE_FAMILY_ALIASES if a newer version is available.`,
)
}
return addProviderPrefix(resolved)
}
/**
* Whether a model rejects non-default sampling params. Accepts a bare alias,
* a canonical ID, or a provider-prefixed ID; resolves aliases and strips the
* provider prefix so `sonnet` and `anthropic/claude-sonnet-5` both match.
*
* "sonnet" -> true (resolves to claude-sonnet-5)
* "claude-sonnet-4-20250514" -> false (dated Sonnet 4 accepts sampling params)
*/
export function rejectsSamplingParams(model: string): boolean {
const canonical = resolveClaudeFamilyAlias(model).replace(/^anthropic\//, "")
return SAMPLING_PARAM_REJECTING_MODELS.has(canonical)
}
+25
View File
@@ -0,0 +1,25 @@
import os from "os"
import path from "path"
import { expandHome } from "./resolve-home"
/**
* Resolve the OpenCode global-config root.
*
* Order of precedence:
* 1. `OPENCODE_CONFIG_DIR` environment variable (NixOS, Docker, non-default
* `XDG_CONFIG_HOME` setups).
* 2. `~/.config/opencode` (XDG default).
*
* See: https://opencode.ai/docs/config/
*
* Both `install` and `cleanup` must agree on this resolution so that an
* install at `OPENCODE_CONFIG_DIR=/custom/path` is later cleaned at the same
* location.
*/
export function resolveOpenCodeGlobalRoot(): string {
const envDir = process.env.OPENCODE_CONFIG_DIR?.trim()
if (envDir) {
return path.resolve(expandHome(envDir))
}
return path.join(os.homedir(), ".config", "opencode")
}
+26
View File
@@ -0,0 +1,26 @@
import os from "os"
import path from "path"
export function expandHome(value: string): string {
if (value === "~") return os.homedir()
// Accept the portable "~/" shorthand on every OS in addition to the native
// separator. On Windows `path.sep` is "\\", so a bare `~${path.sep}` check
// left "~/x" (the spelling users and config files commonly write) unexpanded
// and the CLI then treated a literal "~" as a real directory.
if (value.startsWith("~/") || value.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), value.slice(2))
}
return value
}
export function resolveTargetHome(value: unknown, defaultPath: string): string {
if (!value) return defaultPath
const raw = String(value).trim()
if (!raw) return defaultPath
return path.resolve(expandHome(raw))
}
export function resolveCodexHome(value: unknown): string {
const defaultPath = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex")
return resolveTargetHome(value, path.resolve(expandHome(defaultPath)))
}
+49
View File
@@ -0,0 +1,49 @@
import path from "path"
import type { TargetScope } from "../targets"
import { resolveOpenCodeGlobalRoot } from "./opencode-config"
export function resolveTargetOutputRoot(options: {
targetName: string
outputRoot: string
codexHome: string
piHome: string
pluginName?: string
hasExplicitOutput: boolean
scope?: TargetScope
}): string {
const { targetName, outputRoot, codexHome, piHome, hasExplicitOutput } = options
if (targetName === "codex") return codexHome
if (targetName === "pi") return piHome
if (targetName === "antigravity") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".agy")
}
if (targetName === "kiro") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".kiro")
}
if (targetName === "opencode") {
// Without an explicit --output, default to the OpenCode global-config root
// (OPENCODE_CONFIG_DIR or ~/.config/opencode). With an explicit --output,
// honor it as a workspace root and let the writer nest under .opencode/.
if (!hasExplicitOutput) return resolveOpenCodeGlobalRoot()
return outputRoot
}
return outputRoot
}
/**
* Returns "global" when the OpenCode writer should use the flat global-config
* layout (no `.opencode/` nesting). This is the case when the user did not
* pass `--output` and did not pass an explicit `--scope`. Returns the
* caller's requested scope otherwise so explicit `--scope workspace` still
* wins.
*/
export function resolveOpenCodeWriteScope(
hasExplicitOutput: boolean,
requestedScope: TargetScope | undefined,
): TargetScope | undefined {
if (requestedScope !== undefined) return requestedScope
if (!hasExplicitOutput) return "global"
return undefined
}
+24
View File
@@ -0,0 +1,24 @@
export const SENSITIVE_PATTERN = /key|token|secret|password|credential|api_key/i
/** Check if any MCP servers have env vars that might contain secrets */
export function hasPotentialSecrets(
servers: Record<string, { env?: Record<string, string> }>,
): boolean {
for (const server of Object.values(servers)) {
if (server.env) {
for (const key of Object.keys(server.env)) {
if (SENSITIVE_PATTERN.test(key)) return true
}
}
}
return false
}
/** Return names of MCP servers whose env vars may contain secrets */
export function findServersWithPotentialSecrets(
servers: Record<string, { env?: Record<string, string> }>,
): string[] {
return Object.entries(servers)
.filter(([, s]) => s.env && Object.keys(s.env).some((k) => SENSITIVE_PATTERN.test(k)))
.map(([name]) => name)
}
+41
View File
@@ -0,0 +1,41 @@
import fs from "fs/promises"
/**
* Create a symlink, safely replacing any existing symlink at target.
* Only removes existing symlinks - skips real directories with a warning.
*/
export async function forceSymlink(source: string, target: string): Promise<void> {
try {
const stat = await fs.lstat(target)
if (stat.isSymbolicLink()) {
// Safe to remove existing symlink
await fs.unlink(target)
} else if (stat.isDirectory()) {
// Skip real directories rather than deleting them
console.warn(`Skipping ${target}: a real directory exists there (remove it manually to replace with a symlink).`)
return
} else {
// Regular file - remove it
await fs.unlink(target)
}
} catch (err) {
// ENOENT means target doesn't exist, which is fine
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}
}
await fs.symlink(source, target)
}
/**
* Validate a skill name to prevent path traversal attacks.
* Returns true if safe, false if potentially malicious.
*/
export function isValidSkillName(name: string): boolean {
if (!name || name.length === 0) return false
if (name.includes("/") || name.includes("\\")) return false
if (name.includes("..")) return false
if (name.includes("\0")) return false
if (name === "." || name === "..") return false
return true
}