Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/commands/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.`)
Expand All @@ -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}`)
}
Expand Down Expand Up @@ -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
}
14 changes: 9 additions & 5 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}`)

Expand All @@ -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}`)
}
Expand Down Expand Up @@ -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<ResolvedPluginPath> {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-"))
const source = resolveGitHubSource()
Expand Down
174 changes: 174 additions & 0 deletions src/converters/claude-to-droid.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
read: "Read",
write: "Create",
edit: "Edit",
multiedit: "Edit",
bash: "Execute",
grep: "Grep",
glob: "Glob",
list: "LS",
ls: "LS",
webfetch: "FetchUrl",
websearch: "WebSearch",
task: "Task",
todowrite: "TodoWrite",
todoread: "TodoWrite",
question: "AskUser",
}

const VALID_DROID_TOOLS = new Set([
"Read",
"LS",
"Grep",
"Glob",
"Create",
"Edit",
"ApplyPatch",
"Execute",
"WebSearch",
"FetchUrl",
"TodoWrite",
"Task",
"AskUser",
])

export function convertClaudeToDroid(
plugin: ClaudePlugin,
_options: ClaudeToDroidOptions,
): DroidBundle {
const commands = plugin.commands.map((command) => convertCommand(command))
const droids = plugin.agents.map((agent) => convertAgent(agent))
const skillDirs = 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<string, unknown> = {
description: command.description,
}
if (command.argumentHint) {
frontmatter["argument-hint"] = command.argumentHint
}
if (command.disableModelInvocation) {
frontmatter["disable-model-invocation"] = true
}

const body = transformContentForDroid(command.body.trim())
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}

function convertAgent(agent: ClaudeAgent): DroidAgentFile {
const name = normalizeName(agent.name)
const frontmatter: Record<string, unknown> = {
name,
description: agent.description,
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<string>()
for (const [claudeTool, droidTool] of Object.entries(CLAUDE_TO_DROID_TOOLS)) {
if (bodyLower.includes(claudeTool)) {
mentionedTools.add(droidTool)
}
}

if (mentionedTools.size === 0) return undefined
return [...mentionedTools].filter((t) => VALID_DROID_TOOLS.has(t)).sort()
}

/**
* Transform Claude Code content to Factory Droid-compatible content.
*
* 1. Slash commands: /workflows:plan → /plan, /command-name stays as-is
* 2. Task agent calls: Task agent-name(args) → Task agent-name: args
* 3. Agent references: @agent-name → the agent-name droid
*/
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 = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
if (commandName.includes('/')) return match
if (['dev', 'tmp', 'etc', 'usr', 'var', 'bin', 'home'].includes(commandName)) return match
const flattened = flattenCommandName(commandName)
return `/${flattened}`
})

// 3. Transform @agent-name references to droid references
const agentRefPattern = /@agent-([a-z][a-z0-9-]*)/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
return `the ${normalizeName(agentName)} droid`
})

return result
}

/**
* Flatten a command name by stripping the namespace prefix.
* "workflows:plan" → "plan"
* "plan_review" → "plan_review"
*/
function flattenCommandName(name: string): string {
const colonIndex = name.lastIndexOf(":")
const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name
return normalizeName(base)
}

function normalizeName(value: string): string {
const trimmed = value.trim()
if (!trimmed) return "item"
const normalized = trimmed
.toLowerCase()
.replace(/[\\/]+/g, "-")
.replace(/[:\s]+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
return normalized || "item"
}
50 changes: 50 additions & 0 deletions src/targets/droid.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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"),
}
}
9 changes: 9 additions & 0 deletions src/targets/index.ts
Original file line number Diff line number Diff line change
@@ -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<TBundle = unknown> = {
name: string
Expand All @@ -26,4 +29,10 @@ export const targets: Record<string, TargetHandler> = {
convert: convertClaudeToCodex as TargetHandler<CodexBundle>["convert"],
write: writeCodexBundle as TargetHandler<CodexBundle>["write"],
},
droid: {
name: "droid",
implemented: true,
convert: convertClaudeToDroid as TargetHandler<DroidBundle>["convert"],
write: writeDroidBundle as TargetHandler<DroidBundle>["write"],
},
}
20 changes: 20 additions & 0 deletions src/types/droid.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
Loading