Skip to content
Draft
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
21 changes: 10 additions & 11 deletions src/converters/claude-to-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { formatFrontmatter } from "../utils/frontmatter"
import type { ClaudeAgent, ClaudeCommand, ClaudePlugin, ClaudeSkill } from "../types/claude"
import type { CodexBundle, CodexGeneratedSkill } from "../types/codex"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
import { composeAgentBody } from "../utils/agent-content"
import {
normalizeCodexName,
transformContentForCodex,
Expand Down Expand Up @@ -111,14 +112,15 @@ function convertAgent(
)
const frontmatter: Record<string, unknown> = { name, description }

let body = transformContentForCodex(agent.body.trim(), invocationTargets)
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((capability) => `- ${capability}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
}
const body = [
composeAgentBody({
body: transformContentForCodex(agent.body.trim(), invocationTargets),
fallback: `Instructions converted from the ${agent.name} agent.`,
capabilities: agent.capabilities,
}),
]
.filter(Boolean)
.join("\n\n")

const content = formatFrontmatter(frontmatter, body)
return { name, content }
Expand All @@ -140,9 +142,6 @@ function convertCommandSkill(
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 transformedBody = transformContentForCodex(command.body.trim(), invocationTargets)
sections.push(transformedBody)
const body = sections.filter(Boolean).join("\n\n").trim()
Expand Down
40 changes: 31 additions & 9 deletions src/converters/claude-to-copilot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { formatFrontmatter } from "../utils/frontmatter"
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
import {
composeAgentBody,
mapToolSpecifiers,
resolveStructuredAgentTools,
} from "../utils/agent-content"
import type {
CopilotAgent,
CopilotBundle,
Expand All @@ -11,6 +16,20 @@ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions

const COPILOT_BODY_CHAR_LIMIT = 30_000
const CLAUDE_TO_COPILOT_TOOLS: Record<string, string> = {
task: "agent",
bash: "execute",
edit: "edit",
glob: "search",
grep: "search",
multiedit: "edit",
notebookedit: "edit",
notebookread: "read",
read: "read",
webfetch: "web",
websearch: "web",
write: "edit",
}

export function convertClaudeToCopilot(
plugin: ClaudePlugin,
Expand Down Expand Up @@ -46,25 +65,28 @@ export function convertClaudeToCopilot(
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): CopilotAgent {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = agent.description ?? `Converted from Claude agent ${agent.name}`
const { mappedTools, unmappedTools } = mapToolSpecifiers(agent.tools, CLAUDE_TO_COPILOT_TOOLS)

const frontmatter: Record<string, unknown> = {
description,
tools: ["*"],
tools: resolveStructuredAgentTools({
sourceTools: agent.tools,
mappedTools,
unmappedTools,
target: "Copilot",
}),
infer: true,
}

if (agent.model) {
frontmatter.model = agent.model
}

let body = transformContentForCopilot(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.`
}
const body = composeAgentBody({
body: transformContentForCopilot(agent.body.trim()),
fallback: `Instructions converted from the ${agent.name} agent.`,
capabilities: agent.capabilities,
})

if (body.length > COPILOT_BODY_CHAR_LIMIT) {
console.warn(
Expand Down
37 changes: 26 additions & 11 deletions src/converters/claude-to-droid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { formatFrontmatter } from "../utils/frontmatter"
import type { ClaudeAgent, ClaudeCommand, ClaudePlugin } from "../types/claude"
import { composeAgentBody, formatToolMappingWarning, mapToolSpecifiers } from "../utils/agent-content"
import type { DroidBundle, DroidCommandFile, DroidAgentFile } from "../types/droid"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"

Expand Down Expand Up @@ -72,33 +73,47 @@ function convertCommand(command: ClaudeCommand): DroidCommandFile {

function convertAgent(agent: ClaudeAgent): DroidAgentFile {
const name = normalizeName(agent.name)
const tools = mapAgentTools(agent)
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 body = transformContentForDroid(
composeAgentBody({
body: agent.body.trim(),
fallback: `Instructions converted from the ${agent.name} agent.`,
capabilities: agent.capabilities,
}),
)

const content = formatFrontmatter(frontmatter, body)
return { name, content }
}

function mapAgentTools(agent: ClaudeAgent): string[] | undefined {
if (agent.tools) {
const { mappedTools, unmappedTools } = mapToolSpecifiers(agent.tools, CLAUDE_TO_DROID_TOOLS)
if (mappedTools && mappedTools.length > 0 && unmappedTools.length === 0) {
return mappedTools
}

console.warn(
formatToolMappingWarning({
sourceTools: agent.tools,
unmappedTools,
target: "Droid",
fallback: "Omitting the Droid tools field so the converted agent remains usable.",
}),
)
return undefined
}

const bodyLower = `${agent.name} ${agent.description ?? ""} ${agent.body}`.toLowerCase()

const mentionedTools = new Set<string>()
Expand Down
45 changes: 27 additions & 18 deletions src/converters/claude-to-kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { readFileSync, existsSync } from "fs"
import path from "path"
import { formatFrontmatter } from "../utils/frontmatter"
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
import {
composeAgentBody,
mapToolSpecifiers,
resolveStructuredAgentTools,
} from "../utils/agent-content"
import type {
KiroAgent,
KiroAgentConfig,
Expand All @@ -19,14 +24,15 @@ const KIRO_SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
const KIRO_DESCRIPTION_MAX_LENGTH = 1024

const CLAUDE_TO_KIRO_TOOLS: Record<string, string> = {
Bash: "shell",
Write: "write",
Read: "read",
Edit: "write", // NOTE: Kiro write is full-file, not surgical edit. Lossy mapping.
Glob: "glob",
Grep: "grep",
WebFetch: "web_fetch",
Task: "use_subagent",
bash: "shell",
edit: "write", // NOTE: Kiro write is full-file, not surgical edit. Lossy mapping.
glob: "glob",
grep: "grep",
read: "read",
task: "use_subagent",
webfetch: "web_fetch",
websearch: "web_search",
write: "write",
}

export function convertClaudeToKiro(
Expand Down Expand Up @@ -74,12 +80,18 @@ function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]):
const description = sanitizeDescription(
agent.description ?? `Use this agent for ${agent.name} tasks`,
)
const { mappedTools, unmappedTools } = mapToolSpecifiers(agent.tools, CLAUDE_TO_KIRO_TOOLS)

const config: KiroAgentConfig = {
name,
description,
prompt: `file://./prompts/${name}.md`,
tools: ["*"],
tools: resolveStructuredAgentTools({
sourceTools: agent.tools,
mappedTools,
unmappedTools,
target: "Kiro",
}),
resources: [
"file://.kiro/steering/**/*.md",
"skill://.kiro/skills/**/SKILL.md",
Expand All @@ -88,14 +100,11 @@ function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]):
welcomeMessage: `Switching to the ${name} agent. ${description}`,
}

let body = transformContentForKiro(agent.body.trim(), knownAgentNames)
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
}
const body = composeAgentBody({
body: transformContentForKiro(agent.body.trim(), knownAgentNames),
fallback: `Instructions converted from the ${agent.name} agent.`,
capabilities: agent.capabilities,
})

return { name, config, promptContent: body }
}
Expand Down Expand Up @@ -159,7 +168,7 @@ export function transformContentForKiro(body: string, knownAgentNames: string[]
// 4. Claude tool names -> Kiro tool names
for (const [claudeTool, kiroTool] of Object.entries(CLAUDE_TO_KIRO_TOOLS)) {
// Match tool name references: "the X tool", "using X", "use X to"
const toolPattern = new RegExp(`\\b${claudeTool}\\b(?=\\s+tool|\\s+to\\s)`, "g")
const toolPattern = new RegExp(`\\b${claudeTool}\\b(?=\\s+tool|\\s+to\\s)`, "gi")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Narrow Kiro tool rewrite to avoid generic "task to" text

Using a case-insensitive pattern here causes ordinary prose to be rewritten as if it were a tool reference, because task is now matched in any casing whenever it is followed by to. In converted prompts, sentences like "the next task to complete..." become "the next use_subagent to complete...", which corrupts instructions and can materially change agent behavior. This regression appears in the new lowercase mapping + gi regex combination and should be constrained to explicit tool-invocation phrasing.

Useful? React with 👍 / 👎.

result = result.replace(toolPattern, kiroTool)
}

Expand Down
3 changes: 3 additions & 0 deletions src/converters/claude-to-qwen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToQwenOptions): QwenAge
name: agent.name,
description: agent.description,
}
if (agent.tools && agent.tools.length > 0) {
frontmatter.allowedTools = agent.tools
}

if (agent.model && agent.model !== "inherit") {
frontmatter.model = normalizeModel(agent.model)
Expand Down
1 change: 1 addition & 0 deletions src/parsers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async function loadAgents(agentsDirs: string[]): Promise<ClaudeAgent[]> {
name,
description: data.description as string | undefined,
capabilities: data.capabilities as string[] | undefined,
tools: parseAllowedTools(data.tools),
model: data.model as string | undefined,
body: body.trim(),
sourcePath: file,
Expand Down
1 change: 1 addition & 0 deletions src/types/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type ClaudeAgent = {
name: string
description?: string
capabilities?: string[]
tools?: string[]
model?: string
body: string
sourcePath: string
Expand Down
2 changes: 1 addition & 1 deletion src/types/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type KiroAgentConfig = {
name: string
description: string
prompt: `file://${string}`
tools: ["*"]
tools: string[]
resources: string[]
includeMcpJson: true
welcomeMessage?: string
Expand Down
86 changes: 86 additions & 0 deletions src/utils/agent-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export function composeAgentBody(options: {
body: string
fallback: string
capabilities?: string[]
}): string {
const sections: string[] = []

if (options.capabilities && options.capabilities.length > 0) {
sections.push(
`## Capabilities\n${options.capabilities.map((capability) => `- ${capability}`).join("\n")}`,
)
}

const body = options.body.trim()
sections.push(body.length > 0 ? body : options.fallback)

return sections.join("\n\n")
}

export function normalizeToolSpecifier(tool: string): string {
return tool.split("(", 1)[0]?.trim().toLowerCase() ?? ""
}

export function mapToolSpecifiers(
tools: string[] | undefined,
mapping: Record<string, string>,
): { mappedTools: string[] | undefined; unmappedTools: string[] } {
if (!tools || tools.length === 0) {
return { mappedTools: undefined, unmappedTools: [] }
}

const mapped: string[] = []
const unmapped: string[] = []
for (const tool of tools) {
const mappedTool = mapping[normalizeToolSpecifier(tool)]
if (!mappedTool) {
if (!unmapped.includes(tool)) unmapped.push(tool)
continue
}
if (mapped.includes(mappedTool)) continue
mapped.push(mappedTool)
}

return {
mappedTools: mapped.length > 0 ? mapped : undefined,
unmappedTools: unmapped,
}
}

export function formatToolMappingWarning(options: {
sourceTools: string[]
unmappedTools: string[]
target: string
fallback: string
}): string {
if (options.unmappedTools.length === options.sourceTools.length) {
return `Warning: ${options.target} has no mapping for Claude agent tools [${options.sourceTools.join(", ")}]. ${options.fallback}`
}

return `Warning: ${options.target} cannot preserve Claude agent tools [${options.unmappedTools.join(", ")}] from [${options.sourceTools.join(", ")}]. ${options.fallback}`
}

export function resolveStructuredAgentTools(options: {
sourceTools: string[] | undefined
mappedTools: string[] | undefined
unmappedTools: string[]
target: string
}): string[] {
if (!options.sourceTools || options.sourceTools.length === 0) {
return ["*"]
}

if (options.unmappedTools.length === 0 && options.mappedTools && options.mappedTools.length > 0) {
return options.mappedTools
}

console.warn(
formatToolMappingWarning({
sourceTools: options.sourceTools,
unmappedTools: options.unmappedTools,
target: options.target,
fallback: 'Falling back to ["*"] so the converted agent remains usable.',
}),
)
return ["*"]
}
1 change: 1 addition & 0 deletions tests/claude-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("loadClaudePlugin", () => {

const researchAgent = plugin.agents.find((agent) => agent.name === "repo-research-analyst")
expect(researchAgent?.capabilities).toEqual(["Capability A", "Capability B"])
expect(researchAgent?.tools).toEqual(["Read", "Grep", "Glob"])

const reviewCommand = plugin.commands.find((command) => command.name === "workflows:review")
expect(reviewCommand?.allowedTools).toEqual([
Expand Down
Loading