diff --git a/src/converters/claude-to-codex.ts b/src/converters/claude-to-codex.ts index 238ca1921..6cac42e33 100644 --- a/src/converters/claude-to-codex.ts +++ b/src/converters/claude-to-codex.ts @@ -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, @@ -111,14 +112,15 @@ function convertAgent( ) const frontmatter: Record = { 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 } @@ -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() diff --git a/src/converters/claude-to-copilot.ts b/src/converters/claude-to-copilot.ts index 67f0dab62..b6d9e5d6d 100644 --- a/src/converters/claude-to-copilot.ts +++ b/src/converters/claude-to-copilot.ts @@ -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, @@ -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 = { + 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, @@ -46,10 +65,16 @@ export function convertClaudeToCopilot( function convertAgent(agent: ClaudeAgent, usedNames: Set): 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 = { description, - tools: ["*"], + tools: resolveStructuredAgentTools({ + sourceTools: agent.tools, + mappedTools, + unmappedTools, + target: "Copilot", + }), infer: true, } @@ -57,14 +82,11 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set): CopilotAgent 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( diff --git a/src/converters/claude-to-droid.ts b/src/converters/claude-to-droid.ts index af11f06e7..bdf89565c 100644 --- a/src/converters/claude-to-droid.ts +++ b/src/converters/claude-to-droid.ts @@ -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" @@ -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 = { 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() diff --git a/src/converters/claude-to-kiro.ts b/src/converters/claude-to-kiro.ts index 3e8d6223a..72dde0753 100644 --- a/src/converters/claude-to-kiro.ts +++ b/src/converters/claude-to-kiro.ts @@ -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, @@ -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 = { - 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( @@ -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", @@ -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 } } @@ -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") result = result.replace(toolPattern, kiroTool) } diff --git a/src/converters/claude-to-qwen.ts b/src/converters/claude-to-qwen.ts index c07b17745..85ab41d44 100644 --- a/src/converters/claude-to-qwen.ts +++ b/src/converters/claude-to-qwen.ts @@ -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) diff --git a/src/parsers/claude.ts b/src/parsers/claude.ts index 247f61653..defe0ec6d 100644 --- a/src/parsers/claude.ts +++ b/src/parsers/claude.ts @@ -66,6 +66,7 @@ async function loadAgents(agentsDirs: string[]): Promise { 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, diff --git a/src/types/claude.ts b/src/types/claude.ts index 9e00f7fdd..866189599 100644 --- a/src/types/claude.ts +++ b/src/types/claude.ts @@ -28,6 +28,7 @@ export type ClaudeAgent = { name: string description?: string capabilities?: string[] + tools?: string[] model?: string body: string sourcePath: string diff --git a/src/types/kiro.ts b/src/types/kiro.ts index 00491c8c9..114037ff4 100644 --- a/src/types/kiro.ts +++ b/src/types/kiro.ts @@ -8,7 +8,7 @@ export type KiroAgentConfig = { name: string description: string prompt: `file://${string}` - tools: ["*"] + tools: string[] resources: string[] includeMcpJson: true welcomeMessage?: string diff --git a/src/utils/agent-content.ts b/src/utils/agent-content.ts new file mode 100644 index 000000000..466023cf7 --- /dev/null +++ b/src/utils/agent-content.ts @@ -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, +): { 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 ["*"] +} diff --git a/tests/claude-parser.test.ts b/tests/claude-parser.test.ts index fe2f348da..57f6d0690 100644 --- a/tests/claude-parser.test.ts +++ b/tests/claude-parser.test.ts @@ -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([ diff --git a/tests/codex-converter.test.ts b/tests/codex-converter.test.ts index a82c1875e..22f11e2c4 100644 --- a/tests/codex-converter.test.ts +++ b/tests/codex-converter.test.ts @@ -11,6 +11,7 @@ const fixturePlugin: ClaudePlugin = { name: "Security Reviewer", description: "Security-focused agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities.", sourcePath: "/tmp/plugin/agents/security-reviewer.md", @@ -68,17 +69,18 @@ describe("convertClaudeToCodex", () => { const parsedCommandSkill = parseFrontmatter(commandSkill!.content) expect(parsedCommandSkill.data.name).toBe("workflows-plan") expect(parsedCommandSkill.data.description).toBe("Planning command") - expect(parsedCommandSkill.body).toContain("Allowed tools") - const agentSkill = bundle.generatedSkills.find((skill) => skill.name === "security-reviewer") expect(agentSkill).toBeDefined() const parsedSkill = parseFrontmatter(agentSkill!.content) expect(parsedSkill.data.name).toBe("security-reviewer") expect(parsedSkill.data.description).toBe("Security-focused agent") + expect(parsedSkill.data.tools).toBeUndefined() + expect(parsedSkill.body).not.toContain("## Tool guidance") expect(parsedSkill.body).toContain("Capabilities") expect(parsedSkill.body).toContain("Threat modeling") }) + test("generates prompt wrappers for canonical ce workflow skills and omits workflows aliases", () => { const plugin: ClaudePlugin = { ...fixturePlugin, diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts index 1bc790e4c..2435b8809 100644 --- a/tests/copilot-converter.test.ts +++ b/tests/copilot-converter.test.ts @@ -11,6 +11,7 @@ const fixturePlugin: ClaudePlugin = { name: "Security Reviewer", description: "Security-focused code review agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities.", sourcePath: "/tmp/plugin/agents/security-reviewer.md", @@ -55,7 +56,7 @@ describe("convertClaudeToCopilot", () => { const parsed = parseFrontmatter(agent.content) expect(parsed.data.description).toBe("Security-focused code review agent") - expect(parsed.data.tools).toEqual(["*"]) + expect(parsed.data.tools).toEqual(["read", "search", "execute"]) expect(parsed.data.infer).toBe(true) expect(parsed.body).toContain("Capabilities") expect(parsed.body).toContain("Threat modeling") @@ -127,12 +128,98 @@ describe("convertClaudeToCopilot", () => { expect(parsed.data.model).toBeUndefined() }) - test("agent tools defaults to [*]", () => { - const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + test("agent tools default to [*] when source agent has no tool restrictions", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "unrestricted-agent", + description: "No explicit tools", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/unrestricted.md", + }, + ], + } + const bundle = convertClaudeToCopilot(plugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) expect(parsed.data.tools).toEqual(["*"]) }) + test("agent tool mapping includes agent and web access", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "delegating-agent", + description: "Needs delegation and web access", + tools: ["Task", "WebFetch", "WebSearch"], + body: "Delegate and browse.", + sourcePath: "/tmp/plugin/agents/delegating.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.tools).toEqual(["agent", "web"]) + }) + + test("falls back to [*] when Claude tool restrictions have no Copilot mapping", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "question-agent", + description: "Needs an unmapped Claude-only tool", + tools: ["Question"], + body: "Ask follow-up questions.", + sourcePath: "/tmp/plugin/agents/question-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.tools).toEqual(["*"]) + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: Copilot has no mapping for Claude agent tools [Question]. Falling back to ["*"] so the converted agent remains usable.', + ) + + warnSpy.mockRestore() + }) + + test("falls back to [*] when only part of a Claude tool list maps to Copilot", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "mixed-agent", + description: "Needs one mapped and one unmapped tool", + tools: ["Read", "Question"], + body: "Inspect and ask follow-up questions.", + sourcePath: "/tmp/plugin/agents/mixed-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.tools).toEqual(["*"]) + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: Copilot cannot preserve Claude agent tools [Question] from [Read, Question]. Falling back to ["*"] so the converted agent remains usable.', + ) + + warnSpy.mockRestore() + }) + test("agent infer defaults to true", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) diff --git a/tests/droid-converter.test.ts b/tests/droid-converter.test.ts index cc52cdb42..fdf908c5f 100644 --- a/tests/droid-converter.test.ts +++ b/tests/droid-converter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, 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" @@ -11,6 +11,7 @@ const fixturePlugin: ClaudePlugin = { name: "Security Reviewer", description: "Security-focused agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities.", sourcePath: "/tmp/plugin/agents/security-reviewer.md", @@ -72,11 +73,78 @@ describe("convertClaudeToDroid", () => { 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.data.tools).toEqual(["Read", "Grep", "Glob", "Execute"]) expect(parsed.body).toContain("Capabilities") expect(parsed.body).toContain("Threat modeling") expect(parsed.body).toContain("Focus on vulnerabilities.") }) + test("omits tools when Claude restrictions have no Droid mapping", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "notebook-agent", + description: "Needs an unmapped Claude-only tool", + tools: ["NotebookRead"], + body: "Inspect notebooks.", + sourcePath: "/tmp/plugin/agents/notebook-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.droids[0].content) + expect(parsed.data.tools).toBeUndefined() + expect(warnSpy).toHaveBeenCalledWith( + "Warning: Droid has no mapping for Claude agent tools [NotebookRead]. Omitting the Droid tools field so the converted agent remains usable.", + ) + + warnSpy.mockRestore() + }) + + test("omits tools when only part of a Claude tool list maps to Droid", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "mixed-agent", + description: "Needs one mapped and one unmapped tool", + tools: ["Read", "NotebookRead"], + body: "Inspect code and notebooks.", + sourcePath: "/tmp/plugin/agents/mixed-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.droids[0].content) + expect(parsed.data.tools).toBeUndefined() + expect(warnSpy).toHaveBeenCalledWith( + "Warning: Droid cannot preserve Claude agent tools [NotebookRead] from [Read, NotebookRead]. Omitting the Droid tools field so the converted agent remains usable.", + ) + + warnSpy.mockRestore() + }) + test("passes through skill directories", () => { const bundle = convertClaudeToDroid(fixturePlugin, { agentMode: "subagent", diff --git a/tests/fixtures/sample-plugin/agents/agent-one.md b/tests/fixtures/sample-plugin/agents/agent-one.md index 316fb2026..78a68b0a2 100644 --- a/tests/fixtures/sample-plugin/agents/agent-one.md +++ b/tests/fixtures/sample-plugin/agents/agent-one.md @@ -4,6 +4,7 @@ description: Research repository structure and conventions capabilities: - "Capability A" - "Capability B" +tools: Read, Grep, Glob model: inherit --- diff --git a/tests/gemini-converter.test.ts b/tests/gemini-converter.test.ts index db923ac7b..9742d6d5e 100644 --- a/tests/gemini-converter.test.ts +++ b/tests/gemini-converter.test.ts @@ -11,6 +11,7 @@ const fixturePlugin: ClaudePlugin = { name: "Security Reviewer", description: "Security-focused agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities.", sourcePath: "/tmp/plugin/agents/security-reviewer.md", diff --git a/tests/kiro-converter.test.ts b/tests/kiro-converter.test.ts index 4a743ff06..243abef54 100644 --- a/tests/kiro-converter.test.ts +++ b/tests/kiro-converter.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "fs" import os from "os" import path from "path" -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import { convertClaudeToKiro, transformContentForKiro } from "../src/converters/claude-to-kiro" import { parseFrontmatter } from "../src/utils/frontmatter" import type { ClaudePlugin } from "../src/types/claude" @@ -14,6 +14,7 @@ const fixturePlugin: ClaudePlugin = { name: "Security Reviewer", description: "Security-focused agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities.", sourcePath: "/tmp/plugin/agents/security-reviewer.md", @@ -59,7 +60,7 @@ describe("convertClaudeToKiro", () => { expect(agent!.config.name).toBe("security-reviewer") expect(agent!.config.description).toBe("Security-focused agent") expect(agent!.config.prompt).toBe("file://./prompts/security-reviewer.md") - expect(agent!.config.tools).toEqual(["*"]) + expect(agent!.config.tools).toEqual(["read", "grep", "glob", "shell"]) expect(agent!.config.includeMcpJson).toBe(true) expect(agent!.config.resources).toContain("file://.kiro/steering/**/*.md") expect(agent!.config.resources).toContain("skill://.kiro/skills/**/SKILL.md") @@ -81,6 +82,80 @@ describe("convertClaudeToKiro", () => { expect(agent!.promptContent).toContain("- OWASP") }) + test("agent tool mapping includes web_search", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "web-agent", + description: "Needs search access", + tools: ["WebSearch", "WebFetch"], + body: "Browse for context.", + sourcePath: "/tmp/plugin/agents/web-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].config.tools).toEqual(["web_search", "web_fetch"]) + }) + + test("falls back to [*] when Claude tool restrictions have no Kiro mapping", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "question-agent", + description: "Needs an unmapped Claude-only tool", + tools: ["Question"], + body: "Ask follow-up questions.", + sourcePath: "/tmp/plugin/agents/question-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].config.tools).toEqual(["*"]) + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: Kiro has no mapping for Claude agent tools [Question]. Falling back to ["*"] so the converted agent remains usable.', + ) + + warnSpy.mockRestore() + }) + + test("falls back to [*] when only part of a Claude tool list maps to Kiro", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "mixed-agent", + description: "Needs one mapped and one unmapped tool", + tools: ["Read", "NotebookRead"], + body: "Inspect code and notebooks.", + sourcePath: "/tmp/plugin/agents/mixed-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].config.tools).toEqual(["*"]) + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: Kiro cannot preserve Claude agent tools [NotebookRead] from [Read, NotebookRead]. Falling back to ["*"] so the converted agent remains usable.', + ) + + warnSpy.mockRestore() + }) + test("agent with empty description gets default description", () => { const plugin: ClaudePlugin = { ...fixturePlugin, diff --git a/tests/openclaw-converter.test.ts b/tests/openclaw-converter.test.ts index e1648d58b..7ad697c2c 100644 --- a/tests/openclaw-converter.test.ts +++ b/tests/openclaw-converter.test.ts @@ -11,6 +11,7 @@ const fixturePlugin: ClaudePlugin = { name: "security-reviewer", description: "Security-focused agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities in ~/.claude/settings.", sourcePath: "/tmp/plugin/agents/security-reviewer.md", diff --git a/tests/qwen-converter.test.ts b/tests/qwen-converter.test.ts index b9690a347..4e6c73748 100644 --- a/tests/qwen-converter.test.ts +++ b/tests/qwen-converter.test.ts @@ -11,6 +11,7 @@ const fixturePlugin: ClaudePlugin = { name: "security-sentinel", description: "Security-focused agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities in ~/.claude/settings.", sourcePath: "/tmp/plugin/agents/security-sentinel.md", @@ -74,6 +75,7 @@ describe("convertClaudeToQwen", () => { expect(parsed.data.name).toBe("security-sentinel") expect(parsed.data.description).toBe("Security-focused agent") expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514") + expect(parsed.data.allowedTools).toEqual(["Read", "Grep", "Glob", "Bash"]) expect(parsed.body).toContain("Focus on vulnerabilities") }) diff --git a/tests/windsurf-converter.test.ts b/tests/windsurf-converter.test.ts index 5f76a25eb..0c9924193 100644 --- a/tests/windsurf-converter.test.ts +++ b/tests/windsurf-converter.test.ts @@ -10,6 +10,7 @@ const fixturePlugin: ClaudePlugin = { name: "Security Reviewer", description: "Security-focused agent", capabilities: ["Threat modeling", "OWASP"], + tools: ["Read", "Grep", "Glob", "Bash"], model: "claude-sonnet-4-20250514", body: "Focus on vulnerabilities.", sourcePath: "/tmp/plugin/agents/security-reviewer.md",