refactor(install): prefer native plugin install across targets (#609)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

Co-authored-by: John Cavanaugh <cavanaug@users.noreply.github.com>
This commit is contained in:
Trevin Chow
2026-04-20 18:47:07 -07:00
committed by GitHub
parent 9497a00d90
commit c2d60b47be
104 changed files with 7073 additions and 7068 deletions

708
src/commands/cleanup.ts Normal file
View File

@@ -0,0 +1,708 @@
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 { convertClaudeToGemini } from "../converters/claude-to-gemini"
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,
getLegacyGeminiArtifacts,
getLegacyKiroArtifacts,
getLegacyOpenCodeArtifacts,
getLegacyPiArtifacts,
getLegacyPluginArtifacts,
getLegacyWindsurfArtifacts,
} from "../data/plugin-legacy-artifacts"
import { moveLegacyArtifactToBackup } from "../targets/managed-artifacts"
import { isManagedCodexAgentsSymlink, readCodexInstallManifest, resolveCodexManagedRoots } from "../targets/codex"
import { isSafeManagedPath, pathExists, readJson, sanitizePathName } from "../utils/files"
import { resolveOpenCodeGlobalRoot } from "../utils/opencode-config"
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
const cleanupTargets = ["codex", "opencode", "pi", "gemini", "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 | gemini | 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)",
},
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)",
},
geminiHome: {
type: "string",
alias: "gemini-home",
description: "Gemini root to clean (default: ~/.gemini)",
},
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 hasExplicitGeminiHome = hasExplicitValue(args.geminiHome)
const hasExplicitOpenCodeHome = hasExplicitValue(args.opencodeHome)
const roots = {
codexHome: resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")),
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()),
geminiHome: resolveTargetHome(args.geminiHome, path.join(os.homedir(), ".gemini")),
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),
hasExplicitGeminiHome,
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
geminiHome: string
kiroHome: string
copilotHome: string
droidHome: string
qwenHome: string
windsurfHome: string
agentsHome: string
workspaceRoot: string
hasExplicitOutput: boolean
hasExplicitGeminiHome: 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 "gemini": {
// `install`/`convert` write Gemini output to `<cwd>/.gemini` by default
// (see `resolveTargetOutputRoot`), so cleanup must scan the workspace
// root in the same default flow. When neither `--gemini-home` nor
// `--output` is set, also scan `~/.gemini` as a safety net for users
// who installed to the home-scoped location with an older CLI.
if (roots.hasExplicitGeminiHome) {
return [await cleanupGemini(plugin, roots.geminiHome)]
}
const workspaceGemini = resolveGeminiWorkspaceRoot(roots.workspaceRoot)
if (roots.hasExplicitOutput) {
return [await cleanupGemini(plugin, workspaceGemini)]
}
// Deduplicate before launching parallel cleanups: when cwd === $HOME,
// `<cwd>/.gemini` and `~/.gemini` resolve to the same directory and two
// concurrent passes would race on renames into legacy-backup, producing
// intermittent ENOENT failures. `process.cwd()` and `os.homedir()` are
// already absolute, and `path.join` (inside `resolveGeminiWorkspaceRoot`
// and `resolveTargetHome`) normalizes the result, so string equality on
// the post-resolve paths is sufficient.
const rootsToClean = await dedupeRoots([workspaceGemini, roots.geminiHome])
return await Promise.all(rootsToClean.map((root) => cleanupGemini(plugin, root)))
}
case "kiro":
return [await cleanupKiro(plugin, roots.kiroHome)]
case "copilot": {
// Same race-prevention as Gemini: 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 Gemini/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",
})
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 moveIfExists(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) {
moved += await moveIfExists(managedDir, "prompts", path.join(codexRoot, "prompts"), promptFile, "Codex")
}
// 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",
})
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
await moveLegacyArtifactToBackup(managedDir, kind, artifactRoot, relativePath, label)
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 moveIfExists(managedDir, "skills", path.join(opencodeRoot, "skills"), skillName, "OpenCode")
}
for (const agentPath of artifacts.agents) {
moved += await moveIfExists(managedDir, "agents", path.join(opencodeRoot, "agents"), agentPath, "OpenCode")
}
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 moveIfExists(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")
}
return { target: "pi", root: piRoot, moved }
}
async function cleanupGemini(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>, geminiRoot: string): Promise<CleanupResult> {
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const artifacts = getLegacyGeminiArtifacts(bundle)
const managedDir = path.join(geminiRoot, "compound-engineering")
let moved = 0
for (const skillName of artifacts.skills) {
moved += await moveIfExists(managedDir, "skills", path.join(geminiRoot, "skills"), skillName, "Gemini")
}
for (const agentPath of artifacts.agents) {
moved += await moveIfExists(managedDir, "agents", path.join(geminiRoot, "agents"), agentPath, "Gemini")
}
for (const commandPath of artifacts.commands) {
moved += await moveIfExists(managedDir, "commands", path.join(geminiRoot, "commands"), commandPath, "Gemini")
}
return { target: "gemini", root: geminiRoot, 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 moveIfExists(managedDir, "skills", path.join(kiroRoot, "skills"), skillName, "Kiro")
}
for (const agentName of agentNames) {
moved += await moveIfExists(managedDir, "agents", path.join(kiroRoot, "agents"), `${agentName}.json`, "Kiro")
moved += await moveIfExists(managedDir, "agents", path.join(kiroRoot, "agents", "prompts"), `${agentName}.md`, "Kiro")
}
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 moveIfExists(managedDir, "skills", path.join(copilotRoot, "skills"), skillName, "Copilot")
}
for (const agentPath of artifacts.agents) {
moved += await moveIfExists(managedDir, "agents", path.join(copilotRoot, "agents"), agentPath, "Copilot")
}
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 moveIfExists(managedDir, "skills", path.join(droidRoot, "skills"), skillName, "Droid")
}
for (const droidPath of artifacts.droids) {
moved += await moveIfExists(managedDir, "droids", path.join(droidRoot, "droids"), droidPath, "Droid")
}
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(`${name.split(":").join("/")}.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 moveIfExists(managedDir, "skills", path.join(qwenRoot, "skills"), skillName, "Qwen")
}
for (const agentName of agentNames) {
moved += await moveIfExists(managedDir, "agents", path.join(qwenRoot, "agents"), `${agentName}.yaml`, "Qwen")
moved += await moveIfExists(managedDir, "agents", path.join(qwenRoot, "agents"), `${agentName}.md`, "Qwen")
}
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 moveIfExists(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
}
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 bundledRoot = fileURLToPath(new URL("../../plugins/", import.meta.url))
const pluginPath = path.join(bundledRoot, input)
const manifestPath = path.join(pluginPath, ".claude-plugin", "plugin.json")
if (await pathExists(manifestPath)) return pluginPath
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 resolveGeminiWorkspaceRoot(outputRoot: string): string {
return path.basename(outputRoot) === ".gemini" ? outputRoot : path.join(outputRoot, ".gemini")
}
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")
}

View File

@@ -6,7 +6,7 @@ import { targets, validateScope } from "../targets"
import type { ClaudeToOpenCodeOptions, PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
import { resolveTargetOutputRoot } from "../utils/resolve-output"
import { resolveOpenCodeWriteScope, resolveTargetOutputRoot } from "../utils/resolve-output"
import { detectInstalledTools } from "../utils/detect-tools"
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
@@ -25,7 +25,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)",
description: "Target format (opencode | codex | pi | gemini | kiro | all)",
},
output: {
type: "string",
@@ -42,16 +42,6 @@ export default defineCommand({
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
openclawHome: {
type: "string",
alias: "openclaw-home",
description: "Write OpenClaw output to this extensions root (ex: ~/.openclaw/extensions)",
},
qwenHome: {
type: "string",
alias: "qwen-home",
description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions)",
},
scope: {
type: "string",
description: "Scope level: global | workspace (default varies by target)",
@@ -89,8 +79,6 @@ export default defineCommand({
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
const openclawHome = resolveTargetHome(args.openclawHome, path.join(os.homedir(), ".openclaw", "extensions"))
const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions"))
const options: ClaudeToOpenCodeOptions = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
@@ -100,15 +88,19 @@ export default defineCommand({
if (targetName === "all") {
const detected = await detectInstalledTools()
const activeTargets = detected.filter((t) => t.detected)
const activeTargets = detected.filter((t) => t.detected && targets[t.name]?.implemented)
if (activeTargets.length === 0) {
console.log("No AI coding tools detected. Install at least one tool first.")
console.log("No installable AI coding tools detected. Use native plugin install for Claude Code, Copilot, Droid, and Qwen.")
return
}
console.log(`Detected ${activeTargets.length} tool(s):`)
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}`)
}
@@ -128,12 +120,12 @@ export default defineCommand({
outputRoot,
codexHome,
piHome,
openclawHome,
qwenHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
})
await handler.write(root, bundle)
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}`)
}
@@ -159,8 +151,6 @@ export default defineCommand({
outputRoot,
codexHome,
piHome,
openclawHome,
qwenHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: resolvedScope,
@@ -170,7 +160,9 @@ export default defineCommand({
throw new Error(`Target ${targetName} did not return a bundle.`)
}
await target.write(primaryOutputRoot, bundle, resolvedScope)
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)
@@ -192,16 +184,18 @@ export default defineCommand({
}
const extraRoot = resolveTargetOutputRoot({
targetName: extra,
outputRoot: path.join(outputRoot, extra),
outputRoot,
codexHome,
piHome,
openclawHome,
qwenHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: handler.defaultScope,
})
await handler.write(extraRoot, extraBundle, 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}`)
}

View File

@@ -9,7 +9,7 @@ import { pathExists } from "../utils/files"
import type { ClaudeToOpenCodeOptions, PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
import { resolveTargetOutputRoot } from "../utils/resolve-output"
import { resolveOpenCodeWriteScope, resolveTargetOutputRoot } from "../utils/resolve-output"
import { detectInstalledTools } from "../utils/detect-tools"
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
@@ -28,7 +28,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)",
description: "Target format (opencode | codex | pi | gemini | kiro | all)",
},
output: {
type: "string",
@@ -45,16 +45,6 @@ export default defineCommand({
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
openclawHome: {
type: "string",
alias: "openclaw-home",
description: "Write OpenClaw output to this extensions root (ex: ~/.openclaw/extensions)",
},
qwenHome: {
type: "string",
alias: "qwen-home",
description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions)",
},
scope: {
type: "string",
description: "Scope level: global | workspace (default varies by target)",
@@ -100,8 +90,6 @@ export default defineCommand({
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
const openclawHome = resolveTargetHome(args.openclawHome, path.join(os.homedir(), ".openclaw", "extensions"))
const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions"))
const options: ClaudeToOpenCodeOptions = {
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
@@ -111,15 +99,19 @@ export default defineCommand({
if (targetName === "all") {
const detected = await detectInstalledTools()
const activeTargets = detected.filter((t) => t.detected)
const activeTargets = detected.filter((t) => t.detected && targets[t.name]?.implemented)
if (activeTargets.length === 0) {
console.log("No AI coding tools detected. Install at least one tool first.")
console.log("No installable AI coding tools detected. Use native plugin install for Claude Code, Copilot, Droid, and Qwen.")
return
}
console.log(`Detected ${activeTargets.length} tool(s):`)
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}`)
}
@@ -139,12 +131,12 @@ export default defineCommand({
outputRoot,
codexHome,
piHome,
openclawHome,
qwenHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
})
await handler.write(root, bundle)
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}`)
}
@@ -173,13 +165,13 @@ export default defineCommand({
outputRoot,
codexHome,
piHome,
openclawHome,
qwenHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: resolvedScope,
})
await target.write(primaryOutputRoot, bundle, 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)
@@ -201,16 +193,18 @@ export default defineCommand({
}
const extraRoot = resolveTargetOutputRoot({
targetName: extra,
outputRoot: path.join(outputRoot, extra),
outputRoot,
codexHome,
piHome,
openclawHome,
qwenHome,
pluginName: plugin.manifest.name,
hasExplicitOutput,
scope: handler.defaultScope,
})
await handler.write(extraRoot, extraBundle, 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}`)
}
@@ -264,9 +258,12 @@ function resolveOutputRoot(value: unknown): string {
const expanded = expandHome(String(value).trim())
return path.resolve(expanded)
}
// OpenCode global config lives at ~/.config/opencode per XDG spec
// See: https://opencode.ai/docs/config/
return path.join(os.homedir(), ".config", "opencode")
// 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 (gemini, kiro) 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> {

View File

@@ -1,88 +0,0 @@
import { defineCommand } from "citty"
import path from "path"
import { loadClaudeHome } from "../parsers/claude-home"
import {
getDefaultSyncRegistryContext,
getSyncTarget,
isSyncTargetName,
syncTargetNames,
type SyncTargetName,
} from "../sync/registry"
import { expandHome } from "../utils/resolve-home"
import { hasPotentialSecrets } from "../utils/secrets"
import { detectInstalledTools } from "../utils/detect-tools"
const validTargets = [...syncTargetNames, "all"] as const
type SyncTarget = SyncTargetName | "all"
function isValidTarget(value: string): value is SyncTarget {
return value === "all" || isSyncTargetName(value)
}
export default defineCommand({
meta: {
name: "sync",
description: "Sync Claude Code config (~/.claude/) to supported provider configs and skills",
},
args: {
target: {
type: "string",
default: "all",
description: `Target: ${syncTargetNames.join(" | ")} | all (default: all)`,
},
claudeHome: {
type: "string",
alias: "claude-home",
description: "Path to Claude home (default: ~/.claude)",
},
},
async run({ args }) {
if (!isValidTarget(args.target)) {
throw new Error(`Unknown target: ${args.target}. Use one of: ${validTargets.join(", ")}`)
}
const { home, cwd } = getDefaultSyncRegistryContext()
const claudeHome = expandHome(args.claudeHome ?? path.join(home, ".claude"))
const config = await loadClaudeHome(claudeHome)
// Warn about potential secrets in MCP env vars
if (hasPotentialSecrets(config.mcpServers)) {
console.warn(
"⚠️ Warning: MCP servers contain env vars that may include secrets (API keys, tokens).\n" +
" These will be copied to the target config. Review before sharing the config file.",
)
}
if (args.target === "all") {
const detected = await detectInstalledTools()
const activeTargets = detected.filter((t) => t.detected).map((t) => t.name)
if (activeTargets.length === 0) {
console.log("No AI coding tools detected.")
return
}
console.log(`Syncing to ${activeTargets.length} detected tool(s)...`)
for (const tool of detected) {
console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name}${tool.reason}`)
}
for (const name of activeTargets) {
const target = getSyncTarget(name as SyncTargetName)
const outputRoot = target.resolveOutputRoot(home, cwd)
await target.sync(config, outputRoot)
console.log(`✓ Synced to ${name}: ${outputRoot}`)
}
return
}
console.log(
`Syncing ${config.skills.length} skills, ${config.commands?.length ?? 0} commands, ${Object.keys(config.mcpServers).length} MCP servers...`,
)
const target = getSyncTarget(args.target as SyncTargetName)
const outputRoot = target.resolveOutputRoot(home, cwd)
await target.sync(config, outputRoot)
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
},
})

View File

@@ -2,7 +2,7 @@ import fs, { type Dirent } from "fs"
import path from "path"
import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudePlugin, type ClaudeSkill, filterSkillsByPlatform } from "../types/claude"
import type { CodexBundle, CodexGeneratedSkill, CodexGeneratedSkillSidecarDir } from "../types/codex"
import type { CodexAgent, CodexBundle, CodexGeneratedSkill, CodexGeneratedSkillSidecarDir } from "../types/codex"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
import {
normalizeCodexName,
@@ -57,7 +57,9 @@ export function convertClaudeToCodex(
}
}
const invocationTargets: CodexInvocationTargets = { promptTargets, skillTargets }
const agents = plugin.agents.map(convertAgent)
const agentTargets = buildAgentTargets(plugin, agents)
const invocationTargets: CodexInvocationTargets = { promptTargets, skillTargets, agentTargets }
const commandSkills: CodexGeneratedSkill[] = []
const prompts = invocableCommands.map((command) => {
@@ -68,42 +70,34 @@ export function convertClaudeToCodex(
return { name: promptName, content }
})
const agentSkills = plugin.agents.map((agent) =>
convertAgent(agent, usedSkillNames, invocationTargets),
)
const generatedSkills = [...commandSkills, ...agentSkills]
const generatedSkills = [...commandSkills]
return {
pluginName: plugin.manifest.name,
prompts,
skillDirs,
generatedSkills,
agents,
invocationTargets,
mcpServers: plugin.mcpServers,
}
}
function convertAgent(
agent: ClaudeAgent,
usedNames: Set<string>,
invocationTargets: CodexInvocationTargets,
): CodexGeneratedSkill {
const name = uniqueName(normalizeCodexName(agent.name), usedNames)
function convertAgent(agent: ClaudeAgent): CodexAgent {
const name = buildCodexAgentName(agent)
const description = sanitizeDescription(
agent.description ?? `Converted from Claude agent ${agent.name}`,
)
const frontmatter: Record<string, unknown> = { name, description }
let body = transformContentForCodex(agent.body.trim(), invocationTargets)
let instructions = agent.body.trim()
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((capability) => `- ${capability}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
instructions = `## Capabilities\n${capabilities}\n\n${instructions}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
if (instructions.length === 0) {
instructions = `Instructions converted from the ${agent.name} agent.`
}
const content = formatFrontmatter(frontmatter, body)
return { name, content, sidecarDirs: collectReferencedSidecarDirs(agent) }
return { name, description, instructions, sidecarDirs: collectReferencedSidecarDirs(agent) }
}
function convertCommandSkill(
@@ -164,6 +158,44 @@ 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

View File

@@ -41,7 +41,7 @@ export function convertClaudeToCopilot(
console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.")
}
return { agents, generatedSkills, skillDirs, mcpConfig }
return { pluginName: plugin.manifest.name, agents, generatedSkills, skillDirs, mcpConfig }
}
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): CopilotAgent {

View File

@@ -50,7 +50,7 @@ export function convertClaudeToDroid(
sourceDir: skill.sourceDir,
}))
return { commands, droids, skillDirs }
return { pluginName: plugin.manifest.name, commands, droids, skillDirs }
}
function convertCommand(command: ClaudeCommand): DroidCommandFile {

View File

@@ -1,6 +1,6 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type { GeminiBundle, GeminiCommand, GeminiMcpServer, GeminiSkill } from "../types/gemini"
import type { GeminiAgent, GeminiBundle, GeminiCommand, GeminiMcpServer } from "../types/gemini"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToGeminiOptions = ClaudeToOpenCodeOptions
@@ -11,7 +11,6 @@ export function convertClaudeToGemini(
plugin: ClaudePlugin,
_options: ClaudeToGeminiOptions,
): GeminiBundle {
const usedSkillNames = new Set<string>()
const usedCommandNames = new Set<string>()
const platformSkills = filterSkillsByPlatform(plugin.skills, "gemini")
@@ -20,12 +19,8 @@ export function convertClaudeToGemini(
sourceDir: skill.sourceDir,
}))
// Reserve skill names from pass-through skills
for (const skill of skillDirs) {
usedSkillNames.add(normalizeName(skill.name))
}
const generatedSkills = plugin.agents.map((agent) => convertAgentToSkill(agent, usedSkillNames))
const usedAgentNames = new Set<string>()
const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
@@ -35,16 +30,16 @@ export function convertClaudeToGemini(
console.warn("Warning: Gemini CLI hooks use a different format (BeforeTool/AfterTool with matchers). Hooks were skipped during conversion.")
}
return { generatedSkills, skillDirs, commands, mcpServers }
return { pluginName: plugin.manifest.name, generatedSkills: [], skillDirs, agents, commands, mcpServers }
}
function convertAgentToSkill(agent: ClaudeAgent, usedNames: Set<string>): GeminiSkill {
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): GeminiAgent {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = sanitizeDescription(
agent.description ?? `Use this skill for ${agent.name} tasks`,
agent.description ?? `Use this agent for ${agent.name} tasks`,
)
const frontmatter: Record<string, unknown> = { name, description }
const frontmatter: Record<string, unknown> = { name, description, kind: "local" }
let body = transformContentForGemini(agent.body.trim())
if (agent.capabilities && agent.capabilities.length > 0) {
@@ -80,9 +75,9 @@ function convertCommand(command: ClaudeCommand, usedNames: Set<string>): GeminiC
/**
* Transform Claude Code content to Gemini-compatible content.
*
* 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
* 1. Task agent calls: Task agent-name(args) -> Use the @agent-name subagent to: args
* 2. Path rewriting: .claude/ -> .gemini/, ~/.claude/ -> ~/.gemini/
* 3. Agent references: @agent-name -> the agent-name skill
* 3. Agent references: @agent-name -> @agent-name subagent
*/
export function transformContentForGemini(body: string): string {
let result = body
@@ -91,11 +86,11 @@ export function transformContentForGemini(body: string): string {
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 geminiAgentName = normalizeName(finalSegment)
const trimmedArgs = args.trim()
return trimmedArgs
? `${prefix}Use the ${skillName} skill to: ${trimmedArgs}`
: `${prefix}Use the ${skillName} skill`
? `${prefix}Use the @${geminiAgentName} subagent to: ${trimmedArgs}`
: `${prefix}Use the @${geminiAgentName} subagent`
})
// 2. Rewrite .claude/ paths to .gemini/
@@ -104,9 +99,9 @@ export function transformContentForGemini(body: string): string {
.replace(/\.claude\//g, ".gemini/")
// 3. Transform @agent-name references
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
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 `the ${normalizeName(agentName)} skill`
return `@${normalizeName(agentName)} subagent`
})
return result

View File

@@ -66,7 +66,7 @@ export function convertClaudeToKiro(
)
}
return { agents, generatedSkills, skillDirs, steeringFiles, mcpServers }
return { pluginName: plugin.manifest.name, agents, generatedSkills, skillDirs, steeringFiles, mcpServers }
}
function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]): KiroAgent {

View File

@@ -1,215 +0,0 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { normalizeModelWithProvider } from "../utils/model"
import { sanitizePathName } from "../utils/files"
import {
type ClaudeAgent,
type ClaudeCommand,
type ClaudePlugin,
type ClaudeMcpServer,
filterSkillsByPlatform,
} from "../types/claude"
import type {
OpenClawBundle,
OpenClawCommandRegistration,
OpenClawPluginManifest,
OpenClawSkillFile,
} from "../types/openclaw"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToOpenClawOptions = ClaudeToOpenCodeOptions
export function convertClaudeToOpenClaw(
plugin: ClaudePlugin,
_options: ClaudeToOpenClawOptions,
): OpenClawBundle {
const enabledCommands = plugin.commands.filter((cmd) => !cmd.disableModelInvocation)
const agentSkills = plugin.agents.map(convertAgentToSkill)
const commandSkills = enabledCommands.map(convertCommandToSkill)
const commands = enabledCommands.map(convertCommand)
const skills: OpenClawSkillFile[] = [...agentSkills, ...commandSkills]
const platformSkills = filterSkillsByPlatform(plugin.skills, "openclaw")
const skillDirCopies = platformSkills.map((skill) => ({
sourceDir: skill.sourceDir,
name: skill.name,
}))
const allSkillDirs = [
...agentSkills.map((s) => sanitizePathName(s.dir)),
...commandSkills.map((s) => sanitizePathName(s.dir)),
...platformSkills.map((s) => sanitizePathName(s.name)),
]
const manifest = buildManifest(plugin, allSkillDirs)
const packageJson = buildPackageJson(plugin)
const openclawConfig = plugin.mcpServers
? buildOpenClawConfig(plugin.mcpServers)
: undefined
const entryPoint = generateEntryPoint(commands)
return {
manifest,
packageJson,
entryPoint,
skills,
skillDirCopies,
commands,
openclawConfig,
}
}
function buildManifest(plugin: ClaudePlugin, skillDirs: string[]): OpenClawPluginManifest {
return {
id: plugin.manifest.name,
name: formatDisplayName(plugin.manifest.name),
kind: "tool",
configSchema: {
type: "object",
properties: {},
},
skills: skillDirs.map((dir) => `skills/${dir}`),
}
}
function buildPackageJson(plugin: ClaudePlugin): Record<string, unknown> {
return {
name: `openclaw-${plugin.manifest.name}`,
version: plugin.manifest.version,
type: "module",
private: true,
description: plugin.manifest.description,
main: "index.ts",
openclaw: {
extensions: [
{
id: plugin.manifest.name,
entry: "./index.ts",
},
],
},
keywords: [
"openclaw",
"openclaw-plugin",
...(plugin.manifest.keywords ?? []),
],
}
}
function convertAgentToSkill(agent: ClaudeAgent): OpenClawSkillFile {
const frontmatter: Record<string, unknown> = {
name: agent.name,
description: agent.description,
}
if (agent.model && agent.model !== "inherit") {
frontmatter.model = normalizeModelWithProvider(agent.model)
}
const body = rewritePaths(agent.body)
const content = formatFrontmatter(frontmatter, body)
return {
name: agent.name,
content,
dir: `agent-${agent.name}`,
}
}
function convertCommandToSkill(command: ClaudeCommand): OpenClawSkillFile {
const frontmatter: Record<string, unknown> = {
name: `cmd-${command.name}`,
description: command.description,
}
if (command.model && command.model !== "inherit") {
frontmatter.model = normalizeModelWithProvider(command.model)
}
const body = rewritePaths(command.body)
const content = formatFrontmatter(frontmatter, body)
return {
name: command.name,
content,
dir: `cmd-${command.name}`,
}
}
function convertCommand(command: ClaudeCommand): OpenClawCommandRegistration {
return {
name: command.name.replace(/:/g, "-"),
description: command.description ?? `Run ${command.name}`,
acceptsArgs: Boolean(command.argumentHint),
body: rewritePaths(command.body),
}
}
function buildOpenClawConfig(
servers: Record<string, ClaudeMcpServer>,
): Record<string, unknown> {
const mcpServers: Record<string, unknown> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
mcpServers[name] = {
type: "stdio",
command: server.command,
args: server.args ?? [],
env: server.env,
}
} else if (server.url) {
mcpServers[name] = {
type: "http",
url: server.url,
headers: server.headers,
}
}
}
return { mcpServers }
}
function generateEntryPoint(commands: OpenClawCommandRegistration[]): string {
const commandRegistrations = commands
.map((cmd) => {
const safeName = JSON.stringify(cmd.name)
const safeDesc = JSON.stringify(cmd.description ?? "")
const safeBody = JSON.stringify(cmd.body)
return ` api.registerCommand({
name: ${safeName},
description: ${safeDesc},
acceptsArgs: ${cmd.acceptsArgs},
requireAuth: false,
handler: () => ({
text: ${safeBody},
}),
});`
})
.join("\n\n")
return `// Auto-generated OpenClaw plugin entry point
// Converted from Claude Code plugin format by compound-plugin CLI
export default function register(api) {
${commandRegistrations}
}
`
}
function rewritePaths(body: string): string {
return body
.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.openclaw/")
.replace(/(?<=^|\s|["'`])\.claude\//gm, ".openclaw/")
.replace(/\.claude-plugin\//g, "openclaw-plugin/")
}
function formatDisplayName(name: string): string {
return name
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}

View File

@@ -80,6 +80,7 @@ export function convertClaudeToOpenCode(
applyPermissions(config, plugin.commands, options.permissions)
return {
pluginName: plugin.manifest.name,
config,
agents: agentFiles,
commandFiles: cmdFiles,
@@ -361,11 +362,6 @@ function applyPermissions(
}
const permission: Record<string, "allow" | "deny" | Record<string, "allow" | "deny">> = {}
const tools: Record<string, boolean> = {}
for (const tool of sourceTools) {
tools[tool] = mode === "broad" ? true : enabled.has(tool)
}
if (mode === "broad") {
for (const tool of sourceTools) {
@@ -414,7 +410,6 @@ function applyPermissions(
}
config.permission = permission
config.tools = tools
}
function normalizeTool(raw: string): string | null {

View File

@@ -35,6 +35,7 @@ export function convertClaudeToPi(
]
return {
pluginName: plugin.manifest.name,
prompts,
skillDirs: platformSkills.map((skill) => ({
name: skill.name,

View File

@@ -1,219 +0,0 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { normalizeModelWithProvider } from "../utils/model"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type {
QwenAgentFile,
QwenBundle,
QwenCommandFile,
QwenExtensionConfig,
QwenMcpServer,
QwenSetting,
} from "../types/qwen"
export type ClaudeToQwenOptions = {
agentMode: "primary" | "subagent"
inferTemperature: boolean
}
export function convertClaudeToQwen(plugin: ClaudePlugin, options: ClaudeToQwenOptions): QwenBundle {
const platformSkills = filterSkillsByPlatform(plugin.skills, "qwen")
const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
const cmdFiles = convertCommands(plugin.commands)
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
const settings = extractSettings(plugin.mcpServers)
const config: QwenExtensionConfig = {
name: plugin.manifest.name,
version: plugin.manifest.version || "1.0.0",
commands: "commands",
skills: "skills",
agents: "agents",
}
if (mcp && Object.keys(mcp).length > 0) {
config.mcpServers = mcp
}
if (settings && settings.length > 0) {
config.settings = settings
}
const contextFile = generateContextFile(plugin)
return {
config,
agents: agentFiles,
commandFiles: cmdFiles,
skillDirs: platformSkills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
contextFile,
}
}
function convertAgent(agent: ClaudeAgent, options: ClaudeToQwenOptions): QwenAgentFile {
const frontmatter: Record<string, unknown> = {
name: agent.name,
description: agent.description,
}
if (agent.model && agent.model !== "inherit") {
frontmatter.model = normalizeModelWithProvider(agent.model)
}
if (options.inferTemperature) {
const temperature = inferTemperature(agent)
if (temperature !== undefined) {
frontmatter.temperature = temperature
}
}
// Qwen supports both YAML and Markdown for agents
// Using YAML format for structured config
const content = formatFrontmatter(frontmatter, rewriteQwenPaths(agent.body))
return {
name: agent.name,
content,
format: "yaml",
}
}
function convertCommands(commands: ClaudeCommand[]): QwenCommandFile[] {
const files: QwenCommandFile[] = []
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)
}
if (command.allowedTools && command.allowedTools.length > 0) {
frontmatter.allowedTools = command.allowedTools
}
const content = formatFrontmatter(frontmatter, rewriteQwenPaths(command.body))
files.push({ name: command.name, content })
}
return files
}
function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, QwenMcpServer> {
const result: Record<string, QwenMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (server.url) {
// Qwen only supports stdio (command-based) MCP servers — skip remote servers
console.warn(
`Warning: Remote MCP server '${name}' (URL: ${server.url}) is not supported in Qwen format. Qwen only supports stdio MCP servers. Skipping.`,
)
}
}
return result
}
function extractSettings(mcpServers?: Record<string, ClaudeMcpServer>): QwenSetting[] {
const settings: QwenSetting[] = []
if (!mcpServers) return settings
for (const [name, server] of Object.entries(mcpServers)) {
if (server.env) {
for (const [envVar, value] of Object.entries(server.env)) {
// Only add settings for environment variables that look like placeholders
if (value.startsWith("${") || value.includes("YOUR_") || value.includes("XXX")) {
settings.push({
name: formatSettingName(envVar),
description: `Environment variable for ${name} MCP server`,
envVar,
sensitive: envVar.toLowerCase().includes("key") || envVar.toLowerCase().includes("token") || envVar.toLowerCase().includes("secret"),
})
}
}
}
}
return settings
}
function formatSettingName(envVar: string): string {
return envVar
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
function generateContextFile(plugin: ClaudePlugin): string {
const sections: string[] = []
// Plugin description
sections.push(`# ${plugin.manifest.name}`)
sections.push("")
if (plugin.manifest.description) {
sections.push(plugin.manifest.description)
sections.push("")
}
// Agents section
if (plugin.agents.length > 0) {
sections.push("## Agents")
sections.push("")
for (const agent of plugin.agents) {
sections.push(`- **${agent.name}**: ${agent.description || "No description"}`)
}
sections.push("")
}
// Commands section
if (plugin.commands.length > 0) {
sections.push("## Commands")
sections.push("")
for (const command of plugin.commands) {
if (!command.disableModelInvocation) {
sections.push(`- **/${command.name}**: ${command.description || "No description"}`)
}
}
sections.push("")
}
// Skills section
const qwenSkills = filterSkillsByPlatform(plugin.skills, "qwen")
if (qwenSkills.length > 0) {
sections.push("## Skills")
sections.push("")
for (const skill of qwenSkills) {
sections.push(`- ${skill.name}`)
}
sections.push("")
}
return sections.join("\n")
}
function rewriteQwenPaths(body: string): string {
return body
.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.qwen/")
.replace(/(?<=^|\s|["'`])\.claude\//gm, ".qwen/")
}
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 undefined
}

View File

@@ -1,212 +0,0 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { sanitizePathName } from "../utils/files"
import { findServersWithPotentialSecrets } from "../utils/secrets"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type { WindsurfBundle, WindsurfGeneratedSkill, WindsurfMcpConfig, WindsurfMcpServerEntry, WindsurfWorkflow } from "../types/windsurf"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToWindsurfOptions = ClaudeToOpenCodeOptions
const WINDSURF_WORKFLOW_CHAR_LIMIT = 12_000
export function convertClaudeToWindsurf(
plugin: ClaudePlugin,
_options: ClaudeToWindsurfOptions,
): WindsurfBundle {
const knownAgentNames = plugin.agents.map((a) => normalizeName(a.name))
// Pass-through skills (collected first so agent skill names can deduplicate against them)
const skillDirs = filterSkillsByPlatform(plugin.skills, "windsurf").map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
}))
// Convert agents to skills (seed usedNames with sanitized pass-through skill names
// so generated agent skills detect collisions that would occur on disk)
const usedSkillNames = new Set<string>(skillDirs.map((s) => sanitizePathName(s.name)))
const agentSkills = plugin.agents.map((agent) =>
convertAgentToSkill(agent, knownAgentNames, usedSkillNames),
)
// Convert commands to workflows
const usedCommandNames = new Set<string>()
const commandWorkflows = plugin.commands.map((command) =>
convertCommandToWorkflow(command, knownAgentNames, usedCommandNames),
)
// Build MCP config
const mcpConfig = buildMcpConfig(plugin.mcpServers)
// Warn about hooks
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
console.warn(
"Warning: Windsurf has no hooks equivalent. Hooks were skipped during conversion.",
)
}
return { agentSkills, commandWorkflows, skillDirs, mcpConfig }
}
function convertAgentToSkill(
agent: ClaudeAgent,
knownAgentNames: string[],
usedNames: Set<string>,
): WindsurfGeneratedSkill {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = sanitizeDescription(
agent.description ?? `Converted from Claude agent ${agent.name}`,
)
let body = transformContentForWindsurf(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.`
}
const content = formatFrontmatter({ name, description }, `# ${name}\n\n${body}`) + "\n"
return { name, content }
}
function convertCommandToWorkflow(
command: ClaudeCommand,
knownAgentNames: string[],
usedNames: Set<string>,
): WindsurfWorkflow {
const name = uniqueName(normalizeName(command.name), usedNames)
const description = sanitizeDescription(
command.description ?? `Converted from Claude command ${command.name}`,
)
let body = transformContentForWindsurf(command.body.trim(), knownAgentNames)
if (command.argumentHint) {
body = `> Arguments: ${command.argumentHint}\n\n${body}`
}
if (body.length === 0) {
body = `Instructions converted from the ${command.name} command.`
}
const frontmatter: Record<string, unknown> = { description }
const fullContent = formatFrontmatter(frontmatter, `# ${name}\n\n${body}`)
if (fullContent.length > WINDSURF_WORKFLOW_CHAR_LIMIT) {
console.warn(
`Warning: Workflow "${name}" is ${fullContent.length} characters (limit: ${WINDSURF_WORKFLOW_CHAR_LIMIT}). It may be truncated by Windsurf.`,
)
}
return { name, description, body }
}
/**
* Transform Claude Code content to Windsurf-compatible content.
*
* 1. Path rewriting: .claude/ -> .windsurf/, ~/.claude/ -> ~/.codeium/windsurf/
* 2. Slash command refs: /workflows:plan -> /workflows-plan (Windsurf invokes workflows as /{name})
* 3. @agent-name refs: kept as @agent-name (already Windsurf skill invocation syntax)
* 4. Task agent calls: Task agent-name(args) -> Use the @agent-name skill: args
*/
export function transformContentForWindsurf(body: string, knownAgentNames: string[] = []): string {
let result = body
// 1. Rewrite paths
result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.codeium/windsurf/")
result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".windsurf/")
// 2. Slash command refs: /workflows:plan -> /workflows-plan (Windsurf invokes as /{name})
result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => {
const workflowName = normalizeName(cmdName)
return `/${workflowName}`
})
// 3. @agent-name references: no transformation needed.
// In Windsurf, @skill-name is the native invocation syntax for skills.
// Since agents are now mapped to skills, @agent-name already works correctly.
// 4. Transform Task agent calls to skill references (supports namespaced names)
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 skillRef = normalizeName(finalSegment)
const trimmedArgs = args.trim()
return trimmedArgs
? `${prefix}Use the @${skillRef} skill: ${trimmedArgs}`
: `${prefix}Use the @${skillRef} skill`
})
return result
}
function buildMcpConfig(servers?: Record<string, ClaudeMcpServer>): WindsurfMcpConfig | null {
if (!servers || Object.keys(servers).length === 0) return null
const result: Record<string, WindsurfMcpServerEntry> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
// stdio transport
const entry: WindsurfMcpServerEntry = { command: server.command }
if (server.args?.length) entry.args = server.args
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
result[name] = entry
} else if (server.url) {
// HTTP/SSE transport
const entry: WindsurfMcpServerEntry = { serverUrl: 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 = server.env
result[name] = entry
} else {
console.warn(`Warning: MCP server "${name}" has no command or URL. Skipping.`)
continue
}
}
if (Object.keys(result).length === 0) return null
// Warn about secrets (don't redact — they're needed for the config to work)
const flagged = findServersWithPotentialSecrets(result)
if (flagged.length > 0) {
console.warn(
`Warning: MCP servers contain env vars that may include secrets: ${flagged.join(", ")}.\n` +
" These will be written to mcp_config.json. Review before sharing the config file.",
)
}
return { mcpServers: result }
}
export 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, "-")
.replace(/^-+|-+$/g, "")
if (normalized.length === 0 || !/^[a-z]/.test(normalized)) {
return "item"
}
return normalized
}
function sanitizeDescription(value: string): string {
return value.replace(/\s+/g, " ").trim()
}
function uniqueName(base: string, used: Set<string>): string {
if (!used.has(base)) {
used.add(base)
return base
}
let index = 2
while (used.has(`${base}-${index}`)) {
index += 1
}
const name = `${base}-${index}`
used.add(name)
return name
}

View File

@@ -0,0 +1,620 @@
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 { GeminiBundle } from "../types/gemini"
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: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-audit",
"ce-claude-permissions-optimizer",
"ce-design",
"ce-doctor",
"ce-document-review",
"ce-feature-video",
"ce-orchestrating-swarms",
"ce-plan-beta",
"ce-pr-stack",
"ce-reproduce-bug",
"ce-review",
"ce-review-beta",
"ce-update",
"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: [
"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-lint",
"cli-agent-readiness-reviewer",
"cli-readiness-reviewer",
"code-simplicity-reviewer",
"coherence-reviewer",
"correctness-reviewer",
"data-integrity-guardian",
"data-migration-expert",
"data-migrations-reviewer",
"deployment-verification-agent",
"design-implementation-reviewer",
"design-iterator",
"design-lens-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",
"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",
"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",
"changelog",
"codify",
"compound",
"compound:codify",
"compound:plan",
"compound:review",
"compound:work",
"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[]
}
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 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, { includeRawColon: true })
}
for (const name of extras.agents ?? []) {
skills.add(normalizeCodexName(name))
}
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(),
}
}
export function getLegacyPiArtifacts(bundle: PiBundle): LegacyTargetArtifacts {
const skills = new Set<string>()
const prompts = new Set<string>()
const currentSkills = new Set<string>([
...bundle.generatedSkills.map((skill) => normalizePiName(skill.name)),
...bundle.skillDirs.map((skill) => normalizePiName(skill.name)),
])
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)
}
}
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(),
}
}
export function getLegacyGeminiArtifacts(bundle: GeminiBundle): LegacyTargetFileArtifacts {
const skills = new Set<string>()
const agents = new Set<string>()
const commands = 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)}.md`))
const currentCommands = new Set<string>(bundle.commands.map((command) => `${command.name}.toml`))
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 agentPath = `${skillName}.md`
if (!currentAgents.has(agentPath)) {
agents.add(agentPath)
}
}
for (const name of extras.commands ?? []) {
const commandPath = toNestedCommandRelativePath(name, ".toml")
if (!currentCommands.has(commandPath)) {
commands.add(commandPath)
}
}
return {
skills: [...skills].sort(),
agents: [...agents].sort(),
commands: [...commands].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}`
}

View File

@@ -2,10 +2,10 @@
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"
import sync from "./commands/sync"
const main = defineCommand({
meta: {
@@ -14,11 +14,11 @@ const main = defineCommand({
description: "Convert Claude Code plugins into other agent formats",
},
subCommands: {
cleanup: () => cleanup,
convert: () => convert,
install: () => install,
list: () => listCommand,
"plugin-path": () => pluginPath,
sync: () => sync,
},
})

View File

@@ -1,127 +0,0 @@
import path from "path"
import os from "os"
import fs from "fs/promises"
import { parseFrontmatter } from "../utils/frontmatter"
import { walkFiles } from "../utils/files"
import type { ClaudeCommand, ClaudeSkill, ClaudeMcpServer } from "../types/claude"
export interface ClaudeHomeConfig {
skills: ClaudeSkill[]
commands?: ClaudeCommand[]
mcpServers: Record<string, ClaudeMcpServer>
}
export async function loadClaudeHome(claudeHome?: string): Promise<ClaudeHomeConfig> {
const home = claudeHome ?? path.join(os.homedir(), ".claude")
const [skills, commands, mcpServers] = await Promise.all([
loadPersonalSkills(path.join(home, "skills")),
loadPersonalCommands(path.join(home, "commands")),
loadSettingsMcp(path.join(home, "settings.json")),
])
return { skills, commands, mcpServers }
}
async function loadPersonalSkills(skillsDir: string): Promise<ClaudeSkill[]> {
try {
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
const skills: ClaudeSkill[] = []
for (const entry of entries) {
// Check if directory or symlink (symlinks are common for skills)
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const entryPath = path.join(skillsDir, entry.name)
const skillPath = path.join(entryPath, "SKILL.md")
try {
await fs.access(skillPath)
// Resolve symlink to get the actual source directory
const sourceDir = entry.isSymbolicLink()
? await fs.realpath(entryPath)
: entryPath
let data: Record<string, unknown> = {}
try {
const raw = await fs.readFile(skillPath, "utf8")
data = parseFrontmatter(raw, skillPath).data
} catch {
// Keep syncing the skill even if frontmatter is malformed.
}
skills.push({
name: entry.name,
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
disableModelInvocation: data["disable-model-invocation"] === true ? true : undefined,
sourceDir,
skillPath,
})
} catch {
// No SKILL.md, skip
}
}
return skills
} catch {
return [] // Directory doesn't exist
}
}
async function loadSettingsMcp(
settingsPath: string,
): Promise<Record<string, ClaudeMcpServer>> {
try {
const content = await fs.readFile(settingsPath, "utf-8")
const settings = JSON.parse(content) as { mcpServers?: Record<string, ClaudeMcpServer> }
return settings.mcpServers ?? {}
} catch {
return {} // File doesn't exist or invalid JSON
}
}
async function loadPersonalCommands(commandsDir: string): Promise<ClaudeCommand[]> {
try {
const files = (await walkFiles(commandsDir))
.filter((file) => file.endsWith(".md"))
.sort()
const commands: ClaudeCommand[] = []
for (const file of files) {
const raw = await fs.readFile(file, "utf8")
const { data, body } = parseFrontmatter(raw, file)
commands.push({
name: typeof data.name === "string" ? data.name : deriveCommandName(commandsDir, file),
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
model: data.model as string | undefined,
allowedTools: parseAllowedTools(data["allowed-tools"]),
disableModelInvocation: data["disable-model-invocation"] === true ? true : undefined,
body: body.trim(),
sourcePath: file,
})
}
return commands
} catch {
return []
}
}
function deriveCommandName(commandsDir: string, filePath: string): string {
const relative = path.relative(commandsDir, filePath)
const withoutExt = relative.replace(/\.md$/i, "")
return withoutExt.split(path.sep).join(":")
}
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
}

View File

@@ -1,31 +0,0 @@
import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { mergeCodexConfig, renderCodexConfig } from "../targets/codex"
import { writeTextSecure } from "../utils/files"
import { syncCodexCommands } from "./commands"
import { syncSkills } from "./skills"
export async function syncToCodex(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncCodexCommands(config, outputRoot)
// Write MCP servers to config.toml, or clean up stale managed block if none remain
const configPath = path.join(outputRoot, "config.toml")
let existingContent = ""
try {
existingContent = await fs.readFile(configPath, "utf-8")
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}
}
const mcpToml = renderCodexConfig(config.mcpServers)
const merged = mergeCodexConfig(existingContent, mcpToml)
if (merged !== null) {
await writeTextSecure(configPath, merged)
}
}

View File

@@ -1,198 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudePlugin } from "../types/claude"
import { backupFile, resolveCommandPath, sanitizePathName, writeText } from "../utils/files"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
import { convertClaudeToKiro } from "../converters/claude-to-kiro"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import { convertClaudeToQwen, type ClaudeToQwenOptions } from "../converters/claude-to-qwen"
import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf"
import { writeWindsurfBundle } from "../targets/windsurf"
type WindsurfSyncScope = "global" | "workspace"
const HOME_SYNC_PLUGIN_ROOT = path.join(process.cwd(), ".compound-sync-home")
const DEFAULT_SYNC_OPTIONS: ClaudeToOpenCodeOptions = {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
}
const DEFAULT_QWEN_SYNC_OPTIONS: ClaudeToQwenOptions = {
agentMode: "subagent",
inferTemperature: false,
}
function hasCommands(config: ClaudeHomeConfig): boolean {
return (config.commands?.length ?? 0) > 0
}
function buildClaudeHomePlugin(config: ClaudeHomeConfig): ClaudePlugin {
return {
root: HOME_SYNC_PLUGIN_ROOT,
manifest: {
name: "claude-home",
version: "1.0.0",
description: "Personal Claude Code home config",
},
agents: [],
commands: config.commands ?? [],
skills: config.skills,
mcpServers: undefined,
}
}
export async function syncOpenCodeCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToOpenCode(plugin, DEFAULT_SYNC_OPTIONS)
for (const commandFile of bundle.commandFiles) {
const commandPath = await resolveCommandPath(path.join(outputRoot, "commands"), commandFile.name, ".md")
const backupPath = await backupFile(commandPath)
if (backupPath) {
console.log(`Backed up existing command file to ${backupPath}`)
}
await writeText(commandPath, commandFile.content + "\n")
}
}
export async function syncCodexCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToCodex(plugin, DEFAULT_SYNC_OPTIONS)
for (const prompt of bundle.prompts) {
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
}
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}
export async function syncPiCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToPi(plugin, DEFAULT_SYNC_OPTIONS)
for (const prompt of bundle.prompts) {
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
}
for (const extension of bundle.extensions) {
await writeText(path.join(outputRoot, "extensions", extension.name), extension.content + "\n")
}
}
export async function syncDroidCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToDroid(plugin, DEFAULT_SYNC_OPTIONS)
for (const command of bundle.commands) {
await writeText(path.join(outputRoot, "commands", `${command.name}.md`), command.content + "\n")
}
}
export async function syncCopilotCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToCopilot(plugin, DEFAULT_SYNC_OPTIONS)
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}
export async function syncGeminiCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToGemini(plugin, DEFAULT_SYNC_OPTIONS)
for (const command of bundle.commands) {
await writeText(path.join(outputRoot, "commands", `${command.name}.toml`), command.content + "\n")
}
}
export async function syncKiroCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToKiro(plugin, DEFAULT_SYNC_OPTIONS)
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}
export async function syncWindsurfCommands(
config: ClaudeHomeConfig,
outputRoot: string,
scope: WindsurfSyncScope = "global",
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToWindsurf(plugin, DEFAULT_SYNC_OPTIONS)
await writeWindsurfBundle(outputRoot, {
agentSkills: [],
commandWorkflows: bundle.commandWorkflows,
skillDirs: [],
mcpConfig: null,
}, scope)
}
export async function syncQwenCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToQwen(plugin, DEFAULT_QWEN_SYNC_OPTIONS)
for (const commandFile of bundle.commandFiles) {
const parts = commandFile.name.split(":")
if (parts.length > 1) {
const nestedDir = path.join(outputRoot, "commands", ...parts.slice(0, -1))
await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), commandFile.content + "\n")
continue
}
await writeText(path.join(outputRoot, "commands", `${commandFile.name}.md`), commandFile.content + "\n")
}
}
export function warnUnsupportedOpenClawCommands(config: ClaudeHomeConfig): void {
if (!hasCommands(config)) return
console.warn(
"Warning: OpenClaw personal command sync is skipped because this sync target currently has no documented user-level command surface.",
)
}

View File

@@ -1,78 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import { syncCopilotCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
type CopilotMcpServer = {
type: "local" | "http" | "sse"
command?: string
args?: string[]
url?: string
tools: string[]
env?: Record<string, string>
headers?: Record<string, string>
}
type CopilotMcpConfig = {
mcpServers: Record<string, CopilotMcpServer>
}
export async function syncToCopilot(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncCopilotCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
const mcpPath = path.join(outputRoot, "mcp-config.json")
const converted = convertMcpForCopilot(config.mcpServers)
await mergeJsonConfigAtKey({
configPath: mcpPath,
key: "mcpServers",
incoming: converted,
})
}
}
function convertMcpForCopilot(
servers: Record<string, ClaudeMcpServer>,
): Record<string, CopilotMcpServer> {
const result: Record<string, CopilotMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
const entry: CopilotMcpServer = {
type: server.command ? "local" : hasExplicitSseTransport(server) ? "sse" : "http",
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
}

View File

@@ -1,62 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import { syncDroidCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type DroidMcpServer = {
type: "stdio" | "http"
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
disabled: boolean
}
export async function syncToDroid(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncDroidCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "mcp.json"),
key: "mcpServers",
incoming: convertMcpForDroid(config.mcpServers),
})
}
}
function convertMcpForDroid(
servers: Record<string, ClaudeMcpServer>,
): Record<string, DroidMcpServer> {
const result: Record<string, DroidMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env,
disabled: false,
}
continue
}
if (server.url) {
result[name] = {
type: "http",
url: server.url,
headers: server.headers,
disabled: false,
}
}
}
return result
}

View File

@@ -1,136 +0,0 @@
import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import { sanitizePathName } from "../utils/files"
import { syncGeminiCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type GeminiMcpServer = {
command?: string
args?: string[]
url?: string
env?: Record<string, string>
headers?: Record<string, string>
}
export async function syncToGemini(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncGeminiSkills(config.skills, outputRoot)
await syncGeminiCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
const settingsPath = path.join(outputRoot, "settings.json")
const converted = convertMcpForGemini(config.mcpServers)
await mergeJsonConfigAtKey({
configPath: settingsPath,
key: "mcpServers",
incoming: converted,
})
}
}
async function syncGeminiSkills(
skills: ClaudeHomeConfig["skills"],
outputRoot: string,
): Promise<void> {
const skillsDir = path.join(outputRoot, "skills")
const sharedSkillsDir = getGeminiSharedSkillsDir(outputRoot)
if (!sharedSkillsDir) {
await syncSkills(skills, skillsDir)
return
}
const canonicalSharedSkillsDir = await canonicalizePath(sharedSkillsDir)
const mirroredSkills: ClaudeHomeConfig["skills"] = []
const directSkills: ClaudeHomeConfig["skills"] = []
for (const skill of skills) {
if (await isWithinDir(skill.sourceDir, canonicalSharedSkillsDir)) {
mirroredSkills.push(skill)
} else {
directSkills.push(skill)
}
}
await removeGeminiMirrorConflicts(mirroredSkills, skillsDir, canonicalSharedSkillsDir)
await syncSkills(directSkills, skillsDir)
}
function getGeminiSharedSkillsDir(outputRoot: string): string | null {
if (path.basename(outputRoot) !== ".gemini") return null
return path.join(path.dirname(outputRoot), ".agents", "skills")
}
async function canonicalizePath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath)
} catch {
return path.resolve(targetPath)
}
}
async function isWithinDir(candidate: string, canonicalParentDir: string): Promise<boolean> {
const resolvedCandidate = await canonicalizePath(candidate)
return resolvedCandidate === canonicalParentDir
|| resolvedCandidate.startsWith(`${canonicalParentDir}${path.sep}`)
}
async function removeGeminiMirrorConflicts(
skills: ClaudeHomeConfig["skills"],
skillsDir: string,
sharedSkillsDir: string,
): Promise<void> {
for (const skill of skills) {
const duplicatePath = path.join(skillsDir, sanitizePathName(skill.name))
let stat
try {
stat = await fs.lstat(duplicatePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue
}
throw error
}
if (!stat.isSymbolicLink()) {
continue
}
let resolvedTarget: string
try {
resolvedTarget = await canonicalizePath(duplicatePath)
} catch {
continue
}
if (resolvedTarget === await canonicalizePath(skill.sourceDir)
|| await isWithinDir(resolvedTarget, sharedSkillsDir)) {
await fs.unlink(duplicatePath)
}
}
}
function convertMcpForGemini(
servers: Record<string, ClaudeMcpServer>,
): Record<string, GeminiMcpServer> {
const result: Record<string, GeminiMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
const entry: GeminiMcpServer = {}
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) {
entry.url = server.url
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
}
result[name] = entry
}
return result
}

View File

@@ -1,49 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { KiroMcpServer } from "../types/kiro"
import { syncKiroCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
export async function syncToKiro(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncKiroCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "settings", "mcp.json"),
key: "mcpServers",
incoming: convertMcpForKiro(config.mcpServers),
})
}
}
function convertMcpForKiro(
servers: Record<string, ClaudeMcpServer>,
): Record<string, KiroMcpServer> {
const result: Record<string, KiroMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (server.url) {
result[name] = {
url: server.url,
headers: server.headers,
}
}
}
return result
}

View File

@@ -1,19 +0,0 @@
import type { ClaudeMcpServer } from "../types/claude"
function getTransportType(server: ClaudeMcpServer): string {
return server.type?.toLowerCase().trim() ?? ""
}
export function hasExplicitSseTransport(server: ClaudeMcpServer): boolean {
const type = getTransportType(server)
return type.includes("sse")
}
export function hasExplicitHttpTransport(server: ClaudeMcpServer): boolean {
const type = getTransportType(server)
return type.includes("http") || type.includes("streamable")
}
export function hasExplicitRemoteTransport(server: ClaudeMcpServer): boolean {
return hasExplicitSseTransport(server) || hasExplicitHttpTransport(server)
}

View File

@@ -1,18 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { warnUnsupportedOpenClawCommands } from "./commands"
import { syncSkills } from "./skills"
export async function syncToOpenClaw(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
warnUnsupportedOpenClawCommands(config)
if (Object.keys(config.mcpServers).length > 0) {
console.warn(
"Warning: OpenClaw MCP sync is skipped because the current official OpenClaw docs do not clearly document an MCP server config contract.",
)
}
}

View File

@@ -1,55 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { OpenCodeMcpServer } from "../types/opencode"
import { syncOpenCodeCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
export async function syncToOpenCode(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncOpenCodeCommands(config, outputRoot)
// Merge MCP servers into opencode.json
if (Object.keys(config.mcpServers).length > 0) {
const configPath = path.join(outputRoot, "opencode.json")
const mcpConfig = convertMcpForOpenCode(config.mcpServers)
await mergeJsonConfigAtKey({
configPath,
key: "mcp",
incoming: mcpConfig,
})
}
}
function convertMcpForOpenCode(
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
}

View File

@@ -1,64 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import { ensureDir } from "../utils/files"
import { syncPiCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type McporterServer = {
baseUrl?: string
command?: string
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
}
type McporterConfig = {
mcpServers: Record<string, McporterServer>
}
export async function syncToPi(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncPiCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await ensureDir(path.dirname(mcporterPath))
const converted = convertMcpToMcporter(config.mcpServers)
await mergeJsonConfigAtKey({
configPath: mcporterPath,
key: "mcpServers",
incoming: converted.mcpServers,
})
}
}
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): McporterConfig {
const mcpServers: Record<string, McporterServer> = {}
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 }
}

View File

@@ -1,66 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { QwenMcpServer } from "../types/qwen"
import { syncQwenCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitRemoteTransport, hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
export async function syncToQwen(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncQwenCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "settings.json"),
key: "mcpServers",
incoming: convertMcpForQwen(config.mcpServers),
})
}
}
function convertMcpForQwen(
servers: Record<string, ClaudeMcpServer>,
): Record<string, QwenMcpServer> {
const result: Record<string, QwenMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (!server.url) {
continue
}
if (hasExplicitSseTransport(server)) {
result[name] = {
url: server.url,
headers: server.headers,
}
continue
}
if (!hasExplicitRemoteTransport(server)) {
console.warn(
`Warning: Qwen MCP server "${name}" has an ambiguous remote transport; defaulting to Streamable HTTP.`,
)
}
result[name] = {
httpUrl: server.url,
headers: server.headers,
}
}
return result
}

View File

@@ -1,141 +0,0 @@
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { syncToCodex } from "./codex"
import { syncToCopilot } from "./copilot"
import { syncToDroid } from "./droid"
import { syncToGemini } from "./gemini"
import { syncToKiro } from "./kiro"
import { syncToOpenClaw } from "./openclaw"
import { syncToOpenCode } from "./opencode"
import { syncToPi } from "./pi"
import { syncToQwen } from "./qwen"
import { syncToWindsurf } from "./windsurf"
function getCopilotHomeRoot(home: string): string {
return path.join(home, ".copilot")
}
function getGeminiHomeRoot(home: string): string {
return path.join(home, ".gemini")
}
export type SyncTargetName =
| "opencode"
| "codex"
| "pi"
| "droid"
| "copilot"
| "gemini"
| "windsurf"
| "kiro"
| "qwen"
| "openclaw"
export type SyncTargetDefinition = {
name: SyncTargetName
detectPaths: (home: string, cwd: string) => string[]
resolveOutputRoot: (home: string, cwd: string) => string
sync: (config: ClaudeHomeConfig, outputRoot: string) => Promise<void>
}
export const syncTargets: SyncTargetDefinition[] = [
{
name: "opencode",
detectPaths: (home, cwd) => [
path.join(home, ".config", "opencode"),
path.join(cwd, ".opencode"),
],
resolveOutputRoot: (home) => path.join(home, ".config", "opencode"),
sync: syncToOpenCode,
},
{
name: "codex",
detectPaths: (home) => [path.join(home, ".codex")],
resolveOutputRoot: (home) => path.join(home, ".codex"),
sync: syncToCodex,
},
{
name: "pi",
detectPaths: (home) => [path.join(home, ".pi")],
resolveOutputRoot: (home) => path.join(home, ".pi", "agent"),
sync: syncToPi,
},
{
name: "droid",
detectPaths: (home) => [path.join(home, ".factory")],
resolveOutputRoot: (home) => path.join(home, ".factory"),
sync: syncToDroid,
},
{
name: "copilot",
detectPaths: (home, cwd) => [
getCopilotHomeRoot(home),
path.join(cwd, ".github", "skills"),
path.join(cwd, ".github", "agents"),
path.join(cwd, ".github", "copilot-instructions.md"),
],
resolveOutputRoot: (home) => getCopilotHomeRoot(home),
sync: syncToCopilot,
},
{
name: "gemini",
detectPaths: (home, cwd) => [
path.join(cwd, ".gemini"),
getGeminiHomeRoot(home),
],
resolveOutputRoot: (home) => getGeminiHomeRoot(home),
sync: syncToGemini,
},
{
name: "windsurf",
detectPaths: (home, cwd) => [
path.join(home, ".codeium", "windsurf"),
path.join(cwd, ".windsurf"),
],
resolveOutputRoot: (home) => path.join(home, ".codeium", "windsurf"),
sync: syncToWindsurf,
},
{
name: "kiro",
detectPaths: (home, cwd) => [
path.join(home, ".kiro"),
path.join(cwd, ".kiro"),
],
resolveOutputRoot: (home) => path.join(home, ".kiro"),
sync: syncToKiro,
},
{
name: "qwen",
detectPaths: (home, cwd) => [
path.join(home, ".qwen"),
path.join(cwd, ".qwen"),
],
resolveOutputRoot: (home) => path.join(home, ".qwen"),
sync: syncToQwen,
},
{
name: "openclaw",
detectPaths: (home) => [path.join(home, ".openclaw")],
resolveOutputRoot: (home) => path.join(home, ".openclaw"),
sync: syncToOpenClaw,
},
]
export const syncTargetNames = syncTargets.map((target) => target.name)
export function isSyncTargetName(value: string): value is SyncTargetName {
return syncTargetNames.includes(value as SyncTargetName)
}
export function getSyncTarget(name: SyncTargetName): SyncTargetDefinition {
const target = syncTargets.find((entry) => entry.name === name)
if (!target) {
throw new Error(`Unknown sync target: ${name}`)
}
return target
}
export function getDefaultSyncRegistryContext(): { home: string; cwd: string } {
return { home: os.homedir(), cwd: process.cwd() }
}

View File

@@ -1,29 +0,0 @@
import path from "path"
import type { ClaudeSkill } from "../types/claude"
import { ensureDir, sanitizePathName } from "../utils/files"
import { forceSymlink, isValidSkillName } from "../utils/symlink"
export async function syncSkills(
skills: ClaudeSkill[],
skillsDir: string,
): Promise<void> {
await ensureDir(skillsDir)
const seen = new Set<string>()
for (const skill of skills) {
if (!isValidSkillName(skill.name)) {
console.warn(`Skipping skill with invalid name: ${skill.name}`)
continue
}
const safeName = sanitizePathName(skill.name)
if (seen.has(safeName)) {
console.warn(`Skipping skill "${skill.name}": sanitized name "${safeName}" collides with another skill`)
continue
}
seen.add(safeName)
const target = path.join(skillsDir, safeName)
await forceSymlink(skill.sourceDir, target)
}
}

View File

@@ -1,59 +0,0 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { WindsurfMcpServerEntry } from "../types/windsurf"
import { syncWindsurfCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
export async function syncToWindsurf(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncWindsurfCommands(config, outputRoot, "global")
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "mcp_config.json"),
key: "mcpServers",
incoming: convertMcpForWindsurf(config.mcpServers),
})
}
}
function convertMcpForWindsurf(
servers: Record<string, ClaudeMcpServer>,
): Record<string, WindsurfMcpServerEntry> {
const result: Record<string, WindsurfMcpServerEntry> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (!server.url) {
continue
}
const entry: WindsurfMcpServerEntry = {
headers: server.headers,
}
if (hasExplicitSseTransport(server)) {
entry.url = server.url
} else {
entry.serverUrl = server.url
}
result[name] = entry
}
return result
}

View File

@@ -1,10 +1,10 @@
import fs from "fs/promises"
import path from "path"
import { backupFile, copyDir, copySkillDir, ensureDir, sanitizePathName, writeText, writeTextSecure } from "../utils/files"
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 { cleanupStaleSkillDirs, cleanupStaleAgents, cleanupStalePrompts } from "../utils/legacy-cleanup"
import { getLegacyCodexArtifacts } from "../data/plugin-legacy-artifacts"
const MANAGED_START_MARKER = "# BEGIN Compound Engineering plugin MCP -- do not edit this block"
const MANAGED_END_MARKER = "# END Compound Engineering plugin MCP"
@@ -12,30 +12,56 @@ 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 async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): Promise<void> {
const codexRoot = resolveCodexRoot(outputRoot)
await ensureDir(codexRoot)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
const skillsRoot = path.join(codexRoot, "skills")
await cleanupStaleSkillDirs(skillsRoot)
await cleanupStaleAgents(skillsRoot, null) // agents are generated as skill dirs in Codex
await cleanupStalePrompts(path.join(codexRoot, "prompts"))
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)
if (bundle.prompts.length > 0) {
const promptsDir = path.join(codexRoot, "prompts")
await cleanupRemovedPrompts(promptsDir, manifest, currentPrompts)
for (const prompt of bundle.prompts) {
await writeText(path.join(promptsDir, `${prompt.name}.md`), prompt.content + "\n")
await writeText(path.join(promptsDir, `${sanitizePathName(prompt.name)}.md`), prompt.content + "\n")
}
} else if (pluginName) {
await cleanupRemovedPrompts(path.join(codexRoot, "prompts"), manifest, [])
}
const skillsRoot = pluginName
? path.join(codexRoot, "skills", pluginName)
: path.join(codexRoot, "skills")
const currentSkills = [
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
]
await cleanupRemovedSkills(skillsRoot, manifest, currentSkills)
if (bundle.skillDirs.length > 0) {
const skillsRoot = path.join(codexRoot, "skills")
for (const skill of bundle.skillDirs) {
const targetDir = path.join(skillsRoot, sanitizePathName(skill.name))
await cleanupCurrentManagedSkillDir(targetDir, manifest, sanitizePathName(skill.name))
await copySkillDir(
skill.sourceDir,
path.join(skillsRoot, sanitizePathName(skill.name)),
targetDir,
(content) => transformContentForCodex(content, bundle.invocationTargets, {
unknownSlashBehavior: "preserve",
}),
@@ -44,9 +70,9 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
}
if (bundle.generatedSkills.length > 0) {
const skillsRoot = path.join(codexRoot, "skills")
for (const skill of bundle.generatedSkills) {
const skillDir = path.join(skillsRoot, sanitizePathName(skill.name))
await cleanupCurrentManagedSkillDir(skillDir, manifest, sanitizePathName(skill.name))
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))
@@ -54,6 +80,32 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
}
}
await cleanupRemovedAgents(agentsRoot, manifest, currentAgents)
if (agents.length > 0) {
for (const agent of agents) {
const agentFile = `${sanitizePathName(agent.name)}.toml`
await writeText(path.join(agentsRoot, agentFile), renderCodexAgentToml(agent) + "\n")
for (const sidecar of agent.sidecarDirs ?? []) {
await copyDir(sidecar.sourceDir, path.join(agentsRoot, sanitizePathName(agent.name), sidecar.targetName))
}
}
}
if (pluginName) {
await ensureDir(skillsRoot)
await writeInstallManifest(codexRoot, {
version: 1,
pluginName,
skills: currentSkills,
prompts: currentPrompts,
agents: currentAgents,
})
await cleanupKnownLegacyCodexArtifacts(codexRoot, bundle)
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)
@@ -71,6 +123,313 @@ function resolveCodexRoot(outputRoot: string): string {
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
await fs.rm(path.join(skillsRoot, skillName), { 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
await fs.rm(path.join(agentsRoot, agentFile), { force: true })
await fs.rm(path.join(agentsRoot, path.basename(agentFile, ".toml")), { recursive: true, force: true })
}
}
async function cleanupCurrentManagedSkillDir(
targetDir: string,
manifest: CodexInstallManifest | null,
skillName: string,
): Promise<void> {
if (!manifest?.skills.includes(skillName)) return
await fs.rm(targetDir, { recursive: true, force: true })
}
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)
await moveLegacyArtifactToBackup(codexRoot, pluginName, "skills", legacySkillPath)
}
for (const promptFile of legacyArtifacts.prompts) {
const legacyPromptPath = path.join(codexRoot, "prompts", promptFile)
await moveLegacyArtifactToBackup(codexRoot, pluginName, "prompts", legacyPromptPath)
}
}
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 ?? []) {
const finalSegment = agent.name.includes("-ce-") ? agent.name.split("-ce-").pop() : agent.name
legacySkillNames.add(sanitizePathName(agent.name))
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> {
await fs.rm(path.join(codexRoot, pluginName, "skills"), { 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 {
const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}
async function moveLegacyArtifactToBackup(
codexRoot: string,
pluginName: string,
kind: "skills" | "prompts",
artifactPath: string,
): Promise<void> {
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
@@ -177,6 +536,34 @@ 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)

View File

@@ -1,109 +0,0 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJsonSecure, writeText } from "../utils/files"
import { transformContentForCopilot } from "../converters/claude-to-copilot"
import type { CopilotBundle } from "../types/copilot"
import { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBundle): Promise<void> {
const paths = resolveCopilotPaths(outputRoot)
await ensureDir(paths.githubDir)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
const skillsDir = path.join(paths.githubDir, "skills")
await cleanupStaleSkillDirs(skillsDir)
await cleanupStaleAgents(path.join(paths.githubDir, "agents"), ".agent.md")
if (bundle.agents.length > 0) {
const agentsDir = path.join(paths.githubDir, "agents")
for (const agent of bundle.agents) {
await writeText(path.join(agentsDir, `${sanitizePathName(agent.name)}.agent.md`), agent.content + "\n")
}
}
if (bundle.generatedSkills.length > 0) {
const skillsDir = path.join(paths.githubDir, "skills")
for (const skill of bundle.generatedSkills) {
await writeText(path.join(skillsDir, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
const skillsDir = path.join(paths.githubDir, "skills")
for (const skill of bundle.skillDirs) {
await copySkillDir(skill.sourceDir, path.join(skillsDir, sanitizePathName(skill.name)), transformContentForCopilot)
}
}
const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json")
const merged = await mergeCopilotMcpConfig(mcpPath, bundle.mcpConfig ?? {})
if (merged !== null) {
const backupPath = await backupFile(mcpPath)
if (backupPath) {
console.log(`Backed up existing copilot-mcp-config.json to ${backupPath}`)
}
await writeJsonSecure(mcpPath, merged)
}
}
const MANAGED_KEY = "_compound_managed_mcp"
async function mergeCopilotMcpConfig(
configPath: string,
incoming: Record<string, unknown>,
): Promise<Record<string, unknown> | null> {
let existing: Record<string, unknown> = {}
if (await pathExists(configPath)) {
try {
const parsed = await readJson<unknown>(configPath)
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>
}
} catch {
// Unparseable file — proceed with incoming only
}
}
const existingMcp = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
? { ...(existing.mcpServers as Record<string, unknown>) }
: {}
// Remove previously-managed plugin servers that are no longer in the bundle.
// Legacy migration: if no tracking key exists AND plugin has servers, assume all
// existing servers are plugin-managed (the old writer overwrote the entire file).
// When incoming is empty, skip pruning — there's nothing to migrate and we'd
// wrongly delete user servers from a pre-existing untracked config.
const incomingKeys = Object.keys(incoming)
const hasTrackingKey = Array.isArray(existing[MANAGED_KEY])
const prevManaged = hasTrackingKey
? existing[MANAGED_KEY] as string[]
: incomingKeys.length > 0 ? Object.keys(existingMcp) : []
for (const name of prevManaged) {
if (!(name in incoming)) {
delete existingMcp[name]
}
}
const mergedMcp = { ...existingMcp, ...incoming }
// Nothing to write — no user servers, no plugin servers, no existing file
if (Object.keys(mergedMcp).length === 0 && Object.keys(existing).length === 0) {
return null
}
// Always write tracking key (even as []) to prevent legacy fallback on future installs
return {
...existing,
mcpServers: mergedMcp,
[MANAGED_KEY]: incomingKeys,
}
}
function resolveCopilotPaths(outputRoot: string) {
const base = path.basename(outputRoot)
// If already pointing at .github, write directly into it
if (base === ".github") {
return { githubDir: outputRoot }
}
// Otherwise nest under .github
return { githubDir: path.join(outputRoot, ".github") }
}

View File

@@ -1,57 +0,0 @@
import path from "path"
import { copySkillDir, ensureDir, resolveCommandPath, sanitizePathName, writeText } from "../utils/files"
import { transformContentForDroid } from "../converters/claude-to-droid"
import type { DroidBundle } from "../types/droid"
import { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle): Promise<void> {
const paths = resolveDroidPaths(outputRoot)
await ensureDir(paths.root)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
await cleanupStaleSkillDirs(paths.skillsDir)
await cleanupStaleAgents(paths.droidsDir, ".md")
if (bundle.commands.length > 0) {
await ensureDir(paths.commandsDir)
for (const command of bundle.commands) {
const dest = await resolveCommandPath(paths.commandsDir, command.name, ".md")
await writeText(dest, command.content + "\n")
}
}
if (bundle.droids.length > 0) {
await ensureDir(paths.droidsDir)
for (const droid of bundle.droids) {
await writeText(path.join(paths.droidsDir, `${sanitizePathName(droid.name)}.md`), droid.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
await ensureDir(paths.skillsDir)
for (const skill of bundle.skillDirs) {
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, sanitizePathName(skill.name)), transformContentForDroid)
}
}
}
function resolveDroidPaths(outputRoot: string) {
const base = path.basename(outputRoot)
// If pointing directly at ~/.factory or .factory, write into it
if (base === ".factory") {
return {
root: outputRoot,
commandsDir: path.join(outputRoot, "commands"),
droidsDir: path.join(outputRoot, "droids"),
skillsDir: path.join(outputRoot, "skills"),
}
}
// Otherwise nest under .factory
return {
root: outputRoot,
commandsDir: path.join(outputRoot, ".factory", "commands"),
droidsDir: path.join(outputRoot, ".factory", "droids"),
skillsDir: path.join(outputRoot, ".factory", "skills"),
}
}

View File

@@ -1,32 +1,67 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformContentForGemini } from "../converters/claude-to-gemini"
import type { GeminiBundle } from "../types/gemini"
import { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
import { getLegacyGeminiArtifacts } from "../data/plugin-legacy-artifacts"
import {
archiveLegacyInstallManifestIfOwned,
cleanupCurrentManagedDirectory,
cleanupRemovedManagedDirectories,
cleanupRemovedManagedFiles,
moveLegacyArtifactToBackup,
readManagedInstallManifestWithLegacyFallback,
resolveManagedSegment,
sanitizeManagedPluginName,
writeManagedInstallManifest,
} from "./managed-artifacts"
export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle): Promise<void> {
const paths = resolveGeminiPaths(outputRoot)
await ensureDir(paths.geminiDir)
const pluginName = bundle.pluginName ? sanitizeManagedPluginName(bundle.pluginName) : undefined
const paths = resolveGeminiPaths(outputRoot, pluginName)
const manifest = pluginName
? await readManagedInstallManifestWithLegacyFallback(paths.managedDir, pluginName)
: null
const currentSkills = [
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
]
const agents = bundle.agents ?? []
const currentAgents = agents.map((agent) => `${sanitizePathName(agent.name)}.md`)
const currentCommands = bundle.commands.map((command) => `${command.name}.toml`)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
await cleanupStaleSkillDirs(paths.skillsDir)
await cleanupStaleAgents(paths.skillsDir, null)
await ensureDir(paths.geminiDir)
await cleanupRemovedManagedDirectories(paths.skillsDir, manifest, "skills", currentSkills)
await cleanupRemovedManagedFiles(paths.agentsDir, manifest, "agents", currentAgents)
await cleanupRemovedManagedFiles(paths.commandsDir, manifest, "commands", currentCommands)
if (bundle.generatedSkills.length > 0) {
for (const skill of bundle.generatedSkills) {
await writeText(path.join(paths.skillsDir, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
const skillName = sanitizePathName(skill.name)
const targetDir = path.join(paths.skillsDir, skillName)
await cleanupCurrentManagedDirectory(targetDir, manifest, "skills", skillName)
await writeText(path.join(targetDir, "SKILL.md"), skill.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
for (const skill of bundle.skillDirs) {
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, sanitizePathName(skill.name)), transformContentForGemini)
const skillName = sanitizePathName(skill.name)
const targetDir = path.join(paths.skillsDir, skillName)
await cleanupCurrentManagedDirectory(targetDir, manifest, "skills", skillName)
await copySkillDir(skill.sourceDir, targetDir, transformContentForGemini)
}
}
if (agents.length > 0) {
for (const agent of agents) {
const agentFile = `${sanitizePathName(agent.name)}.md`
await writeText(path.join(paths.agentsDir, agentFile), agent.content + "\n")
}
}
if (bundle.commands.length > 0) {
for (const command of bundle.commands) {
const dest = await resolveCommandPath(paths.commandsDir, command.name, ".toml")
const dest = path.join(paths.commandsDir, ...command.name.split("/")) + ".toml"
await writeText(dest, command.content + "\n")
}
}
@@ -38,7 +73,6 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle
console.log(`Backed up existing settings.json to ${backupPath}`)
}
// Merge mcpServers into existing settings if present
let existingSettings: Record<string, unknown> = {}
if (await pathExists(settingsPath)) {
try {
@@ -54,22 +88,59 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle
const merged = { ...existingSettings, mcpServers: { ...existingMcp, ...bundle.mcpServers } }
await writeJson(settingsPath, merged)
}
if (pluginName) {
await writeManagedInstallManifest(paths.managedDir, {
version: 1,
pluginName,
groups: {
skills: currentSkills,
agents: currentAgents,
commands: currentCommands,
},
})
await archiveLegacyInstallManifestIfOwned(paths.managedDir, pluginName)
await cleanupKnownLegacyGeminiArtifacts(paths, bundle)
}
}
function resolveGeminiPaths(outputRoot: string) {
function resolveGeminiPaths(outputRoot: string, pluginName?: string) {
// Namespace the managed install directory per plugin so multiple plugins
// installed into the same Gemini 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 already pointing at .gemini, write directly into it
if (base === ".gemini") {
return {
geminiDir: outputRoot,
managedDir: path.join(outputRoot, managedSegment),
skillsDir: path.join(outputRoot, "skills"),
agentsDir: path.join(outputRoot, "agents"),
commandsDir: path.join(outputRoot, "commands"),
}
}
// Otherwise nest under .gemini
return {
geminiDir: path.join(outputRoot, ".gemini"),
managedDir: path.join(outputRoot, ".gemini", managedSegment),
skillsDir: path.join(outputRoot, ".gemini", "skills"),
agentsDir: path.join(outputRoot, ".gemini", "agents"),
commandsDir: path.join(outputRoot, ".gemini", "commands"),
}
}
async function cleanupKnownLegacyGeminiArtifacts(
paths: ReturnType<typeof resolveGeminiPaths>,
bundle: GeminiBundle,
): Promise<void> {
const legacyArtifacts = getLegacyGeminiArtifacts(bundle)
for (const skillName of legacyArtifacts.skills) {
await moveLegacyArtifactToBackup(paths.managedDir, "skills", paths.skillsDir, skillName, "Gemini skill")
}
for (const agentPath of legacyArtifacts.agents) {
await moveLegacyArtifactToBackup(paths.managedDir, "agents", paths.agentsDir, agentPath, "Gemini agent")
}
for (const commandPath of legacyArtifacts.commands) {
await moveLegacyArtifactToBackup(paths.managedDir, "commands", paths.commandsDir, commandPath, "Gemini command")
}
}

View File

@@ -1,24 +1,14 @@
import type { ClaudePlugin } from "../types/claude"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
import { convertClaudeToKiro } from "../converters/claude-to-kiro"
import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf"
import { convertClaudeToOpenClaw } from "../converters/claude-to-openclaw"
import { convertClaudeToQwen } from "../converters/claude-to-qwen"
import { writeOpenCodeBundle } from "./opencode"
import { writeCodexBundle } from "./codex"
import { writeDroidBundle } from "./droid"
import { writePiBundle } from "./pi"
import { writeCopilotBundle } from "./copilot"
import { writeGeminiBundle } from "./gemini"
import { writeKiroBundle } from "./kiro"
import { writeWindsurfBundle } from "./windsurf"
import { writeOpenClawBundle } from "./openclaw"
import { writeQwenBundle } from "./qwen"
export type TargetScope = "global" | "workspace"
@@ -70,24 +60,12 @@ export const targets: Record<string, TargetHandler> = {
convert: convertClaudeToCodex as TargetHandler["convert"],
write: writeCodexBundle as TargetHandler["write"],
},
droid: {
name: "droid",
implemented: true,
convert: convertClaudeToDroid as TargetHandler["convert"],
write: writeDroidBundle as TargetHandler["write"],
},
pi: {
name: "pi",
implemented: true,
convert: convertClaudeToPi as TargetHandler["convert"],
write: writePiBundle as TargetHandler["write"],
},
copilot: {
name: "copilot",
implemented: true,
convert: convertClaudeToCopilot as TargetHandler["convert"],
write: writeCopilotBundle as TargetHandler["write"],
},
gemini: {
name: "gemini",
implemented: true,
@@ -100,24 +78,4 @@ export const targets: Record<string, TargetHandler> = {
convert: convertClaudeToKiro as TargetHandler["convert"],
write: writeKiroBundle as TargetHandler["write"],
},
windsurf: {
name: "windsurf",
implemented: true,
defaultScope: "global",
supportedScopes: ["global", "workspace"],
convert: convertClaudeToWindsurf as TargetHandler["convert"],
write: writeWindsurfBundle as TargetHandler["write"],
},
openclaw: {
name: "openclaw",
implemented: true,
convert: convertClaudeToOpenClaw as TargetHandler["convert"],
write: writeOpenClawBundle as TargetHandler["write"],
},
qwen: {
name: "qwen",
implemented: true,
convert: convertClaudeToQwen as TargetHandler["convert"],
write: writeQwenBundle as TargetHandler["write"],
},
}

View File

@@ -3,9 +3,12 @@ import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePath
import { transformContentForKiro } from "../converters/claude-to-kiro"
import type { KiroBundle } from "../types/kiro"
import { cleanupStaleSkillDirs, cleanupStaleAgents } 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)
@@ -100,6 +103,10 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P
const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpServers } }
await writeJson(mcpPath, merged)
}
if (pluginName) {
await cleanupKnownLegacyKiroArtifacts(paths, bundle)
}
}
function resolveKiroPaths(outputRoot: string) {
@@ -108,6 +115,7 @@ function resolveKiroPaths(outputRoot: string) {
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"),
@@ -118,6 +126,7 @@ function resolveKiroPaths(outputRoot: string) {
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"),
@@ -125,6 +134,26 @@ function resolveKiroPaths(outputRoot: string) {
}
}
async function cleanupKnownLegacyKiroArtifacts(
paths: ReturnType<typeof resolveKiroPaths>,
bundle: KiroBundle,
): Promise<void> {
const legacyArtifacts = getLegacyKiroArtifacts(bundle)
for (const skillName of legacyArtifacts.skills) {
await moveLegacyArtifactToBackup(paths.managedDir, "skills", paths.skillsDir, skillName, "Kiro skill")
}
for (const agentName of legacyArtifacts.agents) {
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}`)

View File

@@ -0,0 +1,212 @@
import fs from "fs/promises"
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
await fs.rm(resolveArtifactPath(rootDir, relativePath), { 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
await fs.rm(resolveArtifactPath(rootDir, relativePath), { force: true })
}
}
export async function cleanupCurrentManagedDirectory(
targetDir: string,
manifest: ManagedInstallManifest | null,
group: string,
entryName: string,
): Promise<void> {
if (!manifest?.groups[group]?.includes(entryName)) return
await fs.rm(targetDir, { recursive: true, force: true })
}
export async function moveLegacyArtifactToBackup(
managedDir: string,
kind: string,
artifactRoot: string,
relativePath: string,
label: string,
): Promise<void> {
const artifactPath = resolveArtifactPath(artifactRoot, relativePath)
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("/"))
}

View File

@@ -1,101 +0,0 @@
import path from "path"
import { promises as fs } from "fs"
import { backupFile, copyDir, ensureDir, pathExists, readJson, sanitizePathName, walkFiles, writeJson, writeText } from "../utils/files"
import type { OpenClawBundle } from "../types/openclaw"
import { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBundle): Promise<void> {
const paths = resolveOpenClawPaths(outputRoot)
await ensureDir(paths.root)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
await cleanupStaleSkillDirs(paths.skillsDir)
await cleanupStaleAgents(paths.skillsDir, null, "agent-") // agents are converted to agent-* skill dirs in OpenClaw
// Write openclaw.plugin.json
await writeJson(paths.manifestPath, bundle.manifest)
// Write package.json
await writeJson(paths.packageJsonPath, bundle.packageJson)
// Write index.ts entry point
await writeText(paths.entryPointPath, bundle.entryPoint)
// Write generated skills (agents + commands converted to SKILL.md)
for (const skill of bundle.skills) {
const skillDir = path.join(paths.skillsDir, sanitizePathName(skill.dir))
await ensureDir(skillDir)
await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n")
}
// Copy original skill directories (preserving references/, assets/, scripts/)
// and rewrite .claude/ paths to .openclaw/ in markdown files
for (const skill of bundle.skillDirCopies) {
const destDir = path.join(paths.skillsDir, sanitizePathName(skill.name))
await copyDir(skill.sourceDir, destDir)
await rewritePathsInDir(destDir)
}
// Write openclaw.json config fragment if MCP servers exist
if (bundle.openclawConfig) {
const configPath = path.join(paths.root, "openclaw.json")
const backupPath = await backupFile(configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
const merged = await mergeOpenClawConfig(configPath, bundle.openclawConfig)
await writeJson(configPath, merged)
}
}
function resolveOpenClawPaths(outputRoot: string) {
return {
root: outputRoot,
manifestPath: path.join(outputRoot, "openclaw.plugin.json"),
packageJsonPath: path.join(outputRoot, "package.json"),
entryPointPath: path.join(outputRoot, "index.ts"),
skillsDir: path.join(outputRoot, "skills"),
}
}
async function rewritePathsInDir(dir: string): Promise<void> {
const files = await walkFiles(dir)
for (const file of files) {
if (!file.endsWith(".md")) continue
const content = await fs.readFile(file, "utf8")
const rewritten = content
.replace(/~\/\.claude\//g, "~/.openclaw/")
.replace(/\.claude\//g, ".openclaw/")
.replace(/\.claude-plugin\//g, "openclaw-plugin/")
if (rewritten !== content) {
await fs.writeFile(file, rewritten, "utf8")
}
}
}
async function mergeOpenClawConfig(
configPath: string,
incoming: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (!(await pathExists(configPath))) return incoming
let existing: Record<string, unknown>
try {
existing = await readJson<Record<string, unknown>>(configPath)
} catch {
console.warn(
`Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`,
)
return incoming
}
// Merge MCP servers: existing takes precedence on conflict
const incomingMcp = (incoming.mcpServers ?? {}) as Record<string, unknown>
const existingMcp = (existing.mcpServers ?? {}) as Record<string, unknown>
const mergedMcp = { ...incomingMcp, ...existingMcp }
return {
...existing,
mcpServers: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined,
}
}

View File

@@ -1,45 +1,48 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, 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 { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
import { getLegacyOpenCodeArtifacts } from "../data/plugin-legacy-artifacts"
import {
archiveLegacyInstallManifestIfOwned,
cleanupCurrentManagedDirectory,
cleanupRemovedManagedDirectories,
cleanupRemovedManagedFiles,
moveLegacyArtifactToBackup,
readManagedInstallManifestWithLegacyFallback,
resolveManagedSegment,
sanitizeManagedPluginName,
writeManagedInstallManifest,
} from "./managed-artifacts"
// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.
async function mergeOpenCodeConfig(
configPath: string,
incoming: OpenCodeConfig,
): Promise<OpenCodeConfig> {
// If no existing config, write plugin config as-is
if (!(await pathExists(configPath))) return incoming
let existing: OpenCodeConfig
try {
existing = await readJson<OpenCodeConfig>(configPath)
} catch {
// Safety first per AGENTS.md -- do not destroy user data even if their config is malformed.
// Warn and fall back to plugin-only config rather than crashing.
console.warn(
`Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`
)
return incoming
}
// User config wins on conflict -- see ADR-002
// MCP servers: add plugin entry, skip keys already in user config.
const mergedMcp = {
...(incoming.mcp ?? {}),
...(existing.mcp ?? {}), // existing takes precedence (overwrites same-named plugin entry)
...(existing.mcp ?? {}),
}
// Permission: add plugin entry, skip keys already in user config.
const mergedPermission = incoming.permission
? {
...(incoming.permission),
...(existing.permission ?? {}), // existing takes precedence
...(existing.permission ?? {}),
}
: existing.permission
// Tools: same pattern
const mergedTools = incoming.tools
? {
...(incoming.tools),
@@ -48,7 +51,7 @@ async function mergeOpenCodeConfig(
: existing.tools
return {
...existing, // all user keys preserved
...existing,
$schema: incoming.$schema ?? existing.$schema,
mcp: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined,
permission: mergedPermission,
@@ -56,9 +59,26 @@ async function mergeOpenCodeConfig(
}
}
export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise<void> {
const openCodePaths = resolveOpenCodePaths(outputRoot)
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) => `${commandFile.name.split(":").join("/")}.md`)
const currentPlugins = bundle.plugins.map((plugin) => plugin.name)
const currentSkills = bundle.skillDirs.map((skill) => sanitizePathName(skill.name))
await ensureDir(openCodePaths.root)
await cleanupRemovedManagedFiles(openCodePaths.agentsDir, manifest, "agents", currentAgents)
await cleanupRemovedManagedFiles(openCodePaths.commandDir, manifest, "commands", currentCommands)
await cleanupRemovedManagedFiles(openCodePaths.pluginsDir, manifest, "plugins", currentPlugins)
await cleanupRemovedManagedDirectories(openCodePaths.skillsDir, manifest, "skills", currentSkills)
const hadExistingConfig = await pathExists(openCodePaths.configPath)
const backupPath = await backupFile(openCodePaths.configPath)
@@ -71,11 +91,6 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu
console.log("Merged plugin config into existing opencode.json (user settings preserved)")
}
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
await cleanupStaleSkillDirs(openCodePaths.skillsDir)
await cleanupStaleAgents(openCodePaths.agentsDir, ".md")
const agentsDir = openCodePaths.agentsDir
const seenAgents = new Set<string>()
for (const agent of bundle.agents) {
const safeName = sanitizePathName(agent.name)
@@ -84,11 +99,11 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu
continue
}
seenAgents.add(safeName)
await writeText(path.join(agentsDir, `${safeName}.md`), agent.content + "\n")
await writeText(path.join(openCodePaths.agentsDir, `${safeName}.md`), agent.content + "\n")
}
for (const commandFile of bundle.commandFiles) {
const dest = await resolveCommandPath(openCodePaths.commandDir, commandFile.name, ".md")
const dest = path.join(openCodePaths.commandDir, ...commandFile.name.split(":")) + ".md"
const cmdBackupPath = await backupFile(dest)
if (cmdBackupPath) {
console.log(`Backed up existing command file to ${cmdBackupPath}`)
@@ -97,49 +112,87 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu
}
if (bundle.plugins.length > 0) {
const pluginsDir = openCodePaths.pluginsDir
for (const plugin of bundle.plugins) {
await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n")
await writeText(path.join(openCodePaths.pluginsDir, plugin.name), plugin.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
const skillsRoot = openCodePaths.skillsDir
for (const skill of bundle.skillDirs) {
const skillName = sanitizePathName(skill.name)
const targetDir = path.join(openCodePaths.skillsDir, skillName)
await cleanupCurrentManagedDirectory(targetDir, manifest, "skills", skillName)
await copySkillDir(
skill.sourceDir,
path.join(skillsRoot, sanitizePathName(skill.name)),
targetDir,
transformSkillContentForOpenCode,
true, // transform all .md files — FQ agent names appear in references too
true,
)
}
}
if (pluginName) {
await writeManagedInstallManifest(openCodePaths.managedDir, {
version: 1,
pluginName,
groups: {
agents: currentAgents,
commands: currentCommands,
plugins: currentPlugins,
skills: currentSkills,
},
})
await archiveLegacyInstallManifestIfOwned(openCodePaths.managedDir, pluginName)
await cleanupKnownLegacyOpenCodeArtifacts(openCodePaths, bundle)
}
}
function resolveOpenCodePaths(outputRoot: string) {
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 install: ~/.config/opencode (basename is "opencode")
// Project install: .opencode (basename is ".opencode")
if (base === "opencode" || base === ".opencode") {
// 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"),
// .md command files; alternative to the command key in opencode.json
commandDir: path.join(outputRoot, "commands"),
}
}
// Custom output directory - nest under .opencode subdirectory
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"),
// .md command files; alternative to the command key in opencode.json
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) {
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) {
await moveLegacyArtifactToBackup(paths.managedDir, "agents", paths.agentsDir, agentPath, "OpenCode agent")
}
}

View File

@@ -1,8 +1,10 @@
import fs from "fs/promises"
import path from "path"
import {
backupFile,
copySkillDir,
ensureDir,
isSafeManagedPath,
pathExists,
readText,
sanitizePathName,
@@ -11,10 +13,13 @@ import {
} from "../utils/files"
import { transformContentForPi } from "../converters/claude-to-pi"
import type { PiBundle } from "../types/pi"
import { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
import { getLegacyPiArtifacts } from "../data/plugin-legacy-artifacts"
import { cleanupStaleAgents } from "../utils/legacy-cleanup"
import { resolveLegacyManagedDir, resolveManagedSegment } 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)
@@ -28,27 +33,61 @@ Compatibility notes:
- MCPorter config path: .pi/compound-engineering/mcporter.json (project) or ~/.pi/agent/compound-engineering/mcporter.json (global)
`
export type PiInstallManifest = {
version: 1
pluginName: string
skills: string[]
prompts: string[]
extensions: string[]
}
type PiPaths = {
managedDir: string
skillsDir: string
promptsDir: string
extensionsDir: string
mcporterConfigPath: string
agentsPath: string
}
export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promise<void> {
const paths = resolvePiPaths(outputRoot)
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 currentExtensions = bundle.extensions.map((extension) => extension.name)
await ensureDir(paths.skillsDir)
await ensureDir(paths.promptsDir)
await ensureDir(paths.extensionsDir)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
await cleanupStaleSkillDirs(paths.skillsDir)
await cleanupStaleAgents(paths.skillsDir, null)
await cleanupRemovedPrompts(paths.promptsDir, manifest, currentPrompts)
await cleanupRemovedSkills(paths.skillsDir, manifest, currentSkills)
await cleanupRemovedExtensions(paths.extensionsDir, manifest, currentExtensions)
for (const prompt of bundle.prompts) {
await writeText(path.join(paths.promptsDir, `${sanitizePathName(prompt.name)}.md`), prompt.content + "\n")
}
for (const skill of bundle.skillDirs) {
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, sanitizePathName(skill.name)), transformContentForPi)
const skillName = sanitizePathName(skill.name)
const targetDir = path.join(paths.skillsDir, skillName)
await cleanupCurrentManagedSkillDir(targetDir, manifest, skillName)
await copySkillDir(skill.sourceDir, targetDir, transformContentForPi)
}
for (const skill of bundle.generatedSkills) {
await writeText(path.join(paths.skillsDir, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
const skillName = sanitizePathName(skill.name)
const targetDir = path.join(paths.skillsDir, skillName)
await cleanupCurrentManagedSkillDir(targetDir, manifest, skillName)
await writeText(path.join(targetDir, "SKILL.md"), skill.content + "\n")
}
for (const extension of bundle.extensions) {
@@ -64,39 +103,56 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
}
await ensurePiAgentsBlock(paths.agentsPath)
if (pluginName) {
await writeInstallManifest(paths.managedDir, {
version: 1,
pluginName,
skills: currentSkills,
prompts: currentPrompts,
extensions: currentExtensions,
})
await archiveLegacyInstallManifestIfOwned(paths.managedDir, pluginName)
await cleanupKnownLegacyPiArtifacts(paths, bundle)
}
}
function resolvePiPaths(outputRoot: string) {
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)
// Global install root: ~/.pi/agent
if (base === "agent") {
return {
managedDir: path.join(outputRoot, managedSegment),
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"),
mcporterConfigPath: path.join(outputRoot, managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
// Project local .pi directory
if (base === ".pi") {
return {
managedDir: path.join(outputRoot, managedSegment),
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
mcporterConfigPath: path.join(outputRoot, "compound-engineering", "mcporter.json"),
mcporterConfigPath: path.join(outputRoot, managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
// Custom output root -> nest under .pi
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"),
mcporterConfigPath: path.join(outputRoot, ".pi", "compound-engineering", "mcporter.json"),
mcporterConfigPath: path.join(outputRoot, ".pi", managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
}
@@ -136,3 +192,209 @@ function upsertBlock(existing: string, block: string): string {
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
// 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
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"),
}
}
} 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
await fs.rm(path.join(skillsDir, skillName), { 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 cleanupCurrentManagedSkillDir(
targetDir: string,
manifest: PiInstallManifest | null,
skillName: string,
): Promise<void> {
if (!manifest?.skills.includes(skillName)) return
await fs.rm(targetDir, { recursive: true, force: true })
}
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)
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)
}
}
async function moveLegacyArtifactToBackup(
managedDir: string,
kind: "skills" | "prompts",
artifactPath: string,
): Promise<void> {
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 ${kind.slice(0, -1)} artifact to ${backupPath}`)
}
export {
cleanupRemovedSkills as cleanupRemovedPiSkills,
cleanupRemovedPrompts as cleanupRemovedPiPrompts,
cleanupRemovedExtensions as cleanupRemovedPiExtensions,
}

View File

@@ -1,134 +0,0 @@
import path from "path"
import { backupFile, copyDir, ensureDir, readJson, resolveCommandPath, sanitizePathName, pathExists, writeJsonSecure, writeText } from "../utils/files"
import type { QwenBundle, QwenExtensionConfig } from "../types/qwen"
import { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise<void> {
const qwenPaths = resolveQwenPaths(outputRoot)
await ensureDir(qwenPaths.root)
// Merge qwen-extension.json config, preserving existing user MCP servers
const configPath = qwenPaths.configPath
const backupPath = await backupFile(configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
const merged = await mergeQwenConfig(configPath, bundle.config)
await writeJsonSecure(configPath, merged)
// Write context file (QWEN.md)
if (bundle.contextFile) {
await writeText(qwenPaths.contextPath, bundle.contextFile + "\n")
}
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
await cleanupStaleSkillDirs(qwenPaths.skillsDir)
// Write agents
const agentsDir = qwenPaths.agentsDir
await ensureDir(agentsDir)
await cleanupStaleAgents(agentsDir, ".yaml")
await cleanupStaleAgents(agentsDir, ".md")
for (const agent of bundle.agents) {
const ext = agent.format === "yaml" ? "yaml" : "md"
await writeText(path.join(agentsDir, `${sanitizePathName(agent.name)}.${ext}`), agent.content + "\n")
}
// Write commands
const commandsDir = qwenPaths.commandsDir
await ensureDir(commandsDir)
for (const commandFile of bundle.commandFiles) {
const dest = await resolveCommandPath(commandsDir, commandFile.name, ".md")
await writeText(dest, commandFile.content + "\n")
}
// Copy skills
if (bundle.skillDirs.length > 0) {
const skillsRoot = qwenPaths.skillsDir
await ensureDir(skillsRoot)
for (const skill of bundle.skillDirs) {
await copyDir(skill.sourceDir, path.join(skillsRoot, sanitizePathName(skill.name)))
}
}
}
const MANAGED_KEY = "_compound_managed_mcp"
const MANAGED_KEYS_KEY = "_compound_managed_keys"
const TRACKING_KEYS = new Set([MANAGED_KEY, MANAGED_KEYS_KEY])
async function mergeQwenConfig(
configPath: string,
incoming: QwenExtensionConfig,
): Promise<QwenExtensionConfig> {
let existing: Record<string, unknown> = {}
if (await pathExists(configPath)) {
try {
const parsed = await readJson<unknown>(configPath)
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>
}
} catch {
// Unparseable file — proceed with incoming only
}
}
const existingMcp = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
? { ...(existing.mcpServers as Record<string, unknown>) }
: {}
// Remove previously-managed plugin servers that are no longer in the bundle.
// Legacy migration: if no tracking key exists AND plugin has servers, assume all
// existing servers are plugin-managed (the old writer overwrote the entire file).
// When incoming is empty, skip pruning — there's nothing to migrate and we'd
// wrongly delete user servers from a pre-existing untracked config.
const incomingMcp = incoming.mcpServers ?? {}
const hasTrackingKey = Array.isArray(existing[MANAGED_KEY])
const prevManaged = hasTrackingKey
? existing[MANAGED_KEY] as string[]
: Object.keys(incomingMcp).length > 0 ? Object.keys(existingMcp) : []
for (const name of prevManaged) {
if (!(name in incomingMcp)) {
delete existingMcp[name]
}
}
const mergedMcp = { ...existingMcp, ...incomingMcp }
const { mcpServers: _, ...incomingRest } = incoming
const incomingTopKeys = Object.keys(incomingRest).filter((k) => !TRACKING_KEYS.has(k))
// Prune top-level keys from previous installs that are no longer in the incoming bundle.
// Only prune keys we previously tracked; skip on first install (no tracking key yet).
const prevManagedKeys = Array.isArray(existing[MANAGED_KEYS_KEY])
? existing[MANAGED_KEYS_KEY] as string[]
: []
for (const key of prevManagedKeys) {
if (!incomingTopKeys.includes(key) && key in existing) {
delete existing[key]
}
}
const merged = { ...existing, ...incomingRest } as QwenExtensionConfig & Record<string, unknown>
if (Object.keys(mergedMcp).length > 0) {
merged.mcpServers = mergedMcp as QwenExtensionConfig["mcpServers"]
} else {
delete merged.mcpServers
}
// Always write tracking keys (even as []) so future installs know what to prune.
merged[MANAGED_KEY] = Object.keys(incomingMcp)
merged[MANAGED_KEYS_KEY] = incomingTopKeys
return merged as QwenExtensionConfig
}
function resolveQwenPaths(outputRoot: string) {
return {
root: outputRoot,
configPath: path.join(outputRoot, "qwen-extension.json"),
contextPath: path.join(outputRoot, "QWEN.md"),
agentsDir: path.join(outputRoot, "agents"),
commandsDir: path.join(outputRoot, "commands"),
skillsDir: path.join(outputRoot, "skills"),
}
}

View File

@@ -1,114 +0,0 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJsonSecure, writeText } from "../utils/files"
import { formatFrontmatter } from "../utils/frontmatter"
import { transformContentForWindsurf } from "../converters/claude-to-windsurf"
import type { WindsurfBundle } from "../types/windsurf"
import type { TargetScope } from "./index"
import { cleanupStaleSkillDirs, cleanupStaleAgents } from "../utils/legacy-cleanup"
/**
* Write a WindsurfBundle directly into outputRoot.
*
* Unlike other target writers, this writer expects outputRoot to be the final
* resolved directory — the CLI handles scope-based nesting (global vs workspace).
*/
export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBundle, scope?: TargetScope): Promise<void> {
await ensureDir(outputRoot)
// TODO(cleanup): Remove after v3 transition (circa Q3 2026)
const skillsDir = path.join(outputRoot, "skills")
await cleanupStaleSkillDirs(skillsDir)
await cleanupStaleAgents(skillsDir, null) // agents are written as skill dirs in Windsurf
// Write agent skills (before pass-through copies so pass-through takes precedence on collision)
if (bundle.agentSkills.length > 0) {
const skillsDir = path.join(outputRoot, "skills")
await ensureDir(skillsDir)
for (const skill of bundle.agentSkills) {
validatePathSafe(skill.name, "agent skill")
const destDir = path.join(skillsDir, sanitizePathName(skill.name))
const resolvedDest = path.resolve(destDir)
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
console.warn(`Warning: Agent skill name "${skill.name}" escapes skills/. Skipping.`)
continue
}
await ensureDir(destDir)
await writeText(path.join(destDir, "SKILL.md"), skill.content)
}
}
// Write command workflows (flat in global_workflows/ for global scope, workflows/ for workspace)
if (bundle.commandWorkflows.length > 0) {
const workflowsDirName = scope === "global" ? "global_workflows" : "workflows"
const workflowsDir = path.join(outputRoot, workflowsDirName)
await ensureDir(workflowsDir)
for (const workflow of bundle.commandWorkflows) {
validatePathSafe(workflow.name, "command workflow")
const content = formatWorkflowContent(workflow.name, workflow.description, workflow.body)
await writeText(path.join(workflowsDir, `${workflow.name}.md`), content)
}
}
// Copy pass-through skill directories (after generated skills so copies overwrite on collision)
if (bundle.skillDirs.length > 0) {
const skillsDir = path.join(outputRoot, "skills")
await ensureDir(skillsDir)
for (const skill of bundle.skillDirs) {
validatePathSafe(skill.name, "skill directory")
const destDir = path.join(skillsDir, sanitizePathName(skill.name))
const resolvedDest = path.resolve(destDir)
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
console.warn(`Warning: Skill name "${skill.name}" escapes skills/. Skipping.`)
continue
}
const knownAgentNames = bundle.agentSkills.map((s) => s.name)
await copySkillDir(skill.sourceDir, destDir, (content) =>
transformContentForWindsurf(content, knownAgentNames),
)
}
}
// Merge MCP config
if (bundle.mcpConfig) {
const mcpPath = path.join(outputRoot, "mcp_config.json")
const backupPath = await backupFile(mcpPath)
if (backupPath) {
console.log(`Backed up existing mcp_config.json to ${backupPath}`)
}
let existingConfig: Record<string, unknown> = {}
if (await pathExists(mcpPath)) {
try {
const parsed = await readJson<unknown>(mcpPath)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
existingConfig = parsed as Record<string, unknown>
}
} catch {
console.warn("Warning: existing mcp_config.json could not be parsed and will be replaced.")
}
}
const existingServers =
existingConfig.mcpServers &&
typeof existingConfig.mcpServers === "object" &&
!Array.isArray(existingConfig.mcpServers)
? (existingConfig.mcpServers as Record<string, unknown>)
: {}
const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } }
await writeJsonSecure(mcpPath, merged)
}
}
function validatePathSafe(name: string, label: string): void {
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
throw new Error(`${label} name contains unsafe path characters: ${name}`)
}
}
function formatWorkflowContent(name: string, description: string, body: string): string {
return formatFrontmatter({ description }, `# ${name}\n\n${body}`) + "\n"
}

View File

@@ -22,10 +22,19 @@ export type CodexGeneratedSkillSidecarDir = {
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>
}

View File

@@ -24,6 +24,7 @@ export type CopilotMcpServer = {
}
export type CopilotBundle = {
pluginName?: string
agents: CopilotAgent[]
generatedSkills: CopilotGeneratedSkill[]
skillDirs: CopilotSkillDir[]

View File

@@ -14,6 +14,7 @@ export type DroidSkillDir = {
}
export type DroidBundle = {
pluginName?: string
commands: DroidCommandFile[]
droids: DroidAgentFile[]
skillDirs: DroidSkillDir[]

View File

@@ -13,6 +13,11 @@ export type GeminiCommand = {
content: string // Full TOML content
}
export type GeminiAgent = {
name: string
content: string // Full agent Markdown file with YAML frontmatter
}
export type GeminiMcpServer = {
command?: string
args?: string[]
@@ -22,8 +27,10 @@ export type GeminiMcpServer = {
}
export type GeminiBundle = {
generatedSkills: GeminiSkill[] // From agents
pluginName?: string
generatedSkills: GeminiSkill[] // Target-specific generated skills, if any
skillDirs: GeminiSkillDir[] // From skills (pass-through)
agents?: GeminiAgent[] // From Claude agents
commands: GeminiCommand[]
mcpServers?: Record<string, GeminiMcpServer>
}

View File

@@ -38,6 +38,7 @@ export type KiroMcpServer = {
}
export type KiroBundle = {
pluginName?: string
agents: KiroAgent[]
generatedSkills: KiroSkill[]
skillDirs: KiroSkillDir[]

View File

@@ -1,54 +0,0 @@
export type OpenClawPluginManifest = {
id: string
name: string
kind: "tool"
configSchema: OpenClawConfigSchema
uiHints?: Record<string, OpenClawUiHint>
skills?: string[]
}
export type OpenClawConfigSchema = {
type: "object"
properties: Record<string, OpenClawConfigProperty>
additionalProperties?: boolean
required?: string[]
}
export type OpenClawConfigProperty = {
type: string
description?: string
default?: unknown
}
export type OpenClawUiHint = {
label: string
sensitive?: boolean
placeholder?: string
}
export type OpenClawSkillFile = {
name: string
content: string
/** Subdirectory path inside skills/ (e.g. "agent-native-reviewer") */
dir: string
}
export type OpenClawCommandRegistration = {
name: string
description: string
acceptsArgs: boolean
/** The prompt body that becomes the command handler response */
body: string
}
export type OpenClawBundle = {
manifest: OpenClawPluginManifest
packageJson: Record<string, unknown>
entryPoint: string
skills: OpenClawSkillFile[]
/** Skill directories to copy verbatim (original Claude skills with references/) */
skillDirCopies: { sourceDir: string; name: string }[]
commands: OpenClawCommandRegistration[]
/** openclaw.json fragment for MCP servers */
openclawConfig?: Record<string, unknown>
}

View File

@@ -4,19 +4,37 @@ 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"
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>
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
}
export type OpenCodeSkillsConfig = {
paths?: string[]
urls?: string[]
}
export type OpenCodeMcpServer = {
@@ -44,6 +62,7 @@ export type OpenCodeCommandFile = {
}
export type OpenCodeBundle = {
pluginName?: string
config: OpenCodeConfig
agents: OpenCodeAgentFile[]
// Commands are written as individual .md files, not in opencode.json. See ADR-001.

View File

@@ -32,6 +32,7 @@ export type PiMcporterConfig = {
}
export type PiBundle = {
pluginName?: string
prompts: PiPrompt[]
skillDirs: PiSkillDir[]
generatedSkills: PiGeneratedSkill[]

View File

@@ -1,51 +0,0 @@
export type QwenExtensionConfig = {
name: string
version: string
mcpServers?: Record<string, QwenMcpServer>
contextFileName?: string
commands?: string
skills?: string
agents?: string
settings?: QwenSetting[]
}
export type QwenMcpServer = {
command?: string
args?: string[]
env?: Record<string, string>
cwd?: string
httpUrl?: string
url?: string
headers?: Record<string, string>
}
export type QwenSetting = {
name: string
description: string
envVar: string
sensitive?: boolean
}
export type QwenAgentFile = {
name: string
content: string
format: "yaml" | "markdown"
}
export type QwenSkillDir = {
sourceDir: string
name: string
}
export type QwenCommandFile = {
name: string
content: string
}
export type QwenBundle = {
config: QwenExtensionConfig
agents: QwenAgentFile[]
commandFiles: QwenCommandFile[]
skillDirs: QwenSkillDir[]
contextFile?: string
}

View File

@@ -1,35 +0,0 @@
export type WindsurfWorkflow = {
name: string
description: string
body: string
}
export type WindsurfGeneratedSkill = {
name: string
content: string
}
export type WindsurfSkillDir = {
name: string
sourceDir: string
}
export type WindsurfMcpServerEntry = {
command?: string
args?: string[]
env?: Record<string, string>
serverUrl?: string
url?: string
headers?: Record<string, string>
}
export type WindsurfMcpConfig = {
mcpServers: Record<string, WindsurfMcpServerEntry>
}
export type WindsurfBundle = {
agentSkills: WindsurfGeneratedSkill[]
commandWorkflows: WindsurfWorkflow[]
skillDirs: WindsurfSkillDir[]
mcpConfig: WindsurfMcpConfig | null
}

View File

@@ -1,6 +1,7 @@
export type CodexInvocationTargets = {
promptTargets: Record<string, string>
skillTargets: Record<string, string>
agentTargets?: Record<string, string>
}
export type CodexTransformOptions = {
@@ -27,20 +28,34 @@ export function transformContentForCodex(
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.
// 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)
const trimmedArgs = args.trim()
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
@@ -65,6 +80,8 @@ export function transformContentForCodex(
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`
})
@@ -72,6 +89,21 @@ export function transformContentForCodex(
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"

View File

@@ -1,6 +1,7 @@
import os from "os"
import path from "path"
import { pathExists } from "./files"
import { syncTargets } from "../sync/registry"
import { resolveOpenCodeGlobalRoot } from "./opencode-config"
export type DetectedTool = {
name: string
@@ -8,12 +9,78 @@ export type DetectedTool = {
reason: string
}
type DetectableTool = {
name: string
detectPaths: (home: string, cwd: string) => string[]
}
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) => [path.join(home, ".codex")],
},
{
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: "gemini",
detectPaths: (home, cwd) => [
path.join(cwd, ".gemini"),
path.join(home, ".gemini"),
],
},
{
name: "kiro",
detectPaths: (home, cwd) => [
path.join(home, ".kiro"),
path.join(cwd, ".kiro"),
],
},
{
name: "qwen",
detectPaths: (home, cwd) => [
path.join(home, ".qwen"),
path.join(cwd, ".qwen"),
],
},
]
export async function detectInstalledTools(
home: string = os.homedir(),
cwd: string = process.cwd(),
): Promise<DetectedTool[]> {
const results: DetectedTool[] = []
for (const target of syncTargets) {
for (const target of detectableTools) {
let detected = false
let reason = "not found"
for (const p of target.detectPaths(home, cwd)) {

View File

@@ -85,6 +85,43 @@ 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
}
/**
* Resolve a colon-separated command name into a filesystem path.
* e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md"

View File

@@ -1,5 +1,5 @@
import path from "path"
import { pathExists, readJson, writeJsonSecure } from "../utils/files"
import { pathExists, readJson, writeJsonSecure } from "./files"
type JsonObject = Record<string, unknown>
@@ -19,7 +19,7 @@ export async function mergeJsonConfigAtKey(options: {
...existing,
[key]: {
...existingEntries,
...incoming, // incoming plugin entries overwrite same-named servers
...incoming,
},
}

View File

@@ -20,9 +20,30 @@ import { parseFrontmatter } from "./frontmatter"
/** Old skill directory names that no longer exist after the v3 rename. */
const STALE_SKILL_DIRS = [
// ce: -> ce- (dirs were already hyphenated by sanitizePathName, so these
// only collide if the old name was exactly the same after sanitization —
// which it was for all 8 workflow skills. No orphans from this group.)
// ce: -> ce-. Some targets sanitized these to ce-*; others left raw colon
// directories on filesystems that permit them.
"ce:brainstorm",
"ce:compound",
"ce:compound-refresh",
"ce:ideate",
"ce:plan",
"ce:plan-beta",
"ce:review",
"ce:review-beta",
"ce:work",
"ce:work-beta",
// workflows:* -> ce-*.
"workflows:brainstorm",
"workflows:compound",
"workflows:plan",
"workflows:review",
"workflows:work",
"workflows-brainstorm",
"workflows-compound",
"workflows-plan",
"workflows-review",
"workflows-work",
// git-* -> ce-*
"git-commit",
@@ -62,6 +83,8 @@ const STALE_SKILL_DIRS = [
// ce-review -> ce-code-review, ce-document-review -> ce-doc-review
"ce-review",
"ce-document-review",
"ce-plan-beta",
"ce-review-beta",
]
/** Old agent names (used as generated skill dirs or flat .md files). */
@@ -225,6 +248,14 @@ const LEGACY_ONLY_SKILL_DESCRIPTIONS: Record<string, string> = {
"This skill should be used when orchestrating multi-agent swarms using Claude Code's TeammateTool and Task system. It applies when coordinating multiple agents, running parallel code reviews, creating pipeline workflows with dependencies, building self-organizing task queues, or any task benefiting from divide-and-conquer patterns.",
"reproduce-bug":
"Systematically reproduce and investigate a bug from a GitHub issue. Use when the user provides a GitHub issue number or URL for a bug they want reproduced or investigated.",
"ce:plan-beta":
"[BETA] Transform feature descriptions or requirements into structured implementation plans grounded in repo patterns and research. Use when the user says 'plan this', 'create a plan', 'write a tech plan', 'plan the implementation', 'how should we build', 'what's the approach for', 'break this down', or when a brainstorm/requirements document is ready for technical planning. Best when requirements are at least roughly defined; for exploratory or ambiguous requests, prefer ce:brainstorm first.",
"ce-plan-beta":
"[BETA] Transform feature descriptions or requirements into structured implementation plans grounded in repo patterns and research. Use when the user says 'plan this', 'create a plan', 'write a tech plan', 'plan the implementation', 'how should we build', 'what's the approach for', 'break this down', or when a brainstorm/requirements document is ready for technical planning. Best when requirements are at least roughly defined; for exploratory or ambiguous requests, prefer ce:brainstorm first.",
"ce:review-beta":
"[BETA] Structured code review using tiered persona agents, confidence-gated findings, and a merge/dedup pipeline. Use when reviewing code changes before creating a PR.",
"ce-review-beta":
"[BETA] Structured code review using tiered persona agents, confidence-gated findings, and a merge/dedup pipeline. Use when reviewing code changes before creating a PR.",
}
/**
@@ -248,6 +279,19 @@ type LegacyFingerprints = {
let legacyFingerprintsPromise: Promise<LegacyFingerprints> | null = null
function currentSkillNameForLegacy(legacyName: string): string {
if (legacyName === "ce:review" || legacyName === "workflows:review" || legacyName === "workflows-review") {
return "ce-code-review"
}
if (legacyName.startsWith("ce:")) {
return legacyName.replace(/^ce:/, "ce-")
}
if (legacyName.startsWith("workflows:")) {
return `ce-${legacyName.slice("workflows:".length)}`
}
if (legacyName.startsWith("workflows-")) {
return `ce-${legacyName.slice("workflows-".length)}`
}
switch (legacyName) {
case "git-commit":
return "ce-commit"

View File

@@ -48,8 +48,8 @@ export function addProviderPrefix(model: string): string {
}
/**
* Normalize a model for targets that use provider-prefixed IDs
* (OpenCode, OpenClaw). Resolves bare aliases and adds provider prefix.
* Normalize a model for targets that use provider-prefixed IDs.
* Resolves bare aliases and adds provider prefix.
*
* "sonnet" -> "anthropic/claude-sonnet-4-6"
* "claude-sonnet-4-20250514" -> "anthropic/claude-sonnet-4-20250514"
@@ -66,4 +66,3 @@ export function normalizeModelWithProvider(model: string): string {
}
return addProviderPrefix(resolved)
}

View File

@@ -0,0 +1,25 @@
import os from "os"
import path from "path"
import { expandHome } from "./resolve-home"
/**
* Resolve the OpenCode global-config root.
*
* Order of precedence:
* 1. `OPENCODE_CONFIG_DIR` environment variable (NixOS, Docker, non-default
* `XDG_CONFIG_HOME` setups).
* 2. `~/.config/opencode` (XDG default).
*
* See: https://opencode.ai/docs/config/
*
* Both `install` and `cleanup` must agree on this resolution so that an
* install at `OPENCODE_CONFIG_DIR=/custom/path` is later cleaned at the same
* location.
*/
export function resolveOpenCodeGlobalRoot(): string {
const envDir = process.env.OPENCODE_CONFIG_DIR?.trim()
if (envDir) {
return path.resolve(expandHome(envDir))
}
return path.join(os.homedir(), ".config", "opencode")
}

View File

@@ -1,50 +1,49 @@
import os from "os"
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
openclawHome?: string
qwenHome?: string
pluginName?: string
hasExplicitOutput: boolean
scope?: TargetScope
}): string {
const { targetName, outputRoot, codexHome, piHome, openclawHome, qwenHome, pluginName, hasExplicitOutput, scope } = options
const { targetName, outputRoot, codexHome, piHome, hasExplicitOutput } = options
if (targetName === "codex") return codexHome
if (targetName === "pi") return piHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".cursor")
}
if (targetName === "gemini") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".gemini")
}
if (targetName === "copilot") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".github")
}
if (targetName === "kiro") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".kiro")
}
if (targetName === "windsurf") {
if (hasExplicitOutput) return outputRoot
if (scope === "global") return path.join(os.homedir(), ".codeium", "windsurf")
return path.join(process.cwd(), ".windsurf")
}
if (targetName === "openclaw") {
const home = openclawHome ?? path.join(os.homedir(), ".openclaw", "extensions")
return path.join(home, pluginName ?? "plugin")
}
if (targetName === "qwen") {
const home = qwenHome ?? path.join(os.homedir(), ".qwen", "extensions")
return path.join(home, pluginName ?? "plugin")
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
}