diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 91df453a..9d42570d 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -22,7 +22,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex)", + description: "Target format (opencode | codex | droid)", }, output: { type: "string", @@ -80,7 +80,7 @@ export default defineCommand({ permissions: permissions as PermissionMode, } - const primaryOutputRoot = targetName === "codex" && codexHome ? codexHome : outputRoot + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome) const bundle = target.convert(plugin, options) if (!bundle) { throw new Error(`Target ${targetName} did not return a bundle.`) @@ -106,9 +106,7 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = extra === "codex" && codexHome - ? codexHome - : path.join(outputRoot, extra) + const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome) await handler.write(extraRoot, extraBundle) console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`) } @@ -154,3 +152,9 @@ function resolveOutputRoot(value: unknown): string { } return process.cwd() } + +function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string { + if (targetName === "codex") return codexHome + if (targetName === "droid") return path.join(os.homedir(), ".factory") + return outputRoot +} diff --git a/src/commands/install.ts b/src/commands/install.ts index bab0a4b5..93239370 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -24,7 +24,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex)", + description: "Target format (opencode | codex | droid)", }, output: { type: "string", @@ -88,7 +88,7 @@ export default defineCommand({ if (!bundle) { throw new Error(`Target ${targetName} did not return a bundle.`) } - const primaryOutputRoot = targetName === "codex" && codexHome ? codexHome : outputRoot + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome) await target.write(primaryOutputRoot, bundle) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) @@ -109,9 +109,7 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = extra === "codex" && codexHome - ? codexHome - : path.join(outputRoot, extra) + const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome) await handler.write(extraRoot, extraBundle) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } @@ -180,6 +178,12 @@ function resolveOutputRoot(value: unknown): string { return path.join(os.homedir(), ".config", "opencode") } +function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string { + if (targetName === "codex") return codexHome + if (targetName === "droid") return path.join(os.homedir(), ".factory") + return outputRoot +} + async function resolveGitHubPluginPath(pluginName: string): Promise { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-")) const source = resolveGitHubSource() diff --git a/src/converters/claude-to-droid.ts b/src/converters/claude-to-droid.ts new file mode 100644 index 00000000..547a23d2 --- /dev/null +++ b/src/converters/claude-to-droid.ts @@ -0,0 +1,174 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudePlugin } from "../types/claude" +import type { DroidBundle, DroidCommandFile, DroidAgentFile } from "../types/droid" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToDroidOptions = ClaudeToOpenCodeOptions + +const CLAUDE_TO_DROID_TOOLS: Record = { + read: "Read", + write: "Create", + edit: "Edit", + multiedit: "Edit", + bash: "Execute", + grep: "Grep", + glob: "Glob", + list: "LS", + ls: "LS", + webfetch: "FetchUrl", + websearch: "WebSearch", + task: "Task", + todowrite: "TodoWrite", + todoread: "TodoWrite", + question: "AskUser", +} + +const VALID_DROID_TOOLS = new Set([ + "Read", + "LS", + "Grep", + "Glob", + "Create", + "Edit", + "ApplyPatch", + "Execute", + "WebSearch", + "FetchUrl", + "TodoWrite", + "Task", + "AskUser", +]) + +export function convertClaudeToDroid( + plugin: ClaudePlugin, + _options: ClaudeToDroidOptions, +): DroidBundle { + const commands = plugin.commands.map((command) => convertCommand(command)) + const droids = plugin.agents.map((agent) => convertAgent(agent)) + const skillDirs = plugin.skills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })) + + return { commands, droids, skillDirs } +} + +function convertCommand(command: ClaudeCommand): DroidCommandFile { + const name = flattenCommandName(command.name) + const frontmatter: Record = { + description: command.description, + } + if (command.argumentHint) { + frontmatter["argument-hint"] = command.argumentHint + } + if (command.disableModelInvocation) { + frontmatter["disable-model-invocation"] = true + } + + const body = transformContentForDroid(command.body.trim()) + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +function convertAgent(agent: ClaudeAgent): DroidAgentFile { + const name = normalizeName(agent.name) + const frontmatter: Record = { + name, + description: agent.description, + model: agent.model && agent.model !== "inherit" ? agent.model : "inherit", + } + + const tools = mapAgentTools(agent) + if (tools) { + frontmatter.tools = tools + } + + let body = agent.body.trim() + if (agent.capabilities && agent.capabilities.length > 0) { + const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n") + body = `## Capabilities\n${capabilities}\n\n${body}`.trim() + } + if (body.length === 0) { + body = `Instructions converted from the ${agent.name} agent.` + } + + body = transformContentForDroid(body) + + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +function mapAgentTools(agent: ClaudeAgent): string[] | undefined { + const bodyLower = `${agent.name} ${agent.description ?? ""} ${agent.body}`.toLowerCase() + + const mentionedTools = new Set() + for (const [claudeTool, droidTool] of Object.entries(CLAUDE_TO_DROID_TOOLS)) { + if (bodyLower.includes(claudeTool)) { + mentionedTools.add(droidTool) + } + } + + if (mentionedTools.size === 0) return undefined + return [...mentionedTools].filter((t) => VALID_DROID_TOOLS.has(t)).sort() +} + +/** + * Transform Claude Code content to Factory Droid-compatible content. + * + * 1. Slash commands: /workflows:plan → /plan, /command-name stays as-is + * 2. Task agent calls: Task agent-name(args) → Task agent-name: args + * 3. Agent references: @agent-name → the agent-name droid + */ +function transformContentForDroid(body: string): string { + let result = body + + // 1. Transform Task agent calls + // Match: Task repo-research-analyst(feature_description) + const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm + result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { + const name = normalizeName(agentName) + return `${prefix}Task ${name}: ${args.trim()}` + }) + + // 2. Transform slash command references + // /workflows:plan → /plan, /command-name stays as-is + const slashCommandPattern = /(? { + if (commandName.includes('/')) return match + if (['dev', 'tmp', 'etc', 'usr', 'var', 'bin', 'home'].includes(commandName)) return match + const flattened = flattenCommandName(commandName) + return `/${flattened}` + }) + + // 3. Transform @agent-name references to droid references + const agentRefPattern = /@agent-([a-z][a-z0-9-]*)/gi + result = result.replace(agentRefPattern, (_match, agentName: string) => { + return `the ${normalizeName(agentName)} droid` + }) + + return result +} + +/** + * Flatten a command name by stripping the namespace prefix. + * "workflows:plan" → "plan" + * "plan_review" → "plan_review" + */ +function flattenCommandName(name: string): string { + const colonIndex = name.lastIndexOf(":") + const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name + return normalizeName(base) +} + +function normalizeName(value: string): string { + const trimmed = value.trim() + if (!trimmed) return "item" + const normalized = trimmed + .toLowerCase() + .replace(/[\\/]+/g, "-") + .replace(/[:\s]+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + return normalized || "item" +} diff --git a/src/targets/droid.ts b/src/targets/droid.ts new file mode 100644 index 00000000..85600766 --- /dev/null +++ b/src/targets/droid.ts @@ -0,0 +1,50 @@ +import path from "path" +import { copyDir, ensureDir, writeText } from "../utils/files" +import type { DroidBundle } from "../types/droid" + +export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle): Promise { + const paths = resolveDroidPaths(outputRoot) + await ensureDir(paths.root) + + if (bundle.commands.length > 0) { + await ensureDir(paths.commandsDir) + for (const command of bundle.commands) { + await writeText(path.join(paths.commandsDir, `${command.name}.md`), command.content + "\n") + } + } + + if (bundle.droids.length > 0) { + await ensureDir(paths.droidsDir) + for (const droid of bundle.droids) { + await writeText(path.join(paths.droidsDir, `${droid.name}.md`), droid.content + "\n") + } + } + + if (bundle.skillDirs.length > 0) { + await ensureDir(paths.skillsDir) + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name)) + } + } +} + +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"), + } +} diff --git a/src/targets/index.ts b/src/targets/index.ts index f84b5af8..7e5436ae 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -1,10 +1,13 @@ import type { ClaudePlugin } from "../types/claude" import type { OpenCodeBundle } from "../types/opencode" import type { CodexBundle } from "../types/codex" +import type { DroidBundle } from "../types/droid" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" +import { convertClaudeToDroid } from "../converters/claude-to-droid" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" +import { writeDroidBundle } from "./droid" export type TargetHandler = { name: string @@ -26,4 +29,10 @@ export const targets: Record = { 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"], + }, } diff --git a/src/types/droid.ts b/src/types/droid.ts new file mode 100644 index 00000000..96a9826c --- /dev/null +++ b/src/types/droid.ts @@ -0,0 +1,20 @@ +export type DroidCommandFile = { + name: string + content: string +} + +export type DroidAgentFile = { + name: string + content: string +} + +export type DroidSkillDir = { + name: string + sourceDir: string +} + +export type DroidBundle = { + commands: DroidCommandFile[] + droids: DroidAgentFile[] + skillDirs: DroidSkillDir[] +} diff --git a/tests/droid-converter.test.ts b/tests/droid-converter.test.ts new file mode 100644 index 00000000..9c37e0b1 --- /dev/null +++ b/tests/droid-converter.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToDroid } from "../src/converters/claude-to-droid" +import { parseFrontmatter } from "../src/utils/frontmatter" +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", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: undefined, +} + +describe("convertClaudeToDroid", () => { + test("flattens namespaced command names", () => { + const bundle = convertClaudeToDroid(fixturePlugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + expect(bundle.commands).toHaveLength(1) + const command = bundle.commands[0] + expect(command.name).toBe("plan") + + const parsed = parseFrontmatter(command.content) + expect(parsed.data.description).toBe("Planning command") + expect(parsed.data["argument-hint"]).toBe("[FOCUS]") + expect(parsed.body).toContain("Plan the work.") + }) + + test("converts agents to droids with frontmatter", () => { + const bundle = convertClaudeToDroid(fixturePlugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + expect(bundle.droids).toHaveLength(1) + const droid = bundle.droids[0] + expect(droid.name).toBe("security-reviewer") + + const parsed = parseFrontmatter(droid.content) + expect(parsed.data.name).toBe("security-reviewer") + expect(parsed.data.description).toBe("Security-focused agent") + expect(parsed.data.model).toBe("claude-sonnet-4-20250514") + expect(parsed.body).toContain("Capabilities") + expect(parsed.body).toContain("Threat modeling") + expect(parsed.body).toContain("Focus on vulnerabilities.") + }) + + test("passes through skill directories", () => { + const bundle = convertClaudeToDroid(fixturePlugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + expect(bundle.skillDirs).toHaveLength(1) + expect(bundle.skillDirs[0].name).toBe("existing-skill") + expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill") + }) + + test("sets model to inherit when not specified", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "basic-agent", + description: "Basic agent", + model: "inherit", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/basic.md", + }, + ], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.droids[0].content) + expect(parsed.data.model).toBe("inherit") + }) + + test("transforms Task agent calls to droid-compatible syntax", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "plan", + description: "Planning with agents", + body: `Run these agents in parallel: + +- Task repo-research-analyst(feature_description) +- Task learnings-researcher(feature_description) + +Then consolidate findings. + +Task best-practices-researcher(topic)`, + sourcePath: "/tmp/plugin/commands/plan.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.commands[0].content) + expect(parsed.body).toContain("Task repo-research-analyst: feature_description") + expect(parsed.body).toContain("Task learnings-researcher: feature_description") + expect(parsed.body).toContain("Task best-practices-researcher: topic") + expect(parsed.body).not.toContain("Task repo-research-analyst(") + }) + + test("transforms slash commands by flattening namespaces", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "plan", + description: "Planning with commands", + body: `After planning, you can: + +1. Run /deepen-plan to enhance +2. Run /plan_review for feedback +3. Start /workflows:work to implement + +Don't confuse with file paths like /tmp/output.md or /dev/null.`, + sourcePath: "/tmp/plugin/commands/plan.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.commands[0].content) + expect(parsed.body).toContain("/deepen-plan") + expect(parsed.body).toContain("/plan_review") + expect(parsed.body).toContain("/work") + expect(parsed.body).not.toContain("/workflows:work") + // File paths should NOT be transformed + expect(parsed.body).toContain("/tmp/output.md") + expect(parsed.body).toContain("/dev/null") + }) + + test("transforms @agent references to droid references", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "review", + description: "Review command", + body: "Have @agent-dhh-rails-reviewer and @agent-security-sentinel review the code.", + sourcePath: "/tmp/plugin/commands/review.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.commands[0].content) + expect(parsed.body).toContain("the dhh-rails-reviewer droid") + expect(parsed.body).toContain("the security-sentinel droid") + expect(parsed.body).not.toContain("@agent-") + }) + + test("preserves disable-model-invocation on commands", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "disabled-cmd", + description: "Disabled command", + disableModelInvocation: true, + body: "Body.", + sourcePath: "/tmp/plugin/commands/disabled.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.commands[0].content) + expect(parsed.data["disable-model-invocation"]).toBe(true) + }) + + test("handles multiple commands including nested and top-level", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "workflows:plan", + description: "Plan", + body: "Plan body.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "workflows:work", + description: "Work", + body: "Work body.", + sourcePath: "/tmp/plugin/commands/workflows/work.md", + }, + { + name: "changelog", + description: "Changelog", + body: "Changelog body.", + sourcePath: "/tmp/plugin/commands/changelog.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const names = bundle.commands.map((c) => c.name) + expect(names).toEqual(["plan", "work", "changelog"]) + }) +}) diff --git a/tests/droid-writer.test.ts b/tests/droid-writer.test.ts new file mode 100644 index 00000000..f8ecf6cb --- /dev/null +++ b/tests/droid-writer.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeDroidBundle } from "../src/targets/droid" +import type { DroidBundle } from "../src/types/droid" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +describe("writeDroidBundle", () => { + test("writes commands, droids, and skills", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-test-")) + const bundle: DroidBundle = { + commands: [{ name: "plan", content: "Plan command content" }], + droids: [{ name: "security-reviewer", content: "Droid content" }], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + } + + await writeDroidBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".factory", "commands", "plan.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".factory", "droids", "security-reviewer.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".factory", "skills", "skill-one", "SKILL.md"))).toBe(true) + + const commandContent = await fs.readFile( + path.join(tempRoot, ".factory", "commands", "plan.md"), + "utf8", + ) + expect(commandContent).toContain("Plan command content") + + const droidContent = await fs.readFile( + path.join(tempRoot, ".factory", "droids", "security-reviewer.md"), + "utf8", + ) + expect(droidContent).toContain("Droid content") + }) + + test("writes directly into a .factory output root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-home-")) + const factoryRoot = path.join(tempRoot, ".factory") + const bundle: DroidBundle = { + commands: [{ name: "plan", content: "Plan content" }], + droids: [{ name: "reviewer", content: "Reviewer content" }], + skillDirs: [], + } + + await writeDroidBundle(factoryRoot, bundle) + + expect(await exists(path.join(factoryRoot, "commands", "plan.md"))).toBe(true) + expect(await exists(path.join(factoryRoot, "droids", "reviewer.md"))).toBe(true) + // Should not double-nest under .factory/.factory + expect(await exists(path.join(factoryRoot, ".factory"))).toBe(false) + }) + + test("handles empty bundles gracefully", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-empty-")) + const bundle: DroidBundle = { + commands: [], + droids: [], + skillDirs: [], + } + + await writeDroidBundle(tempRoot, bundle) + + // Root should exist but no subdirectories created + expect(await exists(tempRoot)).toBe(true) + }) + + test("writes multiple commands as separate files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "droid-multi-")) + const factoryRoot = path.join(tempRoot, ".factory") + const bundle: DroidBundle = { + commands: [ + { name: "plan", content: "Plan content" }, + { name: "work", content: "Work content" }, + { name: "brainstorm", content: "Brainstorm content" }, + ], + droids: [], + skillDirs: [], + } + + await writeDroidBundle(factoryRoot, bundle) + + expect(await exists(path.join(factoryRoot, "commands", "plan.md"))).toBe(true) + expect(await exists(path.join(factoryRoot, "commands", "work.md"))).toBe(true) + expect(await exists(path.join(factoryRoot, "commands", "brainstorm.md"))).toBe(true) + }) +})