chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()}`)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.`)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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[]>
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 }[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user