refactor(install): prefer native plugin install across targets (#609)
Co-authored-by: John Cavanaugh <cavanaug@users.noreply.github.com>
This commit is contained in:
708
src/commands/cleanup.ts
Normal file
708
src/commands/cleanup.ts
Normal 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")
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(" ")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,6 +35,7 @@ export function convertClaudeToPi(
|
||||
]
|
||||
|
||||
return {
|
||||
pluginName: plugin.manifest.name,
|
||||
prompts,
|
||||
skillDirs: platformSkills.map((skill) => ({
|
||||
name: skill.name,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
620
src/data/plugin-legacy-artifacts.ts
Normal file
620
src/data/plugin-legacy-artifacts.ts
Normal 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}`
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
212
src/targets/managed-artifacts.ts
Normal file
212
src/targets/managed-artifacts.ts
Normal 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("/"))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type CopilotMcpServer = {
|
||||
}
|
||||
|
||||
export type CopilotBundle = {
|
||||
pluginName?: string
|
||||
agents: CopilotAgent[]
|
||||
generatedSkills: CopilotGeneratedSkill[]
|
||||
skillDirs: CopilotSkillDir[]
|
||||
|
||||
@@ -14,6 +14,7 @@ export type DroidSkillDir = {
|
||||
}
|
||||
|
||||
export type DroidBundle = {
|
||||
pluginName?: string
|
||||
commands: DroidCommandFile[]
|
||||
droids: DroidAgentFile[]
|
||||
skillDirs: DroidSkillDir[]
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export type KiroMcpServer = {
|
||||
}
|
||||
|
||||
export type KiroBundle = {
|
||||
pluginName?: string
|
||||
agents: KiroAgent[]
|
||||
generatedSkills: KiroSkill[]
|
||||
skillDirs: KiroSkillDir[]
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -32,6 +32,7 @@ export type PiMcporterConfig = {
|
||||
}
|
||||
|
||||
export type PiBundle = {
|
||||
pluginName?: string
|
||||
prompts: PiPrompt[]
|
||||
skillDirs: PiSkillDir[]
|
||||
generatedSkills: PiGeneratedSkill[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
25
src/utils/opencode-config.ts
Normal file
25
src/utils/opencode-config.ts
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user