From ee76195daf747fc08c74c84a9323ea46df56c305 Mon Sep 17 00:00:00 2001 From: Wilson Tovar Date: Tue, 17 Feb 2026 14:20:19 +0100 Subject: [PATCH] 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 +}