import path from "path" import { backupFile, copySkillDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" import { transformContentForKiro } from "../converters/claude-to-kiro" 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 } const knownAgentNames = bundle.agents.map((a) => a.name) await copySkillDir(skill.sourceDir, destDir, (content) => transformContentForKiro(content, knownAgentNames), ) } } // 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}`) } }