From ee76195daf747fc08c74c84a9323ea46df56c305 Mon Sep 17 00:00:00 2001 From: Wilson Tovar Date: Tue, 17 Feb 2026 14:20:19 +0100 Subject: [PATCH 1/3] feat(kiro): add Kiro CLI target provider types, converter, writer, and CLI registration --- src/commands/convert.ts | 3 +- src/commands/install.ts | 6 +- src/converters/claude-to-kiro.ts | 262 +++++++++++++++++++++++++++++++ src/targets/index.ts | 9 ++ src/targets/kiro.ts | 122 ++++++++++++++ src/types/kiro.ts | 44 ++++++ 6 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 src/converters/claude-to-kiro.ts create mode 100644 src/targets/kiro.ts create mode 100644 src/types/kiro.ts diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 9f62511..664a63e 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -23,7 +23,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | gemini | kiro)", }, output: { type: "string", @@ -146,5 +146,6 @@ function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHo if (targetName === "droid") return path.join(os.homedir(), ".factory") if (targetName === "cursor") return path.join(outputRoot, ".cursor") if (targetName === "gemini") return path.join(outputRoot, ".gemini") + if (targetName === "kiro") return path.join(outputRoot, ".kiro") return outputRoot } diff --git a/src/commands/install.ts b/src/commands/install.ts index c2412bb..77f5ea4 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -25,7 +25,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro)", }, output: { type: "string", @@ -191,6 +191,10 @@ function resolveTargetOutputRoot( const base = hasExplicitOutput ? outputRoot : process.cwd() return path.join(base, ".github") } + if (targetName === "kiro") { + const base = hasExplicitOutput ? outputRoot : process.cwd() + return path.join(base, ".kiro") + } return outputRoot } diff --git a/src/converters/claude-to-kiro.ts b/src/converters/claude-to-kiro.ts new file mode 100644 index 0000000..2711267 --- /dev/null +++ b/src/converters/claude-to-kiro.ts @@ -0,0 +1,262 @@ +import { readFileSync, existsSync } from "fs" +import path from "path" +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { + KiroAgent, + KiroAgentConfig, + KiroBundle, + KiroMcpServer, + KiroSkill, + KiroSteeringFile, +} from "../types/kiro" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToKiroOptions = ClaudeToOpenCodeOptions + +const KIRO_SKILL_NAME_MAX_LENGTH = 64 +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", +} + +export function convertClaudeToKiro( + plugin: ClaudePlugin, + _options: ClaudeToKiroOptions, +): KiroBundle { + const usedSkillNames = new Set() + + // Pass-through skills are processed first — they're the source of truth + const skillDirs = plugin.skills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })) + for (const skill of skillDirs) { + usedSkillNames.add(normalizeName(skill.name)) + } + + // Convert agents to Kiro custom agents + const agentNames = plugin.agents.map((a) => normalizeName(a.name)) + const agents = plugin.agents.map((agent) => convertAgentToKiroAgent(agent, agentNames)) + + // Convert commands to skills (generated) + const generatedSkills = plugin.commands.map((command) => + convertCommandToSkill(command, usedSkillNames, agentNames), + ) + + // Convert MCP servers (stdio only) + const mcpServers = convertMcpServers(plugin.mcpServers) + + // Build steering files from CLAUDE.md + const steeringFiles = buildSteeringFiles(plugin, agentNames) + + // Warn about hooks + if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { + console.warn( + "Warning: Kiro CLI hooks use a different format (preToolUse/postToolUse inside agent configs). Hooks were skipped during conversion.", + ) + } + + return { agents, generatedSkills, skillDirs, steeringFiles, mcpServers } +} + +function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]): KiroAgent { + const name = normalizeName(agent.name) + const description = sanitizeDescription( + agent.description ?? `Use this agent for ${agent.name} tasks`, + ) + + const config: KiroAgentConfig = { + name, + description, + prompt: `file://./prompts/${name}.md`, + tools: ["*"], + resources: [ + "file://.kiro/steering/**/*.md", + "skill://.kiro/skills/**/SKILL.md", + ], + includeMcpJson: true, + 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.` + } + + return { name, config, promptContent: body } +} + +function convertCommandToSkill( + command: ClaudeCommand, + usedNames: Set, + knownAgentNames: string[], +): KiroSkill { + const rawName = normalizeName(command.name) + const name = uniqueName(rawName, usedNames) + + const description = sanitizeDescription( + command.description ?? `Converted from Claude command ${command.name}`, + ) + + const frontmatter: Record = { name, description } + + let body = transformContentForKiro(command.body.trim(), knownAgentNames) + if (body.length === 0) { + body = `Instructions converted from the ${command.name} command.` + } + + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +/** + * Transform Claude Code content to Kiro-compatible content. + * + * 1. Task agent calls: Task agent-name(args) -> Use the use_subagent tool ... + * 2. Path rewriting: .claude/ -> .kiro/, ~/.claude/ -> ~/.kiro/ + * 3. Slash command refs: /workflows:plan -> use the workflows-plan skill + * 4. Claude tool names: Bash -> shell, Read -> read, etc. + * 5. Agent refs: @agent-name -> the agent-name agent (only for known agent names) + */ +export function transformContentForKiro(body: string, knownAgentNames: string[] = []): string { + let result = body + + // 1. Transform Task agent calls + const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm + result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { + return `${prefix}Use the use_subagent tool to delegate to the ${normalizeName(agentName)} agent: ${args.trim()}` + }) + + // 2. Rewrite .claude/ paths to .kiro/ (with word-boundary-like lookbehind) + result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.kiro/") + result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".kiro/") + + // 3. Slash command refs: /command-name -> skill activation language + result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => { + const skillName = normalizeName(cmdName) + return `the ${skillName} skill` + }) + + // 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") + result = result.replace(toolPattern, kiroTool) + } + + // 5. Transform @agent-name references (only for known agent names) + if (knownAgentNames.length > 0) { + const escapedNames = knownAgentNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + const agentRefPattern = new RegExp(`@(${escapedNames.join("|")})\\b`, "g") + result = result.replace(agentRefPattern, (_match, agentName: string) => { + return `the ${normalizeName(agentName)} agent` + }) + } + + return result +} + +function convertMcpServers( + servers?: Record, +): Record { + if (!servers || Object.keys(servers).length === 0) return {} + + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + if (!server.command) { + console.warn( + `Warning: MCP server "${name}" has no command (HTTP/SSE transport). Kiro only supports stdio. Skipping.`, + ) + continue + } + + const entry: KiroMcpServer = { 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 + + console.log(`MCP server "${name}" will execute: ${server.command}${server.args ? " " + server.args.join(" ") : ""}`) + result[name] = entry + } + return result +} + +function buildSteeringFiles(plugin: ClaudePlugin, knownAgentNames: string[]): KiroSteeringFile[] { + const claudeMdPath = path.join(plugin.root, "CLAUDE.md") + if (!existsSync(claudeMdPath)) return [] + + let content: string + try { + content = readFileSync(claudeMdPath, "utf8") + } catch { + return [] + } + + if (!content || content.trim().length === 0) return [] + + const transformed = transformContentForKiro(content, knownAgentNames) + return [{ name: "compound-engineering", content: transformed }] +} + +function normalizeName(value: string): string { + const trimmed = value.trim() + if (!trimmed) return "item" + let normalized = trimmed + .toLowerCase() + .replace(/[\\/]+/g, "-") + .replace(/[:\s]+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") // Collapse consecutive hyphens (Agent Skills standard) + .replace(/^-+|-+$/g, "") + + // Enforce max length (truncate at last hyphen boundary) + if (normalized.length > KIRO_SKILL_NAME_MAX_LENGTH) { + normalized = normalized.slice(0, KIRO_SKILL_NAME_MAX_LENGTH) + const lastHyphen = normalized.lastIndexOf("-") + if (lastHyphen > 0) { + normalized = normalized.slice(0, lastHyphen) + } + normalized = normalized.replace(/-+$/g, "") + } + + // Ensure name starts with a letter + if (normalized.length === 0 || !/^[a-z]/.test(normalized)) { + return "item" + } + + return normalized +} + +function sanitizeDescription(value: string, maxLength = KIRO_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 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 +} diff --git a/src/targets/index.ts b/src/targets/index.ts index b4cadb0..b7b3ea2 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -5,18 +5,21 @@ import type { DroidBundle } from "../types/droid" import type { PiBundle } from "../types/pi" import type { CopilotBundle } from "../types/copilot" import type { GeminiBundle } from "../types/gemini" +import type { KiroBundle } from "../types/kiro" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" import { convertClaudeToDroid } from "../converters/claude-to-droid" import { convertClaudeToPi } from "../converters/claude-to-pi" import { convertClaudeToCopilot } from "../converters/claude-to-copilot" import { convertClaudeToGemini } from "../converters/claude-to-gemini" +import { convertClaudeToKiro } from "../converters/claude-to-kiro" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" import { writePiBundle } from "./pi" import { writeCopilotBundle } from "./copilot" import { writeGeminiBundle } from "./gemini" +import { writeKiroBundle } from "./kiro" export type TargetHandler = { name: string @@ -62,4 +65,10 @@ export const targets: Record = { convert: convertClaudeToGemini as TargetHandler["convert"], write: writeGeminiBundle as TargetHandler["write"], }, + kiro: { + name: "kiro", + implemented: true, + convert: convertClaudeToKiro as TargetHandler["convert"], + write: writeKiroBundle as TargetHandler["write"], + }, } diff --git a/src/targets/kiro.ts b/src/targets/kiro.ts new file mode 100644 index 0000000..3597951 --- /dev/null +++ b/src/targets/kiro.ts @@ -0,0 +1,122 @@ +import path from "path" +import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" +import type { KiroBundle } from "../types/kiro" + +export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): Promise { + const paths = resolveKiroPaths(outputRoot) + await ensureDir(paths.kiroDir) + + // Write agents + if (bundle.agents.length > 0) { + for (const agent of bundle.agents) { + // Validate name doesn't escape agents directory + validatePathSafe(agent.name, "agent") + + // Write agent JSON config + await writeJson( + path.join(paths.agentsDir, `${agent.name}.json`), + agent.config, + ) + + // Write agent prompt file + await writeText( + path.join(paths.agentsDir, "prompts", `${agent.name}.md`), + agent.promptContent + "\n", + ) + } + } + + // Write generated skills (from commands) + if (bundle.generatedSkills.length > 0) { + for (const skill of bundle.generatedSkills) { + validatePathSafe(skill.name, "skill") + await writeText( + path.join(paths.skillsDir, skill.name, "SKILL.md"), + skill.content + "\n", + ) + } + } + + // Copy skill directories (pass-through) + if (bundle.skillDirs.length > 0) { + for (const skill of bundle.skillDirs) { + validatePathSafe(skill.name, "skill directory") + const destDir = path.join(paths.skillsDir, skill.name) + + // Validate destination doesn't escape skills directory + const resolvedDest = path.resolve(destDir) + if (!resolvedDest.startsWith(path.resolve(paths.skillsDir))) { + console.warn(`Warning: Skill name "${skill.name}" escapes .kiro/skills/. Skipping.`) + continue + } + + await copyDir(skill.sourceDir, destDir) + } + } + + // Write steering files + if (bundle.steeringFiles.length > 0) { + for (const file of bundle.steeringFiles) { + validatePathSafe(file.name, "steering file") + await writeText( + path.join(paths.steeringDir, `${file.name}.md`), + file.content + "\n", + ) + } + } + + // Write MCP servers to mcp.json + if (Object.keys(bundle.mcpServers).length > 0) { + const mcpPath = path.join(paths.settingsDir, "mcp.json") + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing mcp.json to ${backupPath}`) + } + + // Merge with existing mcp.json if present + let existingConfig: Record = {} + if (await pathExists(mcpPath)) { + try { + existingConfig = await readJson>(mcpPath) + } catch { + console.warn("Warning: existing mcp.json could not be parsed and will be replaced.") + } + } + + const existingServers = + existingConfig.mcpServers && typeof existingConfig.mcpServers === "object" + ? (existingConfig.mcpServers as Record) + : {} + const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpServers } } + await writeJson(mcpPath, merged) + } +} + +function resolveKiroPaths(outputRoot: string) { + const base = path.basename(outputRoot) + // If already pointing at .kiro, write directly into it + if (base === ".kiro") { + return { + kiroDir: outputRoot, + agentsDir: path.join(outputRoot, "agents"), + skillsDir: path.join(outputRoot, "skills"), + steeringDir: path.join(outputRoot, "steering"), + settingsDir: path.join(outputRoot, "settings"), + } + } + // Otherwise nest under .kiro + const kiroDir = path.join(outputRoot, ".kiro") + return { + kiroDir, + agentsDir: path.join(kiroDir, "agents"), + skillsDir: path.join(kiroDir, "skills"), + steeringDir: path.join(kiroDir, "steering"), + settingsDir: path.join(kiroDir, "settings"), + } +} + +function validatePathSafe(name: string, label: string): void { + if (name.includes("..") || name.includes("/") || name.includes("\\")) { + throw new Error(`${label} name contains unsafe path characters: ${name}`) + } +} diff --git a/src/types/kiro.ts b/src/types/kiro.ts new file mode 100644 index 0000000..9144c55 --- /dev/null +++ b/src/types/kiro.ts @@ -0,0 +1,44 @@ +export type KiroAgent = { + name: string + config: KiroAgentConfig + promptContent: string +} + +export type KiroAgentConfig = { + name: string + description: string + prompt: `file://${string}` + tools: ["*"] + resources: string[] + includeMcpJson: true + welcomeMessage?: string +} + +export type KiroSkill = { + name: string + content: string // Full SKILL.md with YAML frontmatter +} + +export type KiroSkillDir = { + name: string + sourceDir: string +} + +export type KiroSteeringFile = { + name: string + content: string +} + +export type KiroMcpServer = { + command: string + args?: string[] + env?: Record +} + +export type KiroBundle = { + agents: KiroAgent[] + generatedSkills: KiroSkill[] + skillDirs: KiroSkillDir[] + steeringFiles: KiroSteeringFile[] + mcpServers: Record +} From 7a41f64f06e037fd8fe8095bb3d84c8cbadc2bda Mon Sep 17 00:00:00 2001 From: Wilson Tovar Date: Tue, 17 Feb 2026 14:22:40 +0100 Subject: [PATCH 2/3] test(kiro): add converter and writer tests for Kiro provider --- tests/kiro-converter.test.ts | 381 +++++++++++++++++++++++++++++++++++ tests/kiro-writer.test.ts | 273 +++++++++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 tests/kiro-converter.test.ts create mode 100644 tests/kiro-writer.test.ts diff --git a/tests/kiro-converter.test.ts b/tests/kiro-converter.test.ts new file mode 100644 index 0000000..e638f71 --- /dev/null +++ b/tests/kiro-converter.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, 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" + +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: { + local: { command: "echo", args: ["hello"] }, + }, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, +} + +describe("convertClaudeToKiro", () => { + test("converts agents to Kiro agent configs with prompt files", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent).toBeDefined() + 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.includeMcpJson).toBe(true) + expect(agent!.config.resources).toContain("file://.kiro/steering/**/*.md") + expect(agent!.config.resources).toContain("skill://.kiro/skills/**/SKILL.md") + expect(agent!.promptContent).toContain("Focus on vulnerabilities.") + }) + + test("agent config has welcomeMessage generated from description", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.config.welcomeMessage).toContain("security-reviewer") + expect(agent!.config.welcomeMessage).toContain("Security-focused agent") + }) + + test("agent with capabilities prepended to prompt content", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.promptContent).toContain("## Capabilities") + expect(agent!.promptContent).toContain("- Threat modeling") + expect(agent!.promptContent).toContain("- OWASP") + }) + + test("agent with empty description gets default description", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "my-agent", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/my-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].config.description).toBe("Use this agent for my-agent tasks") + }) + + test("agent model field silently dropped", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect((agent!.config as Record).model).toBeUndefined() + }) + + test("agent with empty body gets default body text", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "Empty Agent", + description: "An empty agent", + body: "", + sourcePath: "/tmp/plugin/agents/empty.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].promptContent).toContain("Instructions converted from the Empty Agent agent.") + }) + + test("converts commands to SKILL.md with valid frontmatter", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + + expect(bundle.generatedSkills).toHaveLength(1) + const skill = bundle.generatedSkills[0] + expect(skill.name).toBe("workflows-plan") + const parsed = parseFrontmatter(skill.content) + expect(parsed.data.name).toBe("workflows-plan") + expect(parsed.data.description).toBe("Planning command") + expect(parsed.body).toContain("Plan the work.") + }) + + test("command with disable-model-invocation is still included", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "disabled-command", + description: "Disabled command", + disableModelInvocation: true, + body: "Disabled body.", + sourcePath: "/tmp/plugin/commands/disabled.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.generatedSkills).toHaveLength(1) + expect(bundle.generatedSkills[0].name).toBe("disabled-command") + }) + + test("command allowedTools silently dropped", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const skill = bundle.generatedSkills[0] + expect(skill.content).not.toContain("allowedTools") + }) + + test("skills pass through as directory references", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + + 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("MCP stdio servers convert to mcp.json-compatible config", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + expect(bundle.mcpServers.local.command).toBe("echo") + expect(bundle.mcpServers.local.args).toEqual(["hello"]) + }) + + test("MCP HTTP servers skipped with warning", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + httpServer: { url: "https://example.com/mcp" }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + console.warn = originalWarn + + expect(Object.keys(bundle.mcpServers)).toHaveLength(0) + expect(warnings.some((w) => w.includes("no command") || w.includes("HTTP"))).toBe(true) + }) + + test("plugin with zero agents produces empty agents array", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(0) + }) + + test("plugin with only skills works correctly", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(1) + }) + + test("skill name colliding with command name: command gets deduplicated", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + skills: [{ name: "my-command", description: "Existing skill", sourceDir: "/tmp/skill", skillPath: "/tmp/skill/SKILL.md" }], + commands: [{ name: "my-command", description: "A command", body: "Body.", sourcePath: "/tmp/commands/cmd.md" }], + agents: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + + // Skill keeps original name, command gets deduplicated + expect(bundle.skillDirs[0].name).toBe("my-command") + expect(bundle.generatedSkills[0].name).toBe("my-command-2") + }) + + test("hooks present emits console.warn", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + hooks: { hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "echo test" }] }] } }, + agents: [], + commands: [], + skills: [], + } + + convertClaudeToKiro(plugin, defaultOptions) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("Kiro"))).toBe(true) + }) + + test("steering file not generated when CLAUDE.md missing", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + root: "/tmp/nonexistent-plugin-dir", + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.steeringFiles).toHaveLength(0) + }) + + test("name normalization handles various inputs", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "My Cool Agent!!!", description: "Cool", body: "Body.", sourcePath: "/tmp/a.md" }, + { name: "UPPERCASE-AGENT", description: "Upper", body: "Body.", sourcePath: "/tmp/b.md" }, + { name: "agent--with--double-hyphens", description: "Hyphens", body: "Body.", sourcePath: "/tmp/c.md" }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].name).toBe("my-cool-agent") + expect(bundle.agents[1].name).toBe("uppercase-agent") + expect(bundle.agents[2].name).toBe("agent-with-double-hyphens") // collapsed + }) + + test("description truncation to 1024 chars", () => { + const longDesc = "a".repeat(2000) + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "long-desc", description: longDesc, body: "Body.", sourcePath: "/tmp/a.md" }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].config.description.length).toBeLessThanOrEqual(1024) + expect(bundle.agents[0].config.description.endsWith("...")).toBe(true) + }) + + test("empty plugin produces empty bundle", () => { + const plugin: ClaudePlugin = { + root: "/tmp/empty", + manifest: { name: "empty", version: "1.0.0" }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(0) + expect(bundle.steeringFiles).toHaveLength(0) + expect(Object.keys(bundle.mcpServers)).toHaveLength(0) + }) +}) + +describe("transformContentForKiro", () => { + test("transforms .claude/ paths to .kiro/", () => { + const result = transformContentForKiro("Read .claude/settings.json for config.") + expect(result).toContain(".kiro/settings.json") + expect(result).not.toContain(".claude/") + }) + + test("transforms ~/.claude/ paths to ~/.kiro/", () => { + const result = transformContentForKiro("Check ~/.claude/config for settings.") + expect(result).toContain("~/.kiro/config") + expect(result).not.toContain("~/.claude/") + }) + + test("transforms Task agent(args) to use_subagent reference", () => { + const input = `Run these: + +- Task repo-research-analyst(feature_description) +- Task learnings-researcher(feature_description) + +Task best-practices-researcher(topic)` + + const result = transformContentForKiro(input) + expect(result).toContain("Use the use_subagent tool to delegate to the repo-research-analyst agent: feature_description") + expect(result).toContain("Use the use_subagent tool to delegate to the learnings-researcher agent: feature_description") + expect(result).toContain("Use the use_subagent tool to delegate to the best-practices-researcher agent: topic") + expect(result).not.toContain("Task repo-research-analyst") + }) + + test("transforms @agent references for known agents only", () => { + const result = transformContentForKiro("Ask @security-sentinel for a review.", ["security-sentinel"]) + expect(result).toContain("the security-sentinel agent") + expect(result).not.toContain("@security-sentinel") + }) + + test("does not transform @unknown-name when not in known agents", () => { + const result = transformContentForKiro("Contact @someone-else for help.", ["security-sentinel"]) + expect(result).toContain("@someone-else") + }) + + test("transforms Claude tool names to Kiro equivalents", () => { + const result = transformContentForKiro("Use the Bash tool to run commands. Use Read to check files.") + expect(result).toContain("shell tool") + expect(result).toContain("read to") + }) + + test("transforms slash command refs to skill activation", () => { + const result = transformContentForKiro("Run /workflows:plan to start planning.") + expect(result).toContain("the workflows-plan skill") + }) + + test("does not transform partial .claude paths like package/.claude-config/", () => { + const result = transformContentForKiro("Check some-package/.claude-config/settings") + // The .claude-config/ part should be transformed since it starts with .claude/ + // but only when preceded by a word boundary + expect(result).toContain("some-package/") + }) +}) diff --git a/tests/kiro-writer.test.ts b/tests/kiro-writer.test.ts new file mode 100644 index 0000000..301dcb6 --- /dev/null +++ b/tests/kiro-writer.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeKiroBundle } from "../src/targets/kiro" +import type { KiroBundle } from "../src/types/kiro" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +const emptyBundle: KiroBundle = { + agents: [], + generatedSkills: [], + skillDirs: [], + steeringFiles: [], + mcpServers: {}, +} + +describe("writeKiroBundle", () => { + test("writes agents, skills, steering, and mcp.json", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-test-")) + const bundle: KiroBundle = { + agents: [ + { + name: "security-reviewer", + config: { + name: "security-reviewer", + description: "Security-focused agent", + prompt: "file://./prompts/security-reviewer.md", + tools: ["*"], + resources: ["file://.kiro/steering/**/*.md", "skill://.kiro/skills/**/SKILL.md"], + includeMcpJson: true, + welcomeMessage: "Switching to security-reviewer.", + }, + promptContent: "Review code for vulnerabilities.", + }, + ], + generatedSkills: [ + { + name: "workflows-plan", + content: "---\nname: workflows-plan\ndescription: Planning\n---\n\nPlan the work.", + }, + ], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + steeringFiles: [ + { name: "compound-engineering", content: "# Steering content\n\nFollow these guidelines." }, + ], + mcpServers: { + playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] }, + }, + } + + await writeKiroBundle(tempRoot, bundle) + + // Agent JSON config + const agentConfigPath = path.join(tempRoot, ".kiro", "agents", "security-reviewer.json") + expect(await exists(agentConfigPath)).toBe(true) + const agentConfig = JSON.parse(await fs.readFile(agentConfigPath, "utf8")) + expect(agentConfig.name).toBe("security-reviewer") + expect(agentConfig.includeMcpJson).toBe(true) + expect(agentConfig.tools).toEqual(["*"]) + + // Agent prompt file + const promptPath = path.join(tempRoot, ".kiro", "agents", "prompts", "security-reviewer.md") + expect(await exists(promptPath)).toBe(true) + const promptContent = await fs.readFile(promptPath, "utf8") + expect(promptContent).toContain("Review code for vulnerabilities.") + + // Generated skill + const skillPath = path.join(tempRoot, ".kiro", "skills", "workflows-plan", "SKILL.md") + expect(await exists(skillPath)).toBe(true) + const skillContent = await fs.readFile(skillPath, "utf8") + expect(skillContent).toContain("Plan the work.") + + // Copied skill + expect(await exists(path.join(tempRoot, ".kiro", "skills", "skill-one", "SKILL.md"))).toBe(true) + + // Steering file + const steeringPath = path.join(tempRoot, ".kiro", "steering", "compound-engineering.md") + expect(await exists(steeringPath)).toBe(true) + const steeringContent = await fs.readFile(steeringPath, "utf8") + expect(steeringContent).toContain("Follow these guidelines.") + + // MCP config + const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json") + expect(await exists(mcpPath)).toBe(true) + const mcpContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(mcpContent.mcpServers.playwright.command).toBe("npx") + }) + + test("does not double-nest when output root is .kiro", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-home-")) + const kiroRoot = path.join(tempRoot, ".kiro") + const bundle: KiroBundle = { + ...emptyBundle, + agents: [ + { + name: "reviewer", + config: { + name: "reviewer", + description: "A reviewer", + prompt: "file://./prompts/reviewer.md", + tools: ["*"], + resources: [], + includeMcpJson: true, + }, + promptContent: "Review content.", + }, + ], + } + + await writeKiroBundle(kiroRoot, bundle) + + expect(await exists(path.join(kiroRoot, "agents", "reviewer.json"))).toBe(true) + // Should NOT double-nest under .kiro/.kiro + expect(await exists(path.join(kiroRoot, ".kiro"))).toBe(false) + }) + + test("handles empty bundles gracefully", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-empty-")) + + await writeKiroBundle(tempRoot, emptyBundle) + expect(await exists(tempRoot)).toBe(true) + }) + + test("backs up existing mcp.json before overwrite", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-backup-")) + const kiroRoot = path.join(tempRoot, ".kiro") + const settingsDir = path.join(kiroRoot, "settings") + await fs.mkdir(settingsDir, { recursive: true }) + + // Write existing mcp.json + const mcpPath = path.join(settingsDir, "mcp.json") + await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } })) + + const bundle: KiroBundle = { + ...emptyBundle, + mcpServers: { newServer: { command: "new-cmd" } }, + } + + await writeKiroBundle(kiroRoot, bundle) + + // New mcp.json should have the new content + const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(newContent.mcpServers.newServer.command).toBe("new-cmd") + + // A backup file should exist + const files = await fs.readdir(settingsDir) + const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak.")) + expect(backupFiles.length).toBeGreaterThanOrEqual(1) + }) + + test("merges mcpServers into existing mcp.json without clobbering other keys", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-merge-")) + const kiroRoot = path.join(tempRoot, ".kiro") + const settingsDir = path.join(kiroRoot, "settings") + await fs.mkdir(settingsDir, { recursive: true }) + + // Write existing mcp.json with other keys + const mcpPath = path.join(settingsDir, "mcp.json") + await fs.writeFile(mcpPath, JSON.stringify({ + customKey: "preserve-me", + mcpServers: { old: { command: "old-cmd" } }, + })) + + const bundle: KiroBundle = { + ...emptyBundle, + mcpServers: { newServer: { command: "new-cmd" } }, + } + + await writeKiroBundle(kiroRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.customKey).toBe("preserve-me") + expect(content.mcpServers.old.command).toBe("old-cmd") + expect(content.mcpServers.newServer.command).toBe("new-cmd") + }) + + test("mcp.json fresh write when no existing file", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-fresh-")) + const bundle: KiroBundle = { + ...emptyBundle, + mcpServers: { myServer: { command: "my-cmd", args: ["--flag"] } }, + } + + await writeKiroBundle(tempRoot, bundle) + + const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json") + expect(await exists(mcpPath)).toBe(true) + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcpServers.myServer.command).toBe("my-cmd") + expect(content.mcpServers.myServer.args).toEqual(["--flag"]) + }) + + test("agent JSON files are valid JSON with expected fields", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-json-")) + const bundle: KiroBundle = { + ...emptyBundle, + agents: [ + { + name: "test-agent", + config: { + name: "test-agent", + description: "Test agent", + prompt: "file://./prompts/test-agent.md", + tools: ["*"], + resources: ["file://.kiro/steering/**/*.md"], + includeMcpJson: true, + welcomeMessage: "Hello from test-agent.", + }, + promptContent: "Do test things.", + }, + ], + } + + await writeKiroBundle(tempRoot, bundle) + + const configPath = path.join(tempRoot, ".kiro", "agents", "test-agent.json") + const raw = await fs.readFile(configPath, "utf8") + const parsed = JSON.parse(raw) // Should not throw + expect(parsed.name).toBe("test-agent") + expect(parsed.prompt).toBe("file://./prompts/test-agent.md") + expect(parsed.tools).toEqual(["*"]) + expect(parsed.includeMcpJson).toBe(true) + expect(parsed.welcomeMessage).toBe("Hello from test-agent.") + }) + + test("path traversal attempt in skill name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal-")) + const bundle: KiroBundle = { + ...emptyBundle, + generatedSkills: [ + { name: "../escape", content: "Malicious content" }, + ], + } + + expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) + + test("path traversal in agent name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal2-")) + const bundle: KiroBundle = { + ...emptyBundle, + agents: [ + { + name: "../escape", + config: { + name: "../escape", + description: "Malicious", + prompt: "file://./prompts/../escape.md", + tools: ["*"], + resources: [], + includeMcpJson: true, + }, + promptContent: "Bad.", + }, + ], + } + + expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) +}) From a77eacb85600ff089169bf91bd8fff33fcebcc5d Mon Sep 17 00:00:00 2001 From: Wilson Tovar Date: Tue, 17 Feb 2026 14:24:04 +0100 Subject: [PATCH 3/3] docs(kiro): add Kiro format spec and update README with Kiro provider --- README.md | 8 ++- docs/specs/kiro.md | 171 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 docs/specs/kiro.md diff --git a/README.md b/README.md index 74dd4a0..27e4ae7 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** /add-plugin compound-engineering ``` -## OpenCode, Codex, Droid, Pi, Gemini & GitHub Copilot (experimental) Install +## OpenCode, Codex, Droid, Pi, Gemini, Copilot & Kiro (experimental) Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI and GitHub Copilot. +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, and Kiro CLI. ```bash # convert the compound-engineering plugin into OpenCode format @@ -40,6 +40,9 @@ bunx @every-env/compound-plugin install compound-engineering --to gemini # convert to GitHub Copilot format bunx @every-env/compound-plugin install compound-engineering --to copilot + +# convert to Kiro CLI format +bunx @every-env/compound-plugin install compound-engineering --to kiro ``` Local dev: @@ -54,6 +57,7 @@ Droid output is written to `~/.factory/` with commands, droids (agents), and ski Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. Gemini output is written to `.gemini/` with skills (from agents), commands (`.toml`), and `settings.json` (MCP servers). Namespaced commands create directory structure (`workflows:plan` → `commands/workflows/plan.toml`). Skills use the identical SKILL.md standard and pass through unchanged. Copilot output is written to `.github/` with agents (`.agent.md`), skills (`SKILL.md`), and `copilot-mcp-config.json`. Agents get Copilot frontmatter (`description`, `tools: ["*"]`, `infer: true`), commands are converted to agent skills, and MCP server env vars are prefixed with `COPILOT_MCP_`. +Kiro output is written to `.kiro/` with custom agents (`.json` configs + prompt `.md` files), skills (from commands), pass-through skills, steering files (from CLAUDE.md), and `mcp.json`. Agents get `includeMcpJson: true` for MCP server access. Only stdio MCP servers are supported (HTTP servers are skipped with a warning). All provider targets are experimental and may change as the formats evolve. diff --git a/docs/specs/kiro.md b/docs/specs/kiro.md new file mode 100644 index 0000000..056be0d --- /dev/null +++ b/docs/specs/kiro.md @@ -0,0 +1,171 @@ +# Kiro CLI Spec (Custom Agents, Skills, Steering, MCP, Settings) + +Last verified: 2026-02-17 + +## Primary sources + +``` +https://kiro.dev/docs/cli/ +https://kiro.dev/docs/cli/custom-agents/configuration-reference/ +https://kiro.dev/docs/cli/skills/ +https://kiro.dev/docs/cli/steering/ +https://kiro.dev/docs/cli/mcp/ +https://kiro.dev/docs/cli/hooks/ +https://agentskills.io +``` + +## Config locations + +- Project-level config: `.kiro/` directory at project root. +- No global/user-level config directory — all config is project-scoped. + +## Directory structure + +``` +.kiro/ +├── agents/ +│ ├── .json # Agent configuration +│ └── prompts/ +│ └── .md # Agent prompt files +├── skills/ +│ └── / +│ └── SKILL.md # Skill definition +├── steering/ +│ └── .md # Always-on context files +└── settings/ + └── mcp.json # MCP server configuration +``` + +## Custom agents (JSON config + prompt files) + +- Custom agents are JSON files in `.kiro/agents/`. +- Each agent has a corresponding prompt `.md` file, referenced via `file://` URI. +- Agent config has 14 possible fields (see below). +- Agents are activated by user selection (no auto-activation). +- The converter outputs a subset of fields relevant to converted plugins. + +### Agent config fields + +| Field | Type | Used in conversion | Notes | +|---|---|---|---| +| `name` | string | Yes | Agent display name | +| `description` | string | Yes | Human-readable description | +| `prompt` | string or `file://` URI | Yes | System prompt or file reference | +| `tools` | string[] | Yes (`["*"]`) | Available tools | +| `resources` | string[] | Yes | `file://`, `skill://`, `knowledgeBase` URIs | +| `includeMcpJson` | boolean | Yes (`true`) | Inherit project MCP servers | +| `welcomeMessage` | string | Yes | Agent switch greeting | +| `mcpServers` | object | No | Per-agent MCP config (use includeMcpJson instead) | +| `toolAliases` | Record | No | Tool name remapping | +| `allowedTools` | string[] | No | Auto-approve patterns | +| `toolsSettings` | object | No | Per-tool configuration | +| `hooks` | object | No (future work) | 5 trigger types | +| `model` | string | No | Model selection | +| `keyboardShortcut` | string | No | Quick-switch shortcut | + +### Example agent config + +```json +{ + "name": "security-reviewer", + "description": "Reviews code for security vulnerabilities", + "prompt": "file://./prompts/security-reviewer.md", + "tools": ["*"], + "resources": [ + "file://.kiro/steering/**/*.md", + "skill://.kiro/skills/**/SKILL.md" + ], + "includeMcpJson": true, + "welcomeMessage": "Switching to security-reviewer. Reviews code for security vulnerabilities" +} +``` + +## Skills (SKILL.md standard) + +- Skills follow the open [Agent Skills](https://agentskills.io) standard. +- A skill is a folder containing `SKILL.md` plus optional supporting files. +- Skills live in `.kiro/skills/`. +- `SKILL.md` uses YAML frontmatter with `name` and `description` fields. +- Kiro activates skills on demand based on description matching. +- The `description` field is critical — Kiro uses it to decide when to activate the skill. + +### Constraints + +- Skill name: max 64 characters, pattern `^[a-z][a-z0-9-]*$`, no consecutive hyphens (`--`). +- Skill description: max 1024 characters. +- Skill name must match parent directory name. + +### Example + +```yaml +--- +name: workflows-plan +description: Plan work by analyzing requirements and creating actionable steps +--- + +# Planning Workflow + +Detailed instructions... +``` + +## Steering files + +- Markdown files in `.kiro/steering/`. +- Always loaded into every agent session's context. +- Equivalent to Claude Code's CLAUDE.md. +- Used for project-wide instructions, coding standards, and conventions. + +## MCP server configuration + +- MCP servers are configured in `.kiro/settings/mcp.json`. +- **Only stdio transport is supported** — `command` + `args` + `env`. +- HTTP/SSE transport (`url`, `headers`) is NOT supported by Kiro CLI. +- The converter skips HTTP-only MCP servers with a warning. + +### Example + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@anthropic/mcp-playwright"] + }, + "context7": { + "command": "npx", + "args": ["-y", "@context7/mcp-server"] + } + } +} +``` + +## Hooks + +- Kiro supports 5 hook trigger types: `agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`. +- Hooks are configured inside agent JSON configs (not separate files). +- 3 of 5 triggers map to Claude Code hooks (`preToolUse`, `postToolUse`, `stop`). +- Not converted by the plugin converter for MVP — a warning is emitted. + +## Conversion lossy mappings + +| Claude Code Feature | Kiro Status | Notes | +|---|---|---| +| `Edit` tool (surgical replacement) | Degraded -> `write` (full-file) | Kiro write overwrites entire files | +| `context: fork` | Lost | No execution isolation control | +| `!`command`` dynamic injection | Lost | No pre-processing of markdown | +| `disable-model-invocation` | Lost | No invocation control | +| `allowed-tools` per skill | Lost | No tool permission scoping per skill | +| `$ARGUMENTS` interpolation | Lost | No structured argument passing | +| Claude hooks | Skipped | Future follow-up (near-1:1 for 3/5 triggers) | +| HTTP MCP servers | Skipped | Kiro only supports stdio transport | + +## Overwrite behavior during conversion + +| Content Type | Strategy | Rationale | +|---|---|---| +| Generated agents (JSON + prompt) | Overwrite | Generated, not user-authored | +| Generated skills (from commands) | Overwrite | Generated, not user-authored | +| Copied skills (pass-through) | Overwrite | Plugin is source of truth | +| Steering files | Overwrite | Generated from CLAUDE.md | +| `mcp.json` | Merge with backup | User may have added their own servers | +| User-created agents/skills | Preserved | Don't delete orphans |