diff --git a/.codebuddy-plugin/marketplace.json b/.codebuddy-plugin/marketplace.json new file mode 100644 index 000000000..37c15c02d --- /dev/null +++ b/.codebuddy-plugin/marketplace.json @@ -0,0 +1,33 @@ +{ + "name": "compound-engineering-plugin", + "owner": { + "name": "studyzy (Devin Zeng)" + }, + "description": "Compound Engineering plugin marketplace for CodeBuddy", + "plugins": [ + { + "name": "compound-engineering", + "description": "Brainstorm, plan, debug, review, and compound learnings with AI agents", + "version": "3.14.3", + "author": { + "name": "studyzy (Devin Zeng)" + }, + "homepage": "https://github.com/studyzy", + "repository": "https://github.com/EveryInc/compound-engineering-plugin", + "license": "MIT", + "keywords": [ + "brainstorming", + "code-review", + "compound-engineering", + "debugging", + "ideation", + "workflows", + "workflow-automation" + ], + "source": { + "source": "github", + "repo": "EveryInc/compound-engineering-plugin" + } + } + ] +} diff --git a/.codebuddy-plugin/plugin.json b/.codebuddy-plugin/plugin.json new file mode 100644 index 000000000..a51c43937 --- /dev/null +++ b/.codebuddy-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "compound-engineering", + "version": "3.14.3", + "description": "Brainstorm, plan, debug, review, and compound learnings with AI agents", + "author": { + "name": "studyzy (Devin Zeng)", + "email": "studyzy@gmail.com" + }, + "homepage": "https://github.com/studyzy", + "repository": "https://github.com/EveryInc/compound-engineering-plugin", + "license": "MIT", + "keywords": [ + "brainstorming", + "code-review", + "compound-engineering", + "debugging", + "ideation", + "workflows", + "workflow-automation" + ], + "skills": "./skills/" +} diff --git a/.github/release-please-config.json b/.github/release-please-config.json index d23c75c9a..1c71c8331 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -46,7 +46,8 @@ ".agents/plugins/marketplace.json", ".kimi-plugin/marketplace.json", ".claude-plugin/marketplace.json", - ".cursor-plugin/marketplace.json" + ".cursor-plugin/marketplace.json", + ".codebuddy-plugin/marketplace.json" ], "extra-files": [ { @@ -78,6 +79,11 @@ "type": "json", "path": ".agy/plugin.json", "jsonpath": "$.version" + }, + { + "type": "json", + "path": ".codebuddy-plugin/plugin.json", + "jsonpath": "$.version" } ] }, diff --git a/.gitignore b/.gitignore index 36adc39e3..8baa2073b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ todos/ .claude/worktrees/ __pycache__/ *.pyc - +.idea/ +.serena/ .compound-engineering/*.local.yaml diff --git a/README.md b/README.md index 6f1b697a5..dedbe7601 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,24 @@ qwen extensions install EveryInc/compound-engineering-plugin:compound-engineerin Qwen Code installs Claude Code-compatible plugins directly from GitHub and converts the plugin format during install, so no Bun install step is needed. +### CodeBuddy + +Compound Engineering is available as a CodeBuddy plugin. Register the marketplace, then install the plugin. + +1. **Register the marketplace with CodeBuddy:** + + ```bash + codebuddy plugin marketplace add EveryInc/compound-engineering-plugin + ``` + +2. **Install the plugin:** + + ```bash + codebuddy plugin install compound-engineering@compound-engineering-plugin + ``` + +The native CodeBuddy plugin install is self-contained for Compound Engineering. Specialist reviewer and research behavior lives inside the skills as local prompt assets; no separate custom-agent install step is required. + ### OpenCode Add Compound Engineering to the `plugin` array in your global or project `opencode.json`: @@ -418,6 +436,15 @@ Restart OpenCode after changing `opencode.json`. pi -e "$PWD" ``` +**CodeBuddy** + +```bash +codebuddy plugin marketplace add "$PWD" +codebuddy plugin install compound-engineering@compound-engineering-plugin +``` + +Use a separate `CODEBUDDY_HOME` when you want to keep local testing isolated from your normal CodeBuddy profile. + **Antigravity CLI (`agy`)** ```bash diff --git a/src/commands/convert.ts b/src/commands/convert.ts index eb3aeae55..7fd342013 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -5,7 +5,7 @@ import { loadClaudePlugin } from "../parsers/claude" import { targets, validateScope } from "../targets" import type { ClaudeToOpenCodeOptions, PermissionMode } from "../converters/claude-to-opencode" import { ensureCodexAgentsFile } from "../utils/codex-agents" -import { expandHome, resolveCodexHome, resolveTargetHome } from "../utils/resolve-home" +import { expandHome, resolveCodeBuddyHome, resolveCodexHome, resolveTargetHome } from "../utils/resolve-home" import { resolveOpenCodeWriteScope, resolveTargetOutputRoot } from "../utils/resolve-output" import { detectInstalledTools } from "../utils/detect-tools" @@ -25,7 +25,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | pi | antigravity | all)", + description: "Target format (opencode | codex | codebuddy | pi | antigravity | all)", }, output: { type: "string", @@ -37,6 +37,11 @@ export default defineCommand({ alias: "codex-home", description: "Write Codex output to this Codex root (default: $CODEX_HOME or ~/.codex)", }, + codebuddyHome: { + type: "string", + alias: "codebuddy-home", + description: "Write CodeBuddy output to this CodeBuddy root (default: ~/.codebuddy)", + }, piHome: { type: "string", alias: "pi-home", @@ -84,6 +89,7 @@ export default defineCommand({ const outputRoot = resolveOutputRoot(args.output) const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) const codexHome = resolveCodexHome(args.codexHome) + const codebuddyHome = resolveCodeBuddyHome(args.codebuddyHome) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) const options: ClaudeToOpenCodeOptions = { @@ -126,6 +132,7 @@ export default defineCommand({ targetName: tool.name, outputRoot, codexHome, + codebuddyHome, piHome, pluginName: plugin.manifest.name, hasExplicitOutput, @@ -157,6 +164,7 @@ export default defineCommand({ targetName, outputRoot, codexHome, + codebuddyHome, piHome, pluginName: plugin.manifest.name, hasExplicitOutput, @@ -193,6 +201,7 @@ export default defineCommand({ targetName: extra, outputRoot, codexHome, + codebuddyHome, piHome, pluginName: plugin.manifest.name, hasExplicitOutput, diff --git a/src/converters/claude-to-codebuddy.ts b/src/converters/claude-to-codebuddy.ts new file mode 100644 index 000000000..015ede74f --- /dev/null +++ b/src/converters/claude-to-codebuddy.ts @@ -0,0 +1,185 @@ +import fs, { type Dirent } from "fs" +import path from "path" +import { formatFrontmatter } from "../utils/frontmatter" +import { type ClaudeAgent, type ClaudeCommand, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude" +import type { CodeBuddyAgent, CodeBuddyBundle, CodeBuddyGeneratedSkill, CodeBuddyGeneratedSkillSidecarDir } from "../types/codebuddy" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToCodeBuddyOptions = ClaudeToOpenCodeOptions + +const CODEBUDDY_DESCRIPTION_MAX_LENGTH = 1024 + +export function convertClaudeToCodeBuddy( + plugin: ClaudePlugin, + options: ClaudeToCodeBuddyOptions, +): CodeBuddyBundle { + const includeSkills = options.codexIncludeSkills ?? false + + const platformSkills = filterSkillsByPlatform(plugin.skills, "codebuddy") + const invocableCommands = plugin.commands.filter((command) => !command.disableModelInvocation) + const copiedSkills = platformSkills + const skillDirs = copiedSkills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })) + const promptNames = new Set() + const usedSkillNames = new Set(skillDirs.map((skill) => normalizeCodeBuddyName(skill.name))) + + const commandPromptNames = new Map() + for (const command of invocableCommands) { + commandPromptNames.set( + command.name, + uniqueName(normalizeCodeBuddyName(command.name), promptNames), + ) + } + + const agents = plugin.agents.map(convertAgent) + + if (!includeSkills) { + const externallyManagedSkillNames = copiedSkills.map((skill) => skill.name) + return { + pluginName: plugin.manifest.name, + prompts: [], + skillDirs: [], + generatedSkills: [], + agents, + mcpServers: undefined, + hooks: plugin.hooks, + externallyManagedSkillNames, + } + } + + const commandSkills: CodeBuddyGeneratedSkill[] = [] + const prompts = invocableCommands.map((command) => { + const promptName = commandPromptNames.get(command.name)! + const commandSkill = convertCommandSkill(command, usedSkillNames) + commandSkills.push(commandSkill) + const content = renderPrompt(command, commandSkill.name) + return { name: promptName, content } + }) + + return { + pluginName: plugin.manifest.name, + prompts, + skillDirs, + generatedSkills: [...commandSkills], + agents, + mcpServers: plugin.mcpServers, + hooks: plugin.hooks, + } +} + +function convertAgent(agent: ClaudeAgent): CodeBuddyAgent { + const name = buildCodeBuddyAgentName(agent) + const description = sanitizeDescription( + agent.description ?? `Converted from Claude agent ${agent.name}`, + ) + let instructions = agent.body.trim() + if (agent.capabilities && agent.capabilities.length > 0) { + const capabilities = agent.capabilities.map((capability) => `- ${capability}`).join("\n") + instructions = `## Capabilities\n${capabilities}\n\n${instructions}`.trim() + } + if (instructions.length === 0) { + instructions = `Instructions converted from the ${agent.name} agent.` + } + + return { name, description, instructions, sidecarDirs: collectReferencedSidecarDirs(agent) } +} + +function convertCommandSkill( + command: ClaudeCommand, + usedNames: Set, +): CodeBuddyGeneratedSkill { + const name = uniqueName(normalizeCodeBuddyName(command.name), usedNames) + const frontmatter: Record = { + name, + description: sanitizeDescription( + command.description ?? `Converted from Claude command ${command.name}`, + ), + } + const sections: string[] = [] + if (command.argumentHint) { + sections.push(`## Arguments\n${command.argumentHint}`) + } + if (command.allowedTools && command.allowedTools.length > 0) { + sections.push(`## Allowed tools\n${command.allowedTools.map((tool) => `- ${tool}`).join("\n")}`) + } + const body = [...sections, command.body.trim()].filter(Boolean).join("\n\n").trim() + const content = formatFrontmatter(frontmatter, body.length > 0 ? body : command.body) + return { name, content } +} + +function renderPrompt( + command: ClaudeCommand, + skillName: string, +): string { + const frontmatter: Record = { + description: command.description, + "argument-hint": command.argumentHint, + } + const instructions = `Use the /${skillName} skill for this command and follow its instructions.` + const body = [instructions, "", command.body].join("\n").trim() + return formatFrontmatter(frontmatter, body) +} + +function buildCodeBuddyAgentName(agent: ClaudeAgent): string { + const category = getAgentCategory(agent) + const agentName = normalizeCodeBuddyName(agent.name) + return category ? `${normalizeCodeBuddyName(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 = CODEBUDDY_DESCRIPTION_MAX_LENGTH): string { + const normalized = value.replace(/\s+/g, " ").trim() + if (normalized.length <= maxLength) return normalized + const ellipsis = "..." + return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis +} + +function normalizeCodeBuddyName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +function uniqueName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base) + return base + } + let index = 2 + while (used.has(`${base}-${index}`)) { + index += 1 + } + const name = `${base}-${index}` + used.add(name) + return name +} + +function collectReferencedSidecarDirs(agent: ClaudeAgent): CodeBuddyGeneratedSkillSidecarDir[] { + const sourceDir = path.dirname(agent.sourcePath) + let entries: Dirent[] + + try { + entries = fs.readdirSync(sourceDir, { withFileTypes: true }) + } catch { + return [] + } + + return entries + .filter((entry) => entry.isDirectory()) + .filter((entry) => agent.body.includes(`${entry.name}/`) || agent.body.includes(`\`${entry.name}\``)) + .map((entry) => ({ + sourceDir: path.join(sourceDir, entry.name), + targetName: entry.name, + })) +} diff --git a/src/data/plugin-legacy-artifacts.ts b/src/data/plugin-legacy-artifacts.ts index b7e494c55..94a3c06ab 100644 --- a/src/data/plugin-legacy-artifacts.ts +++ b/src/data/plugin-legacy-artifacts.ts @@ -1,4 +1,5 @@ import type { CodexBundle } from "../types/codex" +import type { CodeBuddyBundle } from "../types/codebuddy" import type { CopilotBundle } from "../types/copilot" import type { DroidBundle } from "../types/droid" import type { ClaudePlugin } from "../types/claude" @@ -398,6 +399,45 @@ export function getLegacyCodexArtifacts(bundle: CodexBundle): LegacyTargetArtifa } } +export function getLegacyCodeBuddyArtifacts(bundle: CodeBuddyBundle): LegacyTargetArtifacts { + const skills = new Set() + const prompts = new Set() + const agents = new Set() + const currentPromptFiles = new Set() + const currentAgentFiles = new Set((bundle.agents ?? []).map((agent) => `${sanitizePathName(agent.name)}.toml`)) + + for (const prompt of bundle.prompts) { + currentPromptFiles.add(`${sanitizePathName(prompt.name)}.md`) + } + + const extras = getLegacyPluginArtifacts(bundle.pluginName) + for (const name of extras.skills ?? []) { + addLegacySkillVariants(skills, name, { includeRawColon: true }) + } + for (const name of extras.agents ?? []) { + const normalized = sanitizePathName(name) + skills.add(normalized) + const agentFile = `${normalized}.toml` + if (!currentAgentFiles.has(agentFile)) { + agents.add(agentFile) + } + } + for (const name of extras.commands ?? []) { + const normalized = sanitizePathName(name) + skills.add(normalized) + const promptFile = `${normalized}.md` + if (!currentPromptFiles.has(promptFile)) { + prompts.add(promptFile) + } + } + + return { + skills: [...skills].sort(), + prompts: [...prompts].sort(), + agents: [...agents].sort(), + } +} + export function getLegacyPiArtifacts(bundle: PiBundle): LegacyTargetArtifacts { const skills = new Set() const prompts = new Set() diff --git a/src/release/components.ts b/src/release/components.ts index abb28d63c..bacd2ead7 100644 --- a/src/release/components.ts +++ b/src/release/components.ts @@ -25,8 +25,10 @@ const FILE_COMPONENT_MAP: Array<{ component: ReleaseComponent; prefixes: string[ ".kimi-plugin/plugin.json", ".opencode/", ".pi/", + ".codebuddy-plugin/", "AGENTS.md", "CLAUDE.md", + "CODEBUDDY.md", ".agy/", "GEMINI.md", // retained: agy still reads the Gemini-format context file "README.md", diff --git a/src/release/metadata.ts b/src/release/metadata.ts index 2f99ed518..1eb827b29 100644 --- a/src/release/metadata.ts +++ b/src/release/metadata.ts @@ -33,6 +33,13 @@ type KimiPluginManifest = { skills?: string } +type CodeBuddyPluginManifest = { + name: string + version: string + description?: string + skills?: string +} + type AntigravityManifest = { version: string } @@ -70,6 +77,18 @@ type KimiMarketplaceManifest = { }> } +type CodeBuddyMarketplaceManifest = { + name: string + owner?: { name?: string } + description?: string + plugins: Array<{ + name: string + version?: string + description?: string + source?: { source?: string; repo?: string } + }> +} + type SyncOptions = { root?: string componentVersions?: Partial> @@ -220,8 +239,10 @@ export async function syncReleaseMetadata(options: SyncOptions = {}): Promise(compoundPackagePath) const compoundClaude = await readJson(compoundClaudePath) @@ -479,5 +500,71 @@ export async function syncReleaseMetadata(options: SyncOptions = {}): Promise(compoundCodeBuddyPath) + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + codebuddyManifestMissing = true + errors.push(`${compoundCodeBuddyPath} is missing but ${compoundClaudePath} exists. CodeBuddy manifest parity required.`) + updates.push({ path: compoundCodeBuddyPath, changed: false }) + codebuddy = { name: "compound-engineering", version: compoundClaude.version } + } else { + throw err + } + } + + if (codebuddy.name !== "compound-engineering") { + errors.push(`${compoundCodeBuddyPath}: name "${codebuddy.name}" does not match expected "compound-engineering"`) + } + + let codebuddyChanged = false + if (codebuddy.version !== compoundClaude.version) { + codebuddyChanged = true + } + if (compoundClaude.description !== undefined && codebuddy.description !== compoundClaude.description) { + codebuddy.description = compoundClaude.description + codebuddyChanged = true + } + await validateDeclaredSkillsPath(compoundCodeBuddyPath, "compound-engineering", "CodeBuddy", codebuddy.skills, errors) + updates.push({ path: compoundCodeBuddyPath, changed: codebuddyChanged }) + if (write && codebuddyChanged && !codebuddyManifestMissing) await writeJson(compoundCodeBuddyPath, codebuddy) + + try { + const marketplaceCodeBuddy = await readJson(marketplaceCodeBuddyPath) + const claudeNames = [...marketplaceClaude.plugins.map((p) => p.name)].sort() + const codebuddyNames = [...marketplaceCodeBuddy.plugins.map((p) => p.name)].sort() + if (claudeNames.join("|") !== codebuddyNames.join("|")) { + errors.push( + `${marketplaceCodeBuddyPath}: plugin list [${codebuddyNames.join(", ")}] does not match ${marketplaceClaudePath} [${claudeNames.join(", ")}]`, + ) + } + for (const plugin of marketplaceCodeBuddy.plugins) { + if (typeof plugin.version !== "string" || plugin.version !== compoundClaude.version) { + errors.push( + `${marketplaceCodeBuddyPath}: plugin "${plugin.name}" version "${plugin.version ?? ""}" does not match expected "${compoundClaude.version}".`, + ) + } + if (typeof plugin.source !== "object" || plugin.source.source !== "github" || !plugin.source.repo) { + errors.push( + `${marketplaceCodeBuddyPath}: plugin "${plugin.name}" is missing required source.source="github" and source.repo. CodeBuddy marketplace entries must point to a GitHub repo.`, + ) + } + } + updates.push({ path: marketplaceCodeBuddyPath, changed: false }) + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + errors.push(`${marketplaceCodeBuddyPath} is missing but ${marketplaceClaudePath} exists. CodeBuddy marketplace parity required.`) + updates.push({ path: marketplaceCodeBuddyPath, changed: false }) + } else { + throw err + } + } + return { updates, errors } } diff --git a/src/targets/codebuddy.ts b/src/targets/codebuddy.ts new file mode 100644 index 000000000..c375b0efb --- /dev/null +++ b/src/targets/codebuddy.ts @@ -0,0 +1,516 @@ +import fs from "fs/promises" +import path from "path" +import { backupFile, copyDir, copySkillDir, ensureDir, isSafeManagedPath, pathExists, sanitizePathName, writeJson, writeText, writeTextSecure } from "../utils/files" +import type { CodeBuddyBundle } from "../types/codebuddy" +import type { ClaudeMcpServer } from "../types/claude" +import { getLegacyCodeBuddyArtifacts } from "../data/plugin-legacy-artifacts" +import { classifyCodexLegacyPromptOwnership, isLegacyAgentArtifactOwned, isLegacySkillArtifactOwned } from "../utils/legacy-cleanup" + +const MANAGED_START_MARKER = "# BEGIN Compound Engineering plugin MCP -- do not edit this block" +const MANAGED_END_MARKER = "# END Compound Engineering plugin MCP" +const PREV_START_MARKER = "# BEGIN compound-plugin Claude Code MCP" +const PREV_END_MARKER = "# END compound-plugin Claude Code MCP" +const LEGACY_MARKER = "# MCP servers synced from Claude Code" +const UNMARKED_LEGACY_MARKER = "# Generated by compound-plugin" +const MANAGED_INSTALL_MANIFEST = "install-manifest.json" + +export type CodeBuddyInstallManifest = { + version: 1 + pluginName: string + skills: string[] + prompts: string[] + agents: string[] +} + +export type CodeBuddyWriteOptions = { + outputIsCodeBuddyRoot?: boolean +} + +export async function writeCodeBuddyBundle( + outputRoot: string, + bundle: CodeBuddyBundle, + options: CodeBuddyWriteOptions = {}, +): Promise { + const codebuddyRoot = resolveCodeBuddyRoot(outputRoot, options) + await ensureDir(codebuddyRoot) + + const pluginName = bundle.pluginName ? sanitizeCodeBuddyPathComponent(bundle.pluginName) : undefined + const manifest = pluginName ? await readInstallManifest(codebuddyRoot, pluginName) : null + const currentPrompts = bundle.prompts.map((prompt) => `${sanitizePathName(prompt.name)}.md`) + const agents = bundle.agents ?? [] + const agentsRoot = pluginName + ? path.join(codebuddyRoot, "agents", pluginName) + : path.join(codebuddyRoot, "agents") + const currentAgents = agents.map((agent) => `${sanitizePathName(agent.name)}.toml`) + assertNoCodeBuddyAgentFilenameCollisions(agents) + + if (bundle.prompts.length > 0) { + const promptsDir = path.join(codebuddyRoot, "prompts") + await cleanupRemovedPrompts(promptsDir, manifest, currentPrompts) + for (const prompt of bundle.prompts) { + await writeText(path.join(promptsDir, `${sanitizePathName(prompt.name)}.md`), prompt.content + "\n") + } + } else if (pluginName) { + await cleanupRemovedPrompts(path.join(codebuddyRoot, "prompts"), manifest, []) + } + + const skillsRoot = pluginName + ? path.join(codebuddyRoot, "skills", pluginName) + : path.join(codebuddyRoot, "skills") + const currentSkills = Array.from(new Set([ + ...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)), + ...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)), + ...(bundle.externallyManagedSkillNames ?? []).map((name) => sanitizePathName(name)), + ])) + await cleanupRemovedSkills(skillsRoot, manifest, currentSkills) + + if (bundle.skillDirs.length > 0) { + 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, targetDir) + } + } + + if (bundle.generatedSkills.length > 0) { + 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)) + } + } + } + + await cleanupRemovedAgents(agentsRoot, manifest, currentAgents) + if (agents.length > 0) { + for (const agent of agents) { + const agentBaseName = sanitizePathName(agent.name) + const agentFile = `${agentBaseName}.toml` + if ((agent.sidecarDirs ?? []).length === 0 && isSafeManagedPath(agentsRoot, agentBaseName)) { + await fs.rm(path.join(agentsRoot, agentBaseName), { recursive: true, force: true }) + } + await writeText(path.join(agentsRoot, agentFile), renderCodeBuddyAgentToml(agent) + "\n") + for (const sidecar of agent.sidecarDirs ?? []) { + await copyDir(sidecar.sourceDir, path.join(agentsRoot, agentBaseName, sidecar.targetName)) + } + } + } + + if (pluginName) { + await ensureDir(skillsRoot) + await writeInstallManifest(codebuddyRoot, { + version: 1, + pluginName, + skills: currentSkills, + prompts: currentPrompts, + agents: currentAgents, + }) + await cleanupKnownLegacyCodeBuddyArtifacts(codebuddyRoot, bundle) + await cleanupLegacyAgentSkillDirs(codebuddyRoot, pluginName, currentSkills, bundle) + await cleanupPreviousManagedCodeBuddySkillStore(codebuddyRoot, pluginName) + } + + const configPath = path.join(codebuddyRoot, "settings.json") + const existingConfig = await readFileSafe(configPath) + const mcpJson = renderCodeBuddyMcpConfig(bundle.mcpServers) + const merged = mergeCodeBuddyConfig(existingConfig, mcpJson) + if (merged !== null) { + const backupPath = await backupFile(configPath) + if (backupPath) { + console.log(`Backed up existing config to ${backupPath}`) + } + await writeTextSecure(configPath, merged) + } + + if (pluginName) { + const hooksPath = path.join(codebuddyRoot, "hooks.json") + const existingHooks = await readJsonSafe(hooksPath) + const pluginHooks = bundle.hooks?.hooks ?? {} + const mergedHooks = mergeCodeBuddyHooks(existingHooks, pluginHooks, pluginName) + const mergedContent = JSON.stringify(mergedHooks, null, 2) + "\n" + const existingContent = existingHooks !== null + ? JSON.stringify(existingHooks, null, 2) + "\n" + : null + const hasHooks = Object.keys((mergedHooks.hooks as Record) ?? {}).length > 0 + if ((hasHooks || existingHooks !== null) && mergedContent !== existingContent) { + if (existingHooks !== null) { + const backupPath = await backupFile(hooksPath) + if (backupPath) { + console.log(`Backed up existing hooks to ${backupPath}`) + } + } + await writeTextSecure(hooksPath, mergedContent) + } + } +} + +function resolveCodeBuddyRoot(outputRoot: string, options: CodeBuddyWriteOptions): string { + if (options.outputIsCodeBuddyRoot) return outputRoot + return path.basename(outputRoot) === ".codebuddy" ? outputRoot : path.join(outputRoot, ".codebuddy") +} + +function sanitizeCodeBuddyPathComponent(name: string): string { + return sanitizePathName(name).replace(/[\\/]/g, "-") +} + +export async function readCodeBuddyInstallManifest(codebuddyRoot: string, pluginName: string): Promise { + return readInstallManifest(codebuddyRoot, pluginName) +} + +async function readInstallManifest(codebuddyRoot: string, pluginName: string): Promise { + const manifestPath = path.join(codebuddyRoot, pluginName, MANAGED_INSTALL_MANIFEST) + try { + const raw = await fs.readFile(manifestPath, "utf8") + const parsed = JSON.parse(raw) as Partial + if ( + parsed.version === 1 && + parsed.pluginName === pluginName && + Array.isArray(parsed.skills) && + Array.isArray(parsed.prompts) + ) { + const agents = Array.isArray(parsed.agents) ? parsed.agents : [] + return { + version: 1, + pluginName, + skills: filterSafeCodeBuddyManifestEntries(parsed.skills, codebuddyRoot, manifestPath, "skills"), + prompts: filterSafeCodeBuddyManifestEntries(parsed.prompts, codebuddyRoot, manifestPath, "prompts"), + agents: filterSafeCodeBuddyManifestEntries(agents, codebuddyRoot, manifestPath, "agents"), + } + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn(`Ignoring unreadable CodeBuddy install manifest at ${manifestPath}.`) + } + } + return null +} + +function filterSafeCodeBuddyManifestEntries( + entries: unknown[], + codebuddyRoot: string, + manifestPath: string, + group: string, +): string[] { + const safe: string[] = [] + for (const entry of entries) { + if (isSafeManagedPath(codebuddyRoot, entry)) { + safe.push(entry) + } else { + console.warn( + `Dropping unsafe CodeBuddy install-manifest entry in ${manifestPath} (group "${group}"): ${JSON.stringify(entry)}`, + ) + } + } + return safe +} + +async function writeInstallManifest(codebuddyRoot: string, manifest: CodeBuddyInstallManifest): Promise { + await writeJson(path.join(codebuddyRoot, manifest.pluginName, MANAGED_INSTALL_MANIFEST), manifest) +} + +async function cleanupRemovedSkills( + skillsRoot: string, + manifest: CodeBuddyInstallManifest | null, + currentSkills: string[], +): Promise { + if (!manifest) return + const current = new Set(currentSkills) + for (const skillName of manifest.skills) { + if (current.has(skillName)) continue + if (!isSafeManagedPath(skillsRoot, skillName)) continue + await fs.rm(path.join(skillsRoot, skillName), { recursive: true, force: true }) + } +} + +async function cleanupRemovedPrompts( + promptsDir: string, + manifest: CodeBuddyInstallManifest | null, + currentPrompts: string[], +): Promise { + 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: CodeBuddyInstallManifest | null, + currentAgents: string[], +): Promise { + 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: CodeBuddyInstallManifest | null, + skillName: string, +): Promise { + if (!manifest?.skills.includes(skillName)) return + await fs.rm(targetDir, { recursive: true, force: true }) +} + +async function cleanupKnownLegacyCodeBuddyArtifacts(codebuddyRoot: string, bundle: CodeBuddyBundle): Promise { + const pluginName = bundle.pluginName + if (!pluginName) return + + const legacyArtifacts = getLegacyCodeBuddyArtifacts(bundle) + for (const skillName of legacyArtifacts.skills) { + const legacySkillPath = path.join(codebuddyRoot, "skills", skillName) + if (!(await isLegacySkillArtifactOwned(legacySkillPath, skillName))) continue + await moveLegacyArtifactToBackup(codebuddyRoot, pluginName, "skills", legacySkillPath) + } + + for (const promptFile of legacyArtifacts.prompts) { + const legacyPromptPath = path.join(codebuddyRoot, "prompts", promptFile) + const ownership = await classifyCodexLegacyPromptOwnership(legacyPromptPath) + if (ownership === "foreign") continue + await moveLegacyArtifactToBackup(codebuddyRoot, pluginName, "prompts", legacyPromptPath) + } + + for (const agentFile of legacyArtifacts.agents ?? []) { + await moveLegacyArtifactToBackup( + codebuddyRoot, + pluginName, + "agents", + path.join(codebuddyRoot, "agents", pluginName, agentFile), + ) + const flatAgentPath = path.join(codebuddyRoot, "agents", agentFile) + if (await isLegacyAgentArtifactOwned(flatAgentPath, path.basename(agentFile, ".toml"), ".toml")) { + await moveLegacyArtifactToBackup(codebuddyRoot, pluginName, "agents", flatAgentPath) + } + } +} + +async function cleanupLegacyAgentSkillDirs( + codebuddyRoot: string, + pluginName: string, + currentSkills: string[], + bundle: CodeBuddyBundle, +): Promise { + const currentSkillSet = new Set(currentSkills) + const legacySkillNames = new Set() + for (const agent of bundle.agents ?? []) { + legacySkillNames.add(sanitizePathName(agent.name)) + } + for (const name of getLegacyCodeBuddyArtifacts({ + pluginName, + prompts: [], + skillDirs: [], + generatedSkills: [], + agents: [], + }).skills) { + legacySkillNames.add(name) + } + + const skillsRoot = path.join(codebuddyRoot, "skills", pluginName) + for (const skillName of legacySkillNames) { + if (currentSkillSet.has(skillName)) continue + await moveLegacyArtifactToBackup(codebuddyRoot, pluginName, "skills", path.join(skillsRoot, skillName)) + } +} + +async function cleanupPreviousManagedCodeBuddySkillStore(codebuddyRoot: string, pluginName: string): Promise { + await fs.rm(path.join(codebuddyRoot, pluginName, "skills"), { recursive: true, force: true }) +} + +async function moveLegacyArtifactToBackup( + codebuddyRoot: string, + pluginName: string, + kind: "skills" | "prompts" | "agents", + artifactPath: string, +): Promise { + if (!(await pathExists(artifactPath))) return + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const backupDir = path.join(codebuddyRoot, 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 CodeBuddy ${kind.slice(0, -1)} artifact to ${backupPath}`) +} + +export function renderCodeBuddyMcpConfig(mcpServers?: Record): string | null { + if (!mcpServers || Object.keys(mcpServers).length === 0) return null + + const mcpConfig: Record = {} + for (const [name, server] of Object.entries(mcpServers)) { + if (!server.command && !server.url) continue + const entry: Record = {} + 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 + } + } + mcpConfig[name] = entry + } + + return Object.keys(mcpConfig).length > 0 ? JSON.stringify({ mcpServers: mcpConfig }, null, 2) + "\n" : null +} + +async function readFileSafe(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf-8") + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err + } + return "" + } +} + +export function mergeCodeBuddyConfig(existingContent: string, mcpJson: string | null): string | null { + let existing: Record = {} + if (existingContent) { + try { + existing = JSON.parse(existingContent) + } catch { + return existingContent + } + } + + if (!mcpJson) { + if (!existingContent) return null + return JSON.stringify(existing, null, 2) + "\n" + } + + let newMcpConfig: Record + try { + newMcpConfig = JSON.parse(mcpJson) + } catch { + return existingContent + } + + const existingServers = (existing.mcpServers as Record | undefined) ?? {} + const newServers = (newMcpConfig.mcpServers as Record | undefined) ?? {} + existing.mcpServers = { ...existingServers, ...newServers } + + return JSON.stringify(existing, null, 2) + "\n" +} + +function assertNoCodeBuddyAgentFilenameCollisions( + agents: NonNullable, +): void { + const seen = new Map() + 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( + `CodeBuddy 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 renderCodeBuddyAgentToml(agent: NonNullable[number]): string { + const lines = [ + `name = ${JSON.stringify(agent.name)}`, + `description = ${JSON.stringify(agent.description)}`, + `developer_instructions = ${JSON.stringify(agent.instructions)}`, + ] + return lines.join("\n") +} + +// ── Hooks ────────────────────────────────────────────────── + +async function readJsonSafe(filePath: string): Promise | null> { + try { + const content = await fs.readFile(filePath, "utf8") + return JSON.parse(content) + } catch { + return null + } +} + +type HookEntry = { matcher?: string; hooks: Array<{ type: string; command?: string; prompt?: string; agent?: string; timeout?: number }> } + +type ManagedIndex = Record> + +export function mergeCodeBuddyHooks( + existing: Record | null, + pluginHooks: Record, + pluginName: string, +): Record { + const result: Record = {} + const managed: ManagedIndex = (existing?._managed as ManagedIndex) ?? {} + + const ownedIndices: Record> = {} + if (managed[pluginName]) { + for (const [event, indices] of Object.entries(managed[pluginName])) { + ownedIndices[event] = new Set(indices) + } + } + + const existingHooks = (existing?.hooks ?? {}) as Record + for (const [event, matchers] of Object.entries(existingHooks)) { + if (!Array.isArray(matchers)) continue + const owned = ownedIndices[event] + result[event] = owned + ? matchers.filter((_, idx) => !owned.has(idx)) + : [...matchers] + } + + for (const [event, matchers] of Object.entries(result)) { + result[event] = (matchers as Array>).filter((m) => { + if (typeof m === "object" && m !== null && "_source" in m) { + return m._source !== pluginName + } + return true + }) + } + + const newManagedForPlugin: Record = {} + + for (const [event, matchers] of Object.entries(pluginHooks)) { + if (!result[event]) result[event] = [] + const indices: number[] = [] + for (const matcher of matchers) { + indices.push(result[event].length) + result[event].push({ ...matcher }) + } + if (indices.length > 0) { + newManagedForPlugin[event] = indices + } + } + + for (const event of Object.keys(result)) { + if (result[event].length === 0) delete result[event] + } + + if (Object.keys(newManagedForPlugin).length > 0) { + managed[pluginName] = newManagedForPlugin + } else { + delete managed[pluginName] + } + + const output: Record = { hooks: result } + if (Object.keys(managed).length > 0) { + output._managed = managed + } + return output +} diff --git a/src/targets/index.ts b/src/targets/index.ts index 59fab858b..ce526b39d 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -1,10 +1,12 @@ import type { ClaudePlugin } from "../types/claude" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" +import { convertClaudeToCodeBuddy } from "../converters/claude-to-codebuddy" import { convertClaudeToPi } from "../converters/claude-to-pi" import { convertClaudeToAntigravity } from "../converters/claude-to-antigravity" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" +import { writeCodeBuddyBundle } from "./codebuddy" import { writePiBundle } from "./pi" import { writeAntigravityBundle } from "./antigravity" @@ -73,4 +75,13 @@ export const targets: Record = { convert: convertClaudeToAntigravity as TargetHandler["convert"], write: writeAntigravityBundle as TargetHandler["write"], }, + codebuddy: { + name: "codebuddy", + implemented: true, + convert: convertClaudeToCodeBuddy as TargetHandler["convert"], + write: ((outputRoot, bundle) => + writeCodeBuddyBundle(outputRoot, bundle as Parameters[1], { + outputIsCodeBuddyRoot: true, + })) as TargetHandler["write"], + }, } diff --git a/src/types/codebuddy.ts b/src/types/codebuddy.ts new file mode 100644 index 000000000..9d113b39b --- /dev/null +++ b/src/types/codebuddy.ts @@ -0,0 +1,40 @@ +import type { ClaudeMcpServer, ClaudeHooks } from "./claude" + +export type CodeBuddyPrompt = { + name: string + content: string +} + +export type CodeBuddySkillDir = { + name: string + sourceDir: string +} + +export type CodeBuddyGeneratedSkill = { + name: string + content: string + sidecarDirs?: CodeBuddyGeneratedSkillSidecarDir[] +} + +export type CodeBuddyGeneratedSkillSidecarDir = { + sourceDir: string + targetName: string +} + +export type CodeBuddyAgent = { + name: string + description: string + instructions: string + sidecarDirs?: CodeBuddyGeneratedSkillSidecarDir[] +} + +export type CodeBuddyBundle = { + pluginName?: string + prompts: CodeBuddyPrompt[] + skillDirs: CodeBuddySkillDir[] + generatedSkills: CodeBuddyGeneratedSkill[] + agents?: CodeBuddyAgent[] + mcpServers?: Record + hooks?: ClaudeHooks + externallyManagedSkillNames?: string[] +} diff --git a/src/utils/detect-tools.ts b/src/utils/detect-tools.ts index 0b4b1e0b5..7e7b72f33 100644 --- a/src/utils/detect-tools.ts +++ b/src/utils/detect-tools.ts @@ -2,7 +2,7 @@ import os from "os" import path from "path" import { pathExists } from "./files" import { resolveOpenCodeGlobalRoot } from "./opencode-config" -import { resolveCodexHome } from "./resolve-home" +import { resolveCodeBuddyHome, resolveCodexHome } from "./resolve-home" export type DetectedTool = { name: string @@ -69,6 +69,15 @@ const detectableTools: DetectableTool[] = [ path.join(home, ".gemini", "antigravity-cli"), ], }, + { + name: "codebuddy", + detectPaths: (home, _cwd, options) => { + if (!options.useCodexHomeEnv) return [path.join(home, ".codebuddy")] + const codebuddyHome = resolveCodeBuddyHome(undefined) + const defaultCodeBuddyHome = path.join(home, ".codebuddy") + return codebuddyHome === defaultCodeBuddyHome ? [defaultCodeBuddyHome] : [codebuddyHome, defaultCodeBuddyHome] + }, + }, { name: "qwen", detectPaths: (home, cwd) => [ diff --git a/src/utils/resolve-home.ts b/src/utils/resolve-home.ts index fbc4c554a..66cb2fabc 100644 --- a/src/utils/resolve-home.ts +++ b/src/utils/resolve-home.ts @@ -20,3 +20,8 @@ export function resolveCodexHome(value: unknown): string { const defaultPath = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex") return resolveTargetHome(value, path.resolve(expandHome(defaultPath))) } + +export function resolveCodeBuddyHome(value: unknown): string { + const defaultPath = process.env.CODEBUDDY_HOME?.trim() || path.join(os.homedir(), ".codebuddy") + return resolveTargetHome(value, path.resolve(expandHome(defaultPath))) +} diff --git a/src/utils/resolve-output.ts b/src/utils/resolve-output.ts index 71a9aded7..86b59ce99 100644 --- a/src/utils/resolve-output.ts +++ b/src/utils/resolve-output.ts @@ -6,13 +6,15 @@ export function resolveTargetOutputRoot(options: { targetName: string outputRoot: string codexHome: string + codebuddyHome: string piHome: string pluginName?: string hasExplicitOutput: boolean scope?: TargetScope }): string { - const { targetName, outputRoot, codexHome, piHome, hasExplicitOutput } = options + const { targetName, outputRoot, codexHome, codebuddyHome, piHome, hasExplicitOutput } = options if (targetName === "codex") return codexHome + if (targetName === "codebuddy") return codebuddyHome if (targetName === "pi") return piHome if (targetName === "antigravity") { const base = hasExplicitOutput ? outputRoot : process.cwd() diff --git a/tests/codebuddy-converter.test.ts b/tests/codebuddy-converter.test.ts new file mode 100644 index 000000000..86e106e47 --- /dev/null +++ b/tests/codebuddy-converter.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToCodeBuddy } from "../src/converters/claude-to-codebuddy" +import type { ClaudePlugin } from "../src/types/claude" + +const fixturePlugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "Security Reviewer", + description: "Security-focused agent", + capabilities: ["Threat modeling", "OWASP"], + model: "claude-sonnet-4-20250514", + body: "Focus on vulnerabilities.", + sourcePath: "/tmp/plugin/agents/security-reviewer.md", + }, + ], + commands: [ + { + name: "workflows:plan", + description: "Planning command", + argumentHint: "[FOCUS]", + model: "inherit", + allowedTools: ["Read"], + body: "Plan the work.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + ], + skills: [ + { + name: "existing-skill", + description: "Existing skill", + argumentHint: "[ITEM]", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: { + local: { command: "echo", args: ["hello"] }, + }, +} + +describe("convertClaudeToCodeBuddy", () => { + test("default (agents-only): emits only agent conversions, no skills or prompts or command-skills", () => { + const bundle = convertClaudeToCodeBuddy(fixturePlugin, { + codexIncludeSkills: false, + }) + + expect(bundle.agents).toHaveLength(1) + expect(bundle.agents![0].name).toBe("security-reviewer") + expect(bundle.agents![0].description).toBe("Security-focused agent") + expect(bundle.agents![0].instructions).toContain("## Capabilities") + expect(bundle.agents![0].instructions).toContain("Threat modeling") + + expect(bundle.prompts).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.mcpServers).toBeUndefined() + }) + + test("agents-only: passes through hooks", () => { + const pluginWithHooks: ClaudePlugin = { + ...fixturePlugin, + hooks: { + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo hello" }] }], + }, + }, + } + + const bundle = convertClaudeToCodeBuddy(pluginWithHooks, { + codexIncludeSkills: false, + }) + + expect(bundle.hooks).toBeDefined() + expect(bundle.hooks!.hooks!.PreToolUse).toHaveLength(1) + }) + + test("agents-only: includes externallyManagedSkillNames for cleanup", () => { + const bundle = convertClaudeToCodeBuddy(fixturePlugin, { + codexIncludeSkills: false, + }) + + expect(bundle.externallyManagedSkillNames).toEqual(["existing-skill"]) + }) + + test("full mode: includes skills, prompts, command-skills, and MCP", () => { + const bundle = convertClaudeToCodeBuddy(fixturePlugin, { + codexIncludeSkills: true, + }) + + expect(bundle.agents).toHaveLength(1) + expect(bundle.skillDirs).toHaveLength(1) + expect(bundle.skillDirs[0].name).toBe("existing-skill") + expect(bundle.prompts).toHaveLength(1) + expect(bundle.prompts[0].name).toBe("workflows-plan") + expect(bundle.generatedSkills).toHaveLength(1) + expect(bundle.generatedSkills[0].name).toBe("workflows-plan") + expect(bundle.mcpServers).toBeDefined() + expect(bundle.mcpServers!.local.command).toBe("echo") + }) + + test("agent description is sanitized and truncated if too long", () => { + const longDesc = "A".repeat(2000) + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "LongDescAgent", + description: longDesc, + model: "claude-sonnet-4-20250514", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/long-desc.md", + }, + ], + } + + const bundle = convertClaudeToCodeBuddy(plugin, { + codexIncludeSkills: false, + }) + + expect(bundle.agents![0].description.length).toBeLessThanOrEqual(1024) + expect(bundle.agents![0].description.endsWith("...")).toBe(true) + }) + + test("agent without description gets a fallback", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "NoDescAgent", + model: "claude-sonnet-4-20250514", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/no-desc.md", + }, + ], + } + + const bundle = convertClaudeToCodeBuddy(plugin, { + codexIncludeSkills: false, + }) + + expect(bundle.agents![0].description).toContain("Converted from Claude agent") + }) + + test("agent name is normalized to lowercase kebab-case", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "My Cool Agent!", + model: "claude-sonnet-4-20250514", + body: "Be cool.", + sourcePath: "/tmp/plugin/agents/my-cool-agent.md", + }, + ], + } + + const bundle = convertClaudeToCodeBuddy(plugin, { + codexIncludeSkills: false, + }) + + expect(bundle.agents![0].name).toBe("my-cool-agent") + }) + + test("command skill renders with frontmatter", () => { + const bundle = convertClaudeToCodeBuddy(fixturePlugin, { + codexIncludeSkills: true, + }) + + const skill = bundle.generatedSkills[0] + expect(skill.content).toContain("---") + expect(skill.content).toContain("name:") + expect(skill.content).toContain("description:") + }) + + test("prompt renders with instructions to use skill", () => { + const bundle = convertClaudeToCodeBuddy(fixturePlugin, { + codexIncludeSkills: true, + }) + + const prompt = bundle.prompts[0] + expect(prompt.content).toContain("/workflows-plan") + expect(prompt.content).toContain("Use the /") + }) + + test("empty agents array returns empty agents", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + } + + const bundle = convertClaudeToCodeBuddy(plugin, { + codexIncludeSkills: false, + }) + + expect(bundle.agents).toHaveLength(0) + }) + + test("skills with codebuddy platform filter are included", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + skills: [ + ...fixturePlugin.skills, + { + name: "codebuddy-only", + description: "Only for CodeBuddy", + sourceDir: "/tmp/plugin/skills/codebuddy-only", + skillPath: "/tmp/plugin/skills/codebuddy-only/SKILL.md", + platforms: ["codebuddy"], + }, + ], + } + + const bundle = convertClaudeToCodeBuddy(plugin, { + codexIncludeSkills: true, + }) + + expect(bundle.skillDirs).toHaveLength(2) + }) +}) diff --git a/tests/codebuddy-writer.test.ts b/tests/codebuddy-writer.test.ts new file mode 100644 index 000000000..e4b70ac99 --- /dev/null +++ b/tests/codebuddy-writer.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { mergeCodeBuddyConfig, mergeCodeBuddyHooks, renderCodeBuddyMcpConfig, writeCodeBuddyBundle } from "../src/targets/codebuddy" +import type { CodeBuddyBundle } from "../src/types/codebuddy" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +describe("renderCodeBuddyMcpConfig", () => { + test("returns null for undefined MCP servers", () => { + expect(renderCodeBuddyMcpConfig(undefined)).toBeNull() + }) + + test("returns null for empty MCP servers", () => { + expect(renderCodeBuddyMcpConfig({})).toBeNull() + }) + + test("renders MCP servers as JSON", () => { + const result = renderCodeBuddyMcpConfig({ + local: { command: "echo", args: ["hello"] }, + }) + expect(result).not.toBeNull() + const parsed = JSON.parse(result!) + expect(parsed.mcpServers.local.command).toBe("echo") + expect(parsed.mcpServers.local.args).toEqual(["hello"]) + }) + + test("renders URL-based MCP servers", () => { + const result = renderCodeBuddyMcpConfig({ + remote: { url: "https://example.com/mcp" }, + }) + expect(result).not.toBeNull() + const parsed = JSON.parse(result!) + expect(parsed.mcpServers.remote.url).toBe("https://example.com/mcp") + }) + + test("skips MCP servers without command or url", () => { + const result = renderCodeBuddyMcpConfig({ + invalid: {} as any, + }) + expect(result).toBeNull() + }) +}) + +describe("mergeCodeBuddyConfig", () => { + test("returns null for empty existing and null mcp", () => { + expect(mergeCodeBuddyConfig("", null)).toBeNull() + }) + + test("preserves existing config when no MCP servers", () => { + const existing = JSON.stringify({ theme: "dark" }, null, 2) + "\n" + const result = mergeCodeBuddyConfig(existing, null) + expect(result).not.toBeNull() + const parsed = JSON.parse(result!) + expect(parsed.theme).toBe("dark") + }) + + test("merges MCP servers into existing config", () => { + const existing = JSON.stringify({ theme: "dark" }, null, 2) + "\n" + const mcpJson = JSON.stringify({ + mcpServers: { local: { command: "echo", args: ["hello"] } }, + }, null, 2) + "\n" + const result = mergeCodeBuddyConfig(existing, mcpJson) + expect(result).not.toBeNull() + const parsed = JSON.parse(result!) + expect(parsed.theme).toBe("dark") + expect(parsed.mcpServers.local.command).toBe("echo") + }) + + test("merges MCP servers by key, preserving non-plugin servers", () => { + const existing = JSON.stringify({ + mcpServers: { old: { command: "old" } }, + }, null, 2) + "\n" + const mcpJson = JSON.stringify({ + mcpServers: { new: { command: "new" } }, + }, null, 2) + "\n" + const result = mergeCodeBuddyConfig(existing, mcpJson) + const parsed = JSON.parse(result!) + expect(parsed.mcpServers.old.command).toBe("old") + expect(parsed.mcpServers.new.command).toBe("new") + }) + + test("new MCP server overrides existing server with same key", () => { + const existing = JSON.stringify({ + mcpServers: { shared: { command: "old" } }, + }, null, 2) + "\n" + const mcpJson = JSON.stringify({ + mcpServers: { shared: { command: "new" } }, + }, null, 2) + "\n" + const result = mergeCodeBuddyConfig(existing, mcpJson) + const parsed = JSON.parse(result!) + expect(parsed.mcpServers.shared.command).toBe("new") + }) + + test("returns existing content when new config is invalid JSON", () => { + const existing = JSON.stringify({ theme: "dark" }, null, 2) + "\n" + const result = mergeCodeBuddyConfig(existing, "not json") + expect(result).toBe(existing) + }) + + test("returns existing content when existing is invalid JSON", () => { + const mcpJson = JSON.stringify({ + mcpServers: { local: { command: "echo" } }, + }, null, 2) + "\n" + const result = mergeCodeBuddyConfig("not json", mcpJson) + expect(result).toBe("not json") + }) +}) + +describe("mergeCodeBuddyHooks", () => { + test("returns only plugin hooks when no existing hooks", () => { + const result = mergeCodeBuddyHooks(null, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo" }] }], + }, "compound-engineering") + expect(result.hooks).toBeDefined() + const hooks = result.hooks as Record + expect(hooks.PreToolUse).toHaveLength(1) + }) + + test("merges plugin hooks with existing hooks", () => { + const existing = { + hooks: { + PostToolUse: [{ matcher: "Read", hooks: [{ type: "command", command: "lint" }] }], + }, + } + const result = mergeCodeBuddyHooks(existing, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo" }] }], + }, "compound-engineering") + const hooks = result.hooks as Record + expect(hooks.PostToolUse).toHaveLength(1) + expect(hooks.PreToolUse).toHaveLength(1) + }) + + test("replaces plugin's own hooks on re-install", () => { + const existing = { + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "old" }] }], + }, + _managed: { + "compound-engineering": { PreToolUse: [0] }, + }, + } + const result = mergeCodeBuddyHooks(existing, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "new" }] }], + }, "compound-engineering") + const hooks = result.hooks as Record + expect(hooks.PreToolUse).toHaveLength(1) + const entry = hooks.PreToolUse[0] as { hooks: Array<{ command: string }> } + expect(entry.hooks[0].command).toBe("new") + }) +}) + +describe("writeCodeBuddyBundle", () => { + test("writes agent TOML file", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codebuddy-writer-")) + const codebuddyRoot = path.join(tmpDir, ".codebuddy") + + const bundle: CodeBuddyBundle = { + pluginName: "test-plugin", + prompts: [], + skillDirs: [], + generatedSkills: [], + agents: [ + { + name: "test-agent", + description: "A test agent", + instructions: "Do testing.", + }, + ], + } + + await writeCodeBuddyBundle(codebuddyRoot, bundle) + + const agentPath = path.join(codebuddyRoot, "agents", "test-plugin", "test-agent.toml") + expect(await exists(agentPath)).toBe(true) + + const content = await fs.readFile(agentPath, "utf-8") + expect(content).toContain('name = "test-agent"') + expect(content).toContain('description = "A test agent"') + expect(content).toContain('developer_instructions = "Do testing."') + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("writes prompts when present", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codebuddy-writer-")) + const codebuddyRoot = path.join(tmpDir, ".codebuddy") + + const bundle: CodeBuddyBundle = { + pluginName: "test-plugin", + prompts: [ + { name: "my-prompt", content: "---\ndescription: Test\n---\n\nDo the thing.\n" }, + ], + skillDirs: [], + generatedSkills: [], + agents: [], + } + + await writeCodeBuddyBundle(codebuddyRoot, bundle) + + const promptPath = path.join(codebuddyRoot, "prompts", "my-prompt.md") + expect(await exists(promptPath)).toBe(true) + + const content = await fs.readFile(promptPath, "utf-8") + expect(content).toContain("Do the thing.") + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("writes skills from skillDirs", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codebuddy-writer-")) + const codebuddyRoot = path.join(tmpDir, ".codebuddy") + + // Create a source skill dir + const sourceDir = path.join(tmpDir, "source-skill") + await fs.mkdir(sourceDir, { recursive: true }) + await fs.writeFile(path.join(sourceDir, "SKILL.md"), "---\nname: my-skill\n---\n\n# My Skill\n") + + const bundle: CodeBuddyBundle = { + pluginName: "test-plugin", + prompts: [], + skillDirs: [{ name: "my-skill", sourceDir }], + generatedSkills: [], + agents: [], + } + + await writeCodeBuddyBundle(codebuddyRoot, bundle) + + const skillPath = path.join(codebuddyRoot, "skills", "test-plugin", "my-skill", "SKILL.md") + expect(await exists(skillPath)).toBe(true) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("writes generatedSkills", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codebuddy-writer-")) + const codebuddyRoot = path.join(tmpDir, ".codebuddy") + + const bundle: CodeBuddyBundle = { + pluginName: "test-plugin", + prompts: [], + skillDirs: [], + generatedSkills: [ + { name: "gen-skill", content: "---\nname: gen-skill\n---\n\n# Generated\n" }, + ], + agents: [], + } + + await writeCodeBuddyBundle(codebuddyRoot, bundle) + + const skillPath = path.join(codebuddyRoot, "skills", "test-plugin", "gen-skill", "SKILL.md") + expect(await exists(skillPath)).toBe(true) + + const content = await fs.readFile(skillPath, "utf-8") + expect(content).toContain("# Generated") + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("writes MCP config to settings.json", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codebuddy-writer-")) + const codebuddyRoot = path.join(tmpDir, ".codebuddy") + + const bundle: CodeBuddyBundle = { + pluginName: "test-plugin", + prompts: [], + skillDirs: [], + generatedSkills: [], + agents: [], + mcpServers: { + local: { command: "echo", args: ["hello"] }, + }, + } + + await writeCodeBuddyBundle(codebuddyRoot, bundle) + + const settingsPath = path.join(codebuddyRoot, "settings.json") + expect(await exists(settingsPath)).toBe(true) + + const content = await fs.readFile(settingsPath, "utf-8") + const parsed = JSON.parse(content) + expect(parsed.mcpServers.local.command).toBe("echo") + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("writes hooks.json when hooks present", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codebuddy-writer-")) + const codebuddyRoot = path.join(tmpDir, ".codebuddy") + + const bundle: CodeBuddyBundle = { + pluginName: "test-plugin", + prompts: [], + skillDirs: [], + generatedSkills: [], + agents: [], + hooks: { + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo" }] }], + }, + }, + } + + await writeCodeBuddyBundle(codebuddyRoot, bundle) + + const hooksPath = path.join(codebuddyRoot, "hooks.json") + expect(await exists(hooksPath)).toBe(true) + + const content = await fs.readFile(hooksPath, "utf-8") + const parsed = JSON.parse(content) + const hooks = parsed.hooks as Record + expect(hooks.PreToolUse).toHaveLength(1) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("writes install manifest", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "codebuddy-writer-")) + const codebuddyRoot = path.join(tmpDir, ".codebuddy") + + const bundle: CodeBuddyBundle = { + pluginName: "test-plugin", + prompts: [{ name: "test-prompt", content: "---\ndescription: Test\n---\n\n# Test\n" }], + skillDirs: [], + generatedSkills: [{ name: "gen-skill", content: "---\nname: gen-skill\n---\n\n# Generated\n" }], + agents: [{ name: "test-agent", description: "Agent", instructions: "Do." }], + } + + await writeCodeBuddyBundle(codebuddyRoot, bundle) + + const manifestPath = path.join(codebuddyRoot, "test-plugin", "install-manifest.json") + expect(await exists(manifestPath)).toBe(true) + + const content = await fs.readFile(manifestPath, "utf-8") + const manifest = JSON.parse(content) + expect(manifest.version).toBe(1) + expect(manifest.pluginName).toBe("test-plugin") + expect(manifest.prompts).toContain("test-prompt.md") + expect(manifest.skills).toContain("gen-skill") + expect(manifest.agents).toContain("test-agent.toml") + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) +}) diff --git a/tests/detect-tools.test.ts b/tests/detect-tools.test.ts index 9116e1795..78e53324d 100644 --- a/tests/detect-tools.test.ts +++ b/tests/detect-tools.test.ts @@ -47,7 +47,7 @@ describe("detectInstalledTools", () => { const results = await detectInstalledTools(tempHome, tempCwd) - expect(results.length).toBe(7) + expect(results.length).toBe(8) for (const tool of results) { expect(tool.detected).toBe(false) expect(tool.reason).toBe("not found")