@every-env/compound-plugin
Advanced tools
| # 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/ | ||
| │ ├── <name>.json # Agent configuration | ||
| │ └── prompts/ | ||
| │ └── <name>.md # Agent prompt files | ||
| ├── skills/ | ||
| │ └── <name>/ | ||
| │ └── SKILL.md # Skill definition | ||
| ├── steering/ | ||
| │ └── <name>.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 | |
| 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<string, string> = { | ||
| Bash: "shell", | ||
| Write: "write", | ||
| Read: "read", | ||
| Edit: "write", // NOTE: Kiro write is full-file, not surgical edit. Lossy mapping. | ||
| Glob: "glob", | ||
| Grep: "grep", | ||
| WebFetch: "web_fetch", | ||
| Task: "use_subagent", | ||
| } | ||
| export function convertClaudeToKiro( | ||
| plugin: ClaudePlugin, | ||
| _options: ClaudeToKiroOptions, | ||
| ): KiroBundle { | ||
| const usedSkillNames = new Set<string>() | ||
| // 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<string>, | ||
| 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<string, unknown> = { 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<string, ClaudeMcpServer>, | ||
| ): Record<string, KiroMcpServer> { | ||
| if (!servers || Object.keys(servers).length === 0) return {} | ||
| const result: Record<string, KiroMcpServer> = {} | ||
| 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>): 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 | ||
| } |
| 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<void> { | ||
| 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<string, unknown> = {} | ||
| if (await pathExists(mcpPath)) { | ||
| try { | ||
| existingConfig = await readJson<Record<string, unknown>>(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<string, unknown>) | ||
| : {} | ||
| 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}`) | ||
| } | ||
| } |
| 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<string, string> | ||
| } | ||
| export type KiroBundle = { | ||
| agents: KiroAgent[] | ||
| generatedSkills: KiroSkill[] | ||
| skillDirs: KiroSkillDir[] | ||
| steeringFiles: KiroSteeringFile[] | ||
| mcpServers: Record<string, KiroMcpServer> | ||
| } |
| 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<string, unknown>).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/") | ||
| }) | ||
| }) |
| 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<boolean> { | ||
| 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") | ||
| }) | ||
| }) |
+8
-0
@@ -8,2 +8,10 @@ # Changelog | ||
| ## [0.9.0] - 2026-02-17 | ||
| ### Added | ||
| - **Kiro CLI target** — `--to kiro` converts plugins to `.kiro/` format with custom agent JSON configs, prompt files, skills, steering files, and `mcp.json`. Only stdio MCP servers are supported ([#196](https://github.com/EveryInc/compound-engineering-plugin/pull/196)) — thanks [@krthr](https://github.com/krthr)! | ||
| --- | ||
| ## [0.8.0] - 2026-02-17 | ||
@@ -10,0 +18,0 @@ |
+1
-1
| { | ||
| "name": "@every-env/compound-plugin", | ||
| "version": "0.8.0", | ||
| "version": "0.9.0", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "private": false, |
+6
-2
@@ -21,5 +21,5 @@ # Compound Marketplace | ||
| ## 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. | ||
@@ -44,2 +44,5 @@ ```bash | ||
| 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 | ||
| ``` | ||
@@ -59,2 +62,3 @@ | ||
| 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). | ||
@@ -61,0 +65,0 @@ All provider targets are experimental and may change as the formats evolve. |
@@ -26,3 +26,3 @@ import { defineCommand } from "citty" | ||
| default: "opencode", | ||
| description: "Target format (opencode | codex | droid | cursor | pi | gemini)", | ||
| description: "Target format (opencode | codex | droid | cursor | pi | gemini | kiro)", | ||
| }, | ||
@@ -150,3 +150,4 @@ output: { | ||
| if (targetName === "gemini") return path.join(outputRoot, ".gemini") | ||
| if (targetName === "kiro") return path.join(outputRoot, ".kiro") | ||
| return outputRoot | ||
| } |
@@ -28,3 +28,3 @@ import { defineCommand } from "citty" | ||
| default: "opencode", | ||
| description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini)", | ||
| description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro)", | ||
| }, | ||
@@ -195,2 +195,6 @@ output: { | ||
| } | ||
| if (targetName === "kiro") { | ||
| const base = hasExplicitOutput ? outputRoot : process.cwd() | ||
| return path.join(base, ".kiro") | ||
| } | ||
| return outputRoot | ||
@@ -197,0 +201,0 @@ } |
@@ -8,2 +8,3 @@ import type { ClaudePlugin } from "../types/claude" | ||
| import type { GeminiBundle } from "../types/gemini" | ||
| import type { KiroBundle } from "../types/kiro" | ||
| import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" | ||
@@ -15,2 +16,3 @@ import { convertClaudeToCodex } from "../converters/claude-to-codex" | ||
| import { convertClaudeToGemini } from "../converters/claude-to-gemini" | ||
| import { convertClaudeToKiro } from "../converters/claude-to-kiro" | ||
| import { writeOpenCodeBundle } from "./opencode" | ||
@@ -22,2 +24,3 @@ import { writeCodexBundle } from "./codex" | ||
| import { writeGeminiBundle } from "./gemini" | ||
| import { writeKiroBundle } from "./kiro" | ||
@@ -68,2 +71,8 @@ export type TargetHandler<TBundle = unknown> = { | ||
| }, | ||
| kiro: { | ||
| name: "kiro", | ||
| implemented: true, | ||
| convert: convertClaudeToKiro as TargetHandler<KiroBundle>["convert"], | ||
| write: writeKiroBundle as TargetHandler<KiroBundle>["write"], | ||
| }, | ||
| } |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
1960106
2.31%299
2.05%13485
7.54%123
3.36%39
11.43%