diff --git a/README.md b/README.md index 974b07017..74546035b 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, Copilot, Kiro, Windsurf, OpenClaw & Qwen (experimental) Install +## OpenCode, Codex, Droid, Pi, Gemini, Copilot, Kiro, KiloCode, Windsurf, OpenClaw & Qwen (experimental) Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, Kiro CLI, Windsurf, OpenClaw, and Qwen Code. +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, Kiro CLI, KiloCode, Windsurf, OpenClaw, and Qwen Code. ```bash # convert the compound-engineering plugin into OpenCode format @@ -44,6 +44,9 @@ 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 +# convert to KiloCode format +bunx @every-env/compound-plugin install compound-engineering --to kilocode + # convert to OpenClaw format bunx @every-env/compound-plugin install compound-engineering --to openclaw @@ -103,6 +106,7 @@ bun run src/index.ts install ./plugins/compound-engineering --to opencode | `gemini` | `.gemini/` | Skills from agents; commands as `.toml`; namespaced commands become directories (`workflows:plan` → `commands/workflows/plan.toml`) | | `copilot` | `.github/` | Agents as `.agent.md` with Copilot frontmatter; MCP env vars prefixed with `COPILOT_MCP_` | | `kiro` | `.kiro/` | Agents as JSON configs + prompt `.md` files; only stdio MCP servers supported | +| `kilocode` | `.kilocode/` | Agents as `.md` with YAML frontmatter; MCP servers in `kilo.json`; both stdio and HTTP supported | | `openclaw` | `~/.openclaw/extensions//` | Entry-point TypeScript skill file; `openclaw-extension.json` for MCP servers | | `windsurf` | `~/.codeium/windsurf/` (global) or `.windsurf/` (workspace) | Agents become skills; commands become flat workflows; `mcp_config.json` merged | | `qwen` | `~/.qwen/extensions//` | Agents as `.yaml`; env vars with placeholders extracted as settings; colon separator for nested commands | @@ -143,6 +147,9 @@ bunx @every-env/compound-plugin sync --target windsurf # Sync to Kiro bunx @every-env/compound-plugin sync --target kiro +# Sync to KiloCode +bunx @every-env/compound-plugin sync --target kilocode + # Sync to Qwen bunx @every-env/compound-plugin sync --target qwen @@ -169,6 +176,7 @@ Supported sync targets: - `gemini` - `windsurf` - `kiro` +- `kilocode` - `qwen` - `openclaw` diff --git a/docs/plans/2026-03-19-001-feat-add-kilocode-target-provider-plan.md b/docs/plans/2026-03-19-001-feat-add-kilocode-target-provider-plan.md new file mode 100644 index 000000000..af5c837dc --- /dev/null +++ b/docs/plans/2026-03-19-001-feat-add-kilocode-target-provider-plan.md @@ -0,0 +1,425 @@ +--- +title: Add KiloCode Target Provider +created: 2026-03-19 +status: complete +scope: converter-cli +--- + +# Add KiloCode Target Provider + +## Summary + +Add a `kilocode` target provider to the compound-engineering-plugin converter CLI, enabling automatic conversion and installation of the compound-engineering plugin for KiloCode CLI users. + +## Manual Installation Instructions (Interim Solution) + +Until the converter is implemented, users can manually install the compound-engineering skills and agents into KiloCode. + +### Skills Installation + +**Option 1: Copy skills to project-level (recommended for team consistency)** + +```bash +# Clone the repository +git clone https://github.com/EveryInc/compound-engineering-plugin.git +cd compound-engineering-plugin + +# Copy skills to your project +cp -r plugins/compound-engineering/skills/* /path/to/your/project/.kilocode/skills/ +``` + +**Option 2: Copy skills to global location** + +```bash +# Copy skills globally +cp -r plugins/compound-engineering/skills/* ~/.kilocode/skills/ +``` + +### MCP Server Configuration + +KiloCode uses `kilo.json` for MCP configuration. Create or edit `~/.config/kilo/kilo.json` (global) or `./kilo.json` (project): + +```json +{ + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "enabled": true + } + } +} +``` + +### Custom Subagents (Optional) + +To use compound-engineering agents as KiloCode subagents: + +1. Copy agent files from `plugins/compound-engineering/agents/` to `~/.config/kilo/agents/` (global) or `.kilo/agents/` (project) +2. Convert the YAML frontmatter format: + - Claude Code: `description:`, `model:`, `tools:` + - KiloCode: `description:`, `mode: subagent`, `model:`, `permission:` + +Example KiloCode agent file (`.kilo/agents/rails-reviewer.md`): + +```yaml +--- +description: Expert Ruby on Rails code reviewer +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +permission: + edit: deny + bash: deny +--- + +[Agent instructions from the original Claude agent body] +``` + +### AGENTS.md Support + +KiloCode supports the AGENTS.md open standard. Copy the repository's AGENTS.md to your project root: + +```bash +cp compound-engineering-plugin/AGENTS.md /path/to/your/project/AGENTS.md +``` + +### Available Skills (46 total) + +| Category | Skills | +|----------|--------| +| **Core Workflows** | `ce-brainstorm`, `ce-plan`, `ce-work`, `ce-review`, `ce-compound`, `ce-ideate` | +| **Planning** | `deepen-plan`, `ce-plan-beta`, `deepen-plan-beta` | +| **Code Review** | `triage`, `proof`, `document-review` | +| **Testing** | `test-browser`, `test-xcode`, `reproduce-bug`, `report-bug` | +| **Git/Worktrees** | `git-worktree`, `resolve_parallel`, `resolve-pr-parallel`, `resolve-todo-parallel` | +| **Frontend/Design** | `frontend-design`, `every-style-editor`, `feature-video`, `gemini-imagegen` | +| **Ruby/Rails** | `dhh-rails-style`, `andrew-kane-gem-writer`, `dspy-ruby` | +| **Documentation** | `compound-docs`, `deploy-docs`, `changelog`, `agent-native-audit`, `agent-native-architecture` | +| **Utilities** | `setup`, `heal-skill`, `create-agent-skill`, `create-agent-skills`, `claude-permissions-optimizer`, `rclone`, `agent-browser` | +| **Quick Start** | `lfg`, `slfg`, `generate_command`, `file-todos`, `orchestrating-swarms`, `ce-compound-refresh` | + +--- + +## Implementation Plan + +Follow the 6-phase pattern from `docs/solutions/adding-converter-target-providers.md`. + +### Phase 1: Type Definitions + +**File:** `src/types/kilocode.ts` + +```typescript +export type KiloCodePermission = "allow" | "ask" | "deny" + +export type KiloCodeSkillDir = { + name: string + sourceDir: string +} + +export type KiloCodeAgent = { + name: string + content: string // Full file content with YAML frontmatter + category?: string // Maps to agents//.md +} + +export type KiloCodeMcpServer = { + type: "local" | "remote" + command?: string[] + url?: string + environment?: Record + headers?: Record + enabled?: boolean +} + +export type KiloCodeConfig = { + mcp?: Record +} + +export type KiloCodeBundle = { + agents: KiloCodeAgent[] + skillDirs: KiloCodeSkillDir[] + mcpConfig: KiloCodeConfig +} +``` + +**Key mappings:** +- Claude `mcpServers` → KiloCode `mcp` +- Claude `http` type → KiloCode `remote` type +- Claude `stdio` type → KiloCode `local` type + +### Phase 2: Converter + +**File:** `src/converters/claude-to-kilocode.ts` + +**Key transformations:** + +1. **MCP Server conversion:** + ```typescript + function convertMcpServer(claudeServer: ClaudeMcpServer): KiloCodeMcpServer { + if (claudeServer.type === "http") { + return { + type: "remote", + url: claudeServer.url, + enabled: true, + } + } + // stdio type + return { + type: "local", + command: claudeServer.command, + environment: claudeServer.env, + enabled: true, + } + } + ``` + +2. **Agent content transformation:** + - Preserve YAML frontmatter from Claude agents + - Add `mode: subagent` field to KiloCode agents + - Add default `permission: { edit: deny, bash: deny }` for safety + - Transform content paths: `.claude/` → `.kilocode/`, `~/.claude/` → `~/.kilocode/` + +3. **Skills:** Pass-through copy (SKILL.md format is compatible) + +### Phase 3: Writer + +**File:** `src/targets/kilocode.ts` + +**Output structure:** + +``` +.kilocode/ +├── skills/ # Copied from plugin skills +│ ├── ce-plan/ +│ │ └── SKILL.md +│ └── ... +├── agents/ # Converted from plugin agents +│ ├── rails-reviewer.md +│ └── ... +└── kilo.json # MCP config (backup existing) +``` + +**Key implementation:** + +```typescript +export async function writeKiloCodeBundle( + outputRoot: string, + bundle: KiloCodeBundle, + scope?: TargetScope, +): Promise { + const paths = resolveKiloCodePaths(outputRoot, scope) + await ensureDir(paths.root) + + // Copy skills + if (bundle.skillDirs.length > 0) { + await ensureDir(paths.skillsDir) + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name)) + } + } + + // Write agents + if (bundle.agents.length > 0) { + await ensureDir(paths.agentsDir) + for (const agent of bundle.agents) { + await writeText( + path.join(paths.agentsDir, `${agent.name}.md`), + agent.content + "\n", + ) + } + } + + // Write/merge MCP config + if (bundle.mcpConfig.mcp && Object.keys(bundle.mcpConfig.mcp).length > 0) { + const existing = await readJson(paths.configPath) || {} + const merged = { + ...existing, + mcp: { + ...existing.mcp, + ...bundle.mcpConfig.mcp, + }, + } + await backupFile(paths.configPath) + await writeJson(paths.configPath, merged, { mode: 0o600 }) + } +} + +function resolveKiloCodePaths(outputRoot: string, scope?: TargetScope) { + const base = path.basename(outputRoot) + + // Global scope: ~/.config/kilo/ + if (scope === "global") { + const globalRoot = expandHome("~/.config/kilo") + return { + root: globalRoot, + skillsDir: expandHome("~/.kilocode/skills"), + agentsDir: path.join(globalRoot, "agents"), + configPath: path.join(globalRoot, "kilo.json"), + } + } + + // Workspace scope: .kilocode/ + if (base === ".kilocode") { + return { + root: outputRoot, + skillsDir: path.join(outputRoot, "skills"), + agentsDir: path.join(outputRoot, "agents"), + configPath: path.join(outputRoot, "kilo.json"), + } + } + + return { + root: outputRoot, + skillsDir: path.join(outputRoot, ".kilocode", "skills"), + agentsDir: path.join(outputRoot, ".kilocode", "agents"), + configPath: path.join(outputRoot, ".kilocode", "kilo.json"), + } +} +``` + +### Phase 4: CLI Wiring + +**File:** `src/targets/index.ts` + +```typescript +import type { KiloCodeBundle } from "../types/kilocode" +import { convertClaudeToKiloCode } from "../converters/claude-to-kilocode" +import { writeKiloCodeBundle } from "./kilocode" + +export const targets: Record = { + // ... existing targets ... + kilocode: { + name: "kilocode", + implemented: true, + defaultScope: "workspace", + supportedScopes: ["global", "workspace"], + convert: convertClaudeToKiloCode as TargetHandler["convert"], + write: writeKiloCodeBundle as TargetHandler["write"], + }, +} +``` + +### Phase 5: Sync Support (Optional) + +**File:** `src/sync/kilocode.ts` + +Sync personal Claude skills and MCP servers to KiloCode: + +```typescript +export async function syncToKiloCode(outputRoot: string): Promise { + const personalSkillsDir = expandHome("~/.claude/skills") + const personalSettings = loadSettings(expandHome("~/.claude/settings.json")) + + // Sync skills + const skillsDest = expandHome("~/.kilocode/skills") + await ensureDir(skillsDest) + + if (existsSync(personalSkillsDir)) { + const skills = readdirSync(personalSkillsDir) + for (const skill of skills) { + if (!isValidSkillName(skill)) continue + const source = path.join(personalSkillsDir, skill) + const dest = path.join(skillsDest, skill) + await forceSymlink(source, dest) + } + } + + // Sync MCP servers + if (personalSettings.mcpServers) { + const mcpPath = expandHome("~/.config/kilo/kilo.json") + const existing = readJson(mcpPath) || {} + const converted = convertMcpServers(personalSettings.mcpServers) + const merged = { + ...existing, + mcp: { + ...existing.mcp, + ...converted, + }, + } + await writeJson(mcpPath, merged, { mode: 0o600 }) + } +} +``` + +### Phase 6: Tests + +**Files:** +- `tests/kilocode-converter.test.ts` +- `tests/kilocode-writer.test.ts` + +**Test coverage:** +1. Converter tests: + - Agent name normalization + - MCP type conversion (http → remote, stdio → local) + - Content path transformation + - Frontmatter field mapping (mode, permission) + +2. Writer tests: + - Skills copied correctly + - Agents written with correct extension + - MCP config merge with backup + - Double-nesting prevention + - Global vs. workspace scope + +--- + +## Format Specification + +**File:** `docs/specs/kilocode.md` + +Document: +- Last verified date with link to official docs +- Config file locations (global vs. workspace) +- SKILL.md format (YAML frontmatter + Markdown) +- Agent format (YAML frontmatter with mode/permission) +- MCP configuration structure +- Character limits + +--- + +## Dependencies + +None blocking. KiloCode CLI uses similar conventions to Claude Code: +- SKILL.md format is compatible (YAML frontmatter + Markdown) +- MCP uses `mcp` key instead of `mcpServers` +- Agents use markdown files with YAML frontmatter + +--- + +## Estimated Effort + +| Phase | Effort | +|-------|--------| +| Phase 1: Types | 1 hour | +| Phase 2: Converter | 2-3 hours | +| Phase 3: Writer | 1-2 hours | +| Phase 4: CLI Wiring | 30 min | +| Phase 5: Sync | 1 hour (optional) | +| Phase 6: Tests | 2 hours | +| Documentation | 1 hour | +| **Total** | **8-10 hours** | + +--- + +## Acceptance Criteria + +- [x] `bun run src/index.ts convert --to kilocode ./plugins/compound-engineering` produces valid `.kilocode/` output +- [x] `bun run src/index.ts install --to kilocode ./plugins/compound-engineering` installs to correct location +- [x] `bun run src/index.ts install --to kilocode --scope global ./plugins/compound-engineering` installs to `~/.config/kilo/` +- [x] All 46 skills copied correctly +- [x] MCP servers converted with correct type mapping +- [x] Existing `kilo.json` backed up before modification +- [x] `bun test` passes with new tests +- [x] `docs/specs/kilocode.md` created with format specification +- [x] README updated with kilocode in supported targets list + +--- + +## References + +- KiloCode Skills Documentation: https://kilocode.ai/docs/features/skills +- KiloCode MCP Documentation: https://kilocode.ai/docs/features/mcp +- KiloCode Custom Modes: https://kilocode.ai/docs/features/custom-modes +- `docs/solutions/adding-converter-target-providers.md` +- `src/targets/droid.ts` (reference implementation) +- `src/converters/claude-to-copilot.ts` (MCP conversion pattern) diff --git a/docs/specs/kilocode.md b/docs/specs/kilocode.md new file mode 100644 index 000000000..32ec19e00 --- /dev/null +++ b/docs/specs/kilocode.md @@ -0,0 +1,157 @@ +# KiloCode CLI Spec (Agents, Skills, MCP) + +Last verified: 2026-03-19 + +## Primary sources + +``` +https://docs.kilocode.ai +https://docs.kilocode.ai/agents +https://docs.kilocode.ai/skills +https://docs.kilocode.ai/mcp +``` + +## Config locations + +- Project-level config: `.kilocode/` directory at project root. +- User-level config: `~/.kilocode/` for global settings. +- Main config file: `kilo.json` (project) or `~/.kilocode/kilo.json` (user). + +## Directory structure + +``` +.kilocode/ +├── kilo.json # Main config (MCP servers, settings) +├── agents/ +│ └── .md # Agent definitions with YAML frontmatter +└── skills/ + └── / + └── SKILL.md # Skill definition +``` + +## Agents (Markdown with frontmatter) + +- Agents are `.md` files in `.kilocode/agents/` (or `~/.kilocode/agents/`). +- YAML frontmatter defines agent metadata. +- KiloCode supports `mode: "primary" | "subagent" | "all"` to control agent activation. +- The converter outputs agents with mode set via `options.agentMode`. + +### Agent frontmatter fields + +| Field | Type | Required | Notes | +|---|---|---|---| +| `description` | string | Yes | Human-readable description | +| `mode` | string | Yes | `"primary"`, `"subagent"`, or `"all"` | +| `model` | string | No | Model selection (e.g., `anthropic/claude-sonnet-4-20250514`) | +| `permission` | object | No | Edit/bash permissions (`allow` or `deny`) | + +### Example agent + +```yaml +--- +description: Reviews code for security vulnerabilities +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +permission: + edit: deny + bash: deny +--- + +# Security Reviewer + +You are a security-focused code reviewer... +``` + +## 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 `.kilocode/skills/` (project) or `~/.kilocode/skills/` (user). +- `SKILL.md` uses YAML frontmatter with `name` and `description` fields. + +### Constraints + +- Skill name: pattern `^[a-z][a-z0-9-]*$`. +- Skill description: used for skill activation matching. + +### Example + +```yaml +--- +name: workflows-plan +description: Plan work by analyzing requirements and creating actionable steps +--- + +# Planning Workflow + +Detailed instructions... +``` + +## MCP server configuration + +- MCP servers are configured in `kilo.json` under the `mcp` key. +- Both stdio (`local`) and HTTP/SSE (`remote`) transports are supported. +- `mcpServers` key from Claude Code is renamed to `mcp`. +- Command is an array instead of string + args. + +### MCP server fields + +| Field | Type | Notes | +|---|---|---| +| `type` | string | `"local"` (stdio) or `"remote"` (HTTP/SSE) | +| `enabled` | boolean | Whether server is active | +| `command` | string[] | For local: command array (e.g., `["npx", "-y", "@anthropic/mcp-server"]`) | +| `environment` | object | For local: environment variables | +| `url` | string | For remote: server URL | +| `headers` | object | For remote: HTTP headers | + +### Example + +```json +{ + "mcp": { + "playwright": { + "type": "local", + "enabled": true, + "command": ["npx", "-y", "@anthropic/mcp-playwright"], + "environment": { + "DEBUG": "1" + } + }, + "remote-server": { + "type": "remote", + "enabled": true, + "url": "https://mcp.example.com/api", + "headers": { + "Authorization": "Bearer token" + } + } + } +} +``` + +## Hooks + +- KiloCode does not currently support hooks in the same way as Claude Code. +- The converter emits a warning when hooks are present and skips them. + +## Conversion mappings + +| Claude Code Feature | KiloCode Status | Notes | +|---|---|---| +| Agents | Converted | Frontmatter with `mode`, `description`, `model`, `permission` | +| Skills | Pass-through | SKILL.md format is compatible | +| Commands | Lost | No direct equivalent; consider creating skills | +| MCP servers (stdio) | Converted | `command` + `args` → `command` array, `env` → `environment` | +| MCP servers (http) | Converted | `url` + `headers` preserved as `remote` type | +| Hooks | Skipped | Not supported; warning emitted | +| `allowed-tools` | Lost | No tool permission scoping per agent | + +## Overwrite behavior during conversion + +| Content Type | Strategy | Rationale | +|---|---|---| +| Generated agents | Overwrite | Generated, not user-authored | +| Copied skills (pass-through) | Overwrite | Plugin is source of truth | +| `kilo.json` | Merge with backup | User may have added their own servers | +| User-created agents/skills | Preserved | Don't delete orphans | diff --git a/src/commands/install.ts b/src/commands/install.ts index a1f2f1c50..dd0f7b9b2 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -27,7 +27,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | kilocode | all)", }, output: { type: "string", diff --git a/src/converters/claude-to-kilocode.ts b/src/converters/claude-to-kilocode.ts new file mode 100644 index 000000000..d56a9c6a9 --- /dev/null +++ b/src/converters/claude-to-kilocode.ts @@ -0,0 +1,250 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { KiloCodeAgent, KiloCodeBundle, KiloCodeMcpServer } from "../types/kilocode" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToKiloCodeOptions = ClaudeToOpenCodeOptions + +/** + * Convert a Claude Code plugin to KiloCode format. + * + * Key transformations: + * - MCP servers: http → remote, stdio → local + * - MCP config key: "mcpServers" → "mcp" + * - MCP command: string → string[] + * - Agents: add mode: subagent and permission fields + * - Content paths: .claude/ → .kilocode/ + * - Skills: pass-through copy (SKILL.md format is compatible) + */ +export function convertClaudeToKiloCode( + plugin: ClaudePlugin, + options: ClaudeToKiloCodeOptions, +): KiloCodeBundle { + const usedNames = new Set() + + const agents = plugin.agents.map((agent) => convertAgent(agent, options, usedNames)) + + const skillDirs = plugin.skills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })) + + const mcpConfig = convertMcpServers(plugin.mcpServers) + + if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { + console.warn("Warning: KiloCode does not support hooks. Hooks were skipped during conversion.") + } + + return { agents, skillDirs, mcpConfig } +} + +/** + * Convert a Claude agent to KiloCode subagent format. + * + * KiloCode agent frontmatter: + * - description: Required + * - mode: "subagent" | "primary" | "all" + * - model: Optional (e.g., "anthropic/claude-sonnet-4-20250514") + * - permission: Optional (edit, bash permissions) + */ +function convertAgent(agent: ClaudeAgent, options: ClaudeToKiloCodeOptions, usedNames: Set): KiloCodeAgent { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = agent.description ?? `Converted from Claude agent ${agent.name}` + + const frontmatter: Record = { + description, + mode: options.agentMode, + } + + if (agent.model && agent.model !== "inherit") { + // KiloCode uses format like "anthropic/claude-sonnet-4-20250514" + frontmatter.model = agent.model + } + + // Default to deny edit/bash for safety (subagents typically shouldn't modify files) + frontmatter.permission = { + edit: "deny", + bash: "deny", + } + + let body = transformContentForKiloCode(agent.body.trim()) + + if (agent.capabilities && agent.capabilities.length > 0) { + const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n") + body = `## Capabilities\n${capabilities}\n\n${body}`.trim() + } + + if (body.length === 0) { + body = `Instructions converted from the ${agent.name} agent.` + } + + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +/** + * Convert Claude MCP servers to KiloCode format. + * + * Key differences: + * - KiloCode uses "mcp" key instead of "mcpServers" + * - KiloCode uses "local" | "remote" types instead of "stdio" | "http" + * - KiloCode command is an array instead of a string + * - KiloCode uses "environment" instead of "env" + */ +function convertMcpServers( + servers?: Record, +): KiloCodeBundle["mcpConfig"] { + if (!servers || Object.keys(servers).length === 0) { + return {} + } + + const mcp: Record = {} + + for (const [name, server] of Object.entries(servers)) { + const entry: KiloCodeMcpServer = { + enabled: true, + } + + // Determine type based on whether command or url is present + if (server.url) { + // HTTP/SSE server → remote + entry.type = "remote" + entry.url = server.url + if (server.headers && Object.keys(server.headers).length > 0) { + entry.headers = server.headers + } + } else if (server.command) { + // stdio server → local + entry.type = "local" + // KiloCode expects command as an array + entry.command = buildCommandArray(server.command, server.args) + if (server.env && Object.keys(server.env).length > 0) { + entry.environment = server.env + } + } else { + // Skip servers without command or url + console.warn(`Warning: MCP server "${name}" has neither command nor url. Skipping.`) + continue + } + + mcp[name] = entry + } + + if (Object.keys(mcp).length === 0) { + return {} + } + + return { mcp } +} + +/** + * Build a command array from command string and args. + * + * KiloCode expects command as an array, e.g.: + * ["npx", "-y", "@anthropic/mcp-server"] + */ +function buildCommandArray(command?: string, args?: string[]): string[] { + if (!command) return [] + + // If command already looks like a full command line, split it + // Otherwise use command as first element with args following + if (command.includes(" ") && !args?.length) { + return command.split(/\s+/).filter(Boolean) + } + + const result: string[] = [command] + if (args && args.length > 0) { + result.push(...args) + } + return result +} + +/** + * Transform Claude Code content to KiloCode-compatible content. + * + * 1. Rewrite paths: .claude/ → .kilocode/, ~/.claude/ → ~/.kilocode/ + * 2. Transform slash command references (flatten namespaced commands) + * 3. Transform Task agent calls to skill references + * 4. Transform @agent-name references + */ +export function transformContentForKiloCode(body: string): string { + let result = body + + // 1. Rewrite paths + result = result + .replace(/~\/\.claude\//g, "~/.kilocode/") + .replace(/\.claude\//g, ".kilocode/") + + // 2. 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) => { + const skillName = normalizeName(agentName) + return `${prefix}Use the ${skillName} skill to: ${args.trim()}` + }) + + // 3. Transform slash command references (flatten namespaced commands) + const slashCommandPattern = /(? { + if (commandName.includes("/")) return match // Skip file paths + if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match + const flattened = flattenCommandName(commandName) + return `/${flattened}` + }) + + // 4. Transform @agent-name references + const agentRefPattern = + /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi + result = result.replace(agentRefPattern, (_match, agentName: string) => { + return `the ${normalizeName(agentName)} subagent` + }) + + return result +} + +/** + * Flatten a namespaced command name. + * "ce:plan" → "ce-plan" + * "workflows:plan" → "workflows-plan" + */ +function flattenCommandName(name: string): string { + // Replace colons with hyphens for KiloCode compatibility + return normalizeName(name.replace(/:/g, "-")) +} + +/** + * Normalize a name to lowercase with hyphens. + */ +export 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, "-") + .replace(/^-+|-+$/g, "") + + if (normalized.length === 0 || !/^[a-z]/.test(normalized)) { + return "item" + } + + return normalized +} + +/** + * Generate a unique name, appending -2, -3, etc. for collisions. + */ +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 b1214d0b2..b41d151f4 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -9,6 +9,7 @@ import type { KiroBundle } from "../types/kiro" import type { WindsurfBundle } from "../types/windsurf" import type { OpenClawBundle } from "../types/openclaw" import type { QwenBundle } from "../types/qwen" +import type { KiloCodeBundle } from "../types/kilocode" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" import { convertClaudeToDroid } from "../converters/claude-to-droid" @@ -19,6 +20,7 @@ import { convertClaudeToKiro } from "../converters/claude-to-kiro" import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf" import { convertClaudeToOpenClaw } from "../converters/claude-to-openclaw" import { convertClaudeToQwen } from "../converters/claude-to-qwen" +import { convertClaudeToKiloCode } from "../converters/claude-to-kilocode" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" @@ -29,6 +31,7 @@ import { writeKiroBundle } from "./kiro" import { writeWindsurfBundle } from "./windsurf" import { writeOpenClawBundle } from "./openclaw" import { writeQwenBundle } from "./qwen" +import { writeKiloCodeBundle } from "./kilocode" export type TargetScope = "global" | "workspace" @@ -130,4 +133,12 @@ export const targets: Record = { convert: convertClaudeToQwen as TargetHandler["convert"], write: writeQwenBundle as TargetHandler["write"], }, + kilocode: { + name: "kilocode", + implemented: true, + defaultScope: "global", + supportedScopes: ["global", "workspace"], + convert: convertClaudeToKiloCode as TargetHandler["convert"], + write: writeKiloCodeBundle as TargetHandler["write"], + }, } diff --git a/src/targets/kilocode.ts b/src/targets/kilocode.ts new file mode 100644 index 000000000..ae167eea4 --- /dev/null +++ b/src/targets/kilocode.ts @@ -0,0 +1,102 @@ +import path from "path" +import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJsonSecure, writeText } from "../utils/files" +import type { KiloCodeBundle } from "../types/kilocode" +import type { TargetScope } from "./index" + +export async function writeKiloCodeBundle( + outputRoot: string, + bundle: KiloCodeBundle, + scope?: TargetScope, +): Promise { + const paths = resolveKiloCodePaths(outputRoot, scope) + await ensureDir(paths.configDir) + + if (bundle.agents.length > 0) { + const agentsDir = paths.agentsDir + await ensureDir(agentsDir) + for (const agent of bundle.agents) { + validatePathSafe(agent.name, "agent") + const destPath = path.join(agentsDir, `${agent.name}.md`) + await writeText(destPath, agent.content + "\n") + } + } + + if (bundle.skillDirs.length > 0) { + const skillsDir = paths.skillsDir + await ensureDir(skillsDir) + for (const skill of bundle.skillDirs) { + validatePathSafe(skill.name, "skill directory") + const destDir = path.join(skillsDir, skill.name) + + const resolvedDest = path.resolve(destDir) + if (!resolvedDest.startsWith(path.resolve(skillsDir))) { + console.warn(`Warning: Skill name "${skill.name}" escapes skills/. Skipping.`) + continue + } + + await copyDir(skill.sourceDir, destDir) + } + } + + if (bundle.mcpConfig && bundle.mcpConfig.mcp && Object.keys(bundle.mcpConfig.mcp).length > 0) { + const mcpPath = paths.mcpPath + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing kilo.json to ${backupPath}`) + } + + let existingConfig: Record = {} + if (await pathExists(mcpPath)) { + try { + const parsed = await readJson(mcpPath) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + existingConfig = parsed as Record + } + } catch { + console.warn("Warning: existing kilo.json could not be parsed and will be replaced.") + } + } + + const existingMcp = + existingConfig.mcp && + typeof existingConfig.mcp === "object" && + !Array.isArray(existingConfig.mcp) + ? (existingConfig.mcp as Record) + : {} + const merged = { ...existingConfig, mcp: { ...existingMcp, ...bundle.mcpConfig.mcp } } + await writeJsonSecure(mcpPath, merged) + } +} + +export function resolveKiloCodePaths( + outputRoot: string, + scope?: TargetScope, +): { + configDir: string + agentsDir: string + skillsDir: string + mcpPath: string +} { + if (scope === "global") { + const home = process.env.HOME || process.env.USERPROFILE || "" + return { + configDir: path.join(home, ".config", "kilo"), + agentsDir: path.join(home, ".config", "kilo", "agents"), + skillsDir: path.join(home, ".kilocode", "skills"), + mcpPath: path.join(home, ".config", "kilo", "kilo.json"), + } + } + + return { + configDir: path.join(outputRoot, ".kilo"), + agentsDir: path.join(outputRoot, ".kilo", "agents"), + skillsDir: path.join(outputRoot, ".kilocode", "skills"), + mcpPath: path.join(outputRoot, "kilo.json"), + } +} + +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/kilocode.ts b/src/types/kilocode.ts new file mode 100644 index 000000000..aacd605bc --- /dev/null +++ b/src/types/kilocode.ts @@ -0,0 +1,41 @@ +/** + * KiloCode CLI bundle types. + * + * KiloCode uses: + * - .kilocode/skills/ for project skills (SKILL.md format) + * - ~/.kilocode/skills/ for global skills + * - .kilo/agents/ or ~/.config/kilo/agents/ for custom subagents + * - kilo.json for MCP configuration with "mcp" key + * + * @see https://kilocode.ai/docs/features/skills + * @see https://kilocode.ai/docs/features/mcp + */ + +export type KiloCodeSkillDir = { + name: string + sourceDir: string +} + +export type KiloCodeAgent = { + name: string + content: string // Full file content with YAML frontmatter +} + +export type KiloCodeMcpServer = { + type: "local" | "remote" + command?: string[] + url?: string + environment?: Record + headers?: Record + enabled?: boolean +} + +export type KiloCodeConfig = { + mcp?: Record +} + +export type KiloCodeBundle = { + agents: KiloCodeAgent[] + skillDirs: KiloCodeSkillDir[] + mcpConfig: KiloCodeConfig +} diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index dfe85bfcf..221a749a0 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -48,6 +48,12 @@ function formatYamlLine(key: string, value: unknown): string { const items = value.map((item) => ` - ${formatYamlValue(item)}`) return [key + ":", ...items].join("\n") } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ` ${k}: ${formatYamlValue(v)}`) + return [key + ":", ...entries].join("\n") + } return `${key}: ${formatYamlValue(value)}` } diff --git a/src/utils/resolve-output.ts b/src/utils/resolve-output.ts index 724f142ab..378663569 100644 --- a/src/utils/resolve-output.ts +++ b/src/utils/resolve-output.ts @@ -46,5 +46,10 @@ export function resolveTargetOutputRoot(options: { const home = qwenHome ?? path.join(os.homedir(), ".qwen", "extensions") return path.join(home, pluginName ?? "plugin") } + if (targetName === "kilocode") { + if (hasExplicitOutput) return outputRoot + if (scope === "global") return path.join(os.homedir(), ".config", "kilo") + return path.join(process.cwd(), ".kilo") + } return outputRoot } diff --git a/tests/kilocode-converter.test.ts b/tests/kilocode-converter.test.ts new file mode 100644 index 000000000..8a9b0bee8 --- /dev/null +++ b/tests/kilocode-converter.test.ts @@ -0,0 +1,465 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToKiloCode, transformContentForKiloCode, normalizeName } from "../src/converters/claude-to-kilocode" +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: [], + skills: [ + { + name: "existing-skill", + description: "Existing skill", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + mcpServers: { + local: { command: "echo", args: ["hello"] }, + }, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, +} + +describe("convertClaudeToKiloCode", () => { + test("converts agents with correct frontmatter (description, mode: subagent, permission fields)", () => { + const bundle = convertClaudeToKiloCode(fixturePlugin, defaultOptions) + + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent).toBeDefined() + expect(agent!.content).toContain("description: Security-focused agent") + expect(agent!.content).toContain("mode: subagent") + expect(agent!.content).toContain("permission:") + expect(agent!.content).toContain("edit: deny") + expect(agent!.content).toContain("bash: deny") + }) + + test("agent capabilities included in content", () => { + const bundle = convertClaudeToKiloCode(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.content).toContain("## Capabilities") + expect(agent!.content).toContain("- Threat modeling") + expect(agent!.content).toContain("- OWASP") + }) + + test("agent with empty description gets default", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "my-agent", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/my-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(bundle.agents[0].content).toContain("description: Converted from Claude agent my-agent") + }) + + test("agent model field preserved when not inherit", () => { + const bundle = convertClaudeToKiloCode(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.content).toContain("model: claude-sonnet-4-20250514") + }) + + test("agent model field omitted when inherit", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "Inherit Agent", + description: "Uses inherit model", + model: "inherit", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/inherit.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "inherit-agent") + expect(agent!.content).not.toContain("model: inherit") + expect(agent!.content).not.toMatch(/^model:\s*$/m) + }) + + test("agent with empty body gets default text", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "Empty Agent", + description: "An empty agent", + body: "", + sourcePath: "/tmp/plugin/agents/empty.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(bundle.agents[0].content).toContain("Instructions converted from the Empty Agent agent.") + }) + + test("skills pass through as directory references", () => { + const bundle = convertClaudeToKiloCode(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 server conversion: stdio → local type with command array", () => { + const bundle = convertClaudeToKiloCode(fixturePlugin, defaultOptions) + expect(bundle.mcpConfig.mcp).toBeDefined() + expect(bundle.mcpConfig.mcp!.local).toEqual({ + type: "local", + command: ["echo", "hello"], + enabled: true, + }) + }) + + test("MCP server conversion: http → remote type with url", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer abc" } }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(bundle.mcpConfig.mcp!.remote).toEqual({ + type: "remote", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer abc" }, + enabled: true, + }) + }) + + test("MCP key is 'mcp' (not 'mcpServers')", () => { + const bundle = convertClaudeToKiloCode(fixturePlugin, defaultOptions) + expect(bundle.mcpConfig.mcp).toBeDefined() + expect(bundle.mcpConfig).not.toHaveProperty("mcpServers") + }) + + test("MCP command is an array (not a string)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + myserver: { command: "npx", args: ["-y", "@anthropic/mcp-server"] }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(Array.isArray(bundle.mcpConfig.mcp!.myserver.command)).toBe(true) + expect(bundle.mcpConfig.mcp!.myserver.command).toEqual(["npx", "-y", "@anthropic/mcp-server"]) + }) + + test("MCP environment mapping (env → environment)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + myserver: { + command: "serve", + env: { + API_KEY: "secret123", + PORT: "3000", + }, + }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(bundle.mcpConfig.mcp!.myserver.environment).toEqual({ + API_KEY: "secret123", + PORT: "3000", + }) + expect(bundle.mcpConfig.mcp!.myserver.env).toBeUndefined() + }) + + 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: [], + } + + convertClaudeToKiloCode(plugin, defaultOptions) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("KiloCode") && w.includes("hooks"))).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 = convertClaudeToKiloCode(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(0) + expect(bundle.mcpConfig).toEqual({}) + }) + + 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 = convertClaudeToKiloCode(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") + }) + + test("name deduplication within agents", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "reviewer", description: "First", body: "Body.", sourcePath: "/tmp/a.md" }, + { name: "Reviewer", description: "Second", body: "Body.", sourcePath: "/tmp/b.md" }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(bundle.agents[0].name).toBe("reviewer") + expect(bundle.agents[1].name).toBe("reviewer-2") + }) + + test("agent name deduplicates against pass-through skill names", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "existing-skill", description: "Agent with same name as skill", body: "Body.", sourcePath: "/tmp/a.md" }, + ], + commands: [], + skills: [ + { + name: "existing-skill", + description: "Pass-through skill", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(bundle.agents[0].name).toBe("existing-skill") + expect(bundle.skillDirs[0].name).toBe("existing-skill") + }) + + test("MCP server with no command and no URL is skipped with warning", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + broken: {} as { command: string }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + console.warn = originalWarn + + expect(bundle.mcpConfig.mcp).toBeUndefined() + expect(warnings.some((w) => w.includes("broken") && w.includes("neither command nor url"))).toBe(true) + }) + + test("mixed stdio and HTTP servers both included", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + local: { command: "echo", args: ["hello"] }, + remote: { url: "https://example.com/mcp" }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiloCode(plugin, defaultOptions) + expect(Object.keys(bundle.mcpConfig.mcp!)).toHaveLength(2) + expect(bundle.mcpConfig.mcp!.local.type).toBe("local") + expect(bundle.mcpConfig.mcp!.remote.type).toBe("remote") + }) +}) + +describe("transformContentForKiloCode", () => { + test("transforms .claude/ → .kilocode/", () => { + const result = transformContentForKiloCode("Read .claude/settings.json for config.") + expect(result).toContain(".kilocode/settings.json") + expect(result).not.toContain(".claude/") + }) + + test("transforms ~/.claude/ → ~/.kilocode/", () => { + const result = transformContentForKiloCode("Check ~/.claude/config for settings.") + expect(result).toContain("~/.kilocode/config") + expect(result).not.toContain("~/.claude/") + }) + + test("transforms Task agent calls to skill references", () => { + const input = `Run these: + +- Task repo-research-analyst(feature_description) +- Task learnings-researcher(feature_description) + +Task best-practices-researcher(topic)` + + const result = transformContentForKiloCode(input) + expect(result).toContain("Use the repo-research-analyst skill to: feature_description") + expect(result).toContain("Use the learnings-researcher skill to: feature_description") + expect(result).toContain("Use the best-practices-researcher skill to: topic") + expect(result).not.toContain("Task repo-research-analyst") + }) + + test("transforms @agent-name references", () => { + const result = transformContentForKiloCode("Ask @security-reviewer for a review.") + expect(result).toContain("the security-reviewer subagent") + expect(result).not.toContain("@security-reviewer") + }) + + test("transforms @agent-name references with various suffixes", () => { + const result = transformContentForKiloCode("Contact @learnings-researcher, @code-analyst, and @bug-specialist.") + expect(result).toContain("the learnings-researcher subagent") + expect(result).toContain("the code-analyst subagent") + expect(result).toContain("the bug-specialist subagent") + }) + + test("does not transform partial .claude paths in middle of word", () => { + const result = transformContentForKiloCode("Check some-package/.claude-config/settings") + expect(result).toContain("some-package/") + }) + + test("handles multiple occurrences of same transform", () => { + const result = transformContentForKiloCode( + "Use .claude/foo and .claude/bar for config.", + ) + expect(result).toContain(".kilocode/foo") + expect(result).toContain(".kilocode/bar") + expect(result).not.toContain(".claude/") + }) + + test("transforms slash command references (flatten namespaced commands)", () => { + const result = transformContentForKiloCode("Run /workflows:plan to start planning.") + expect(result).toContain("/workflows-plan") + expect(result).not.toContain("/workflows:plan") + }) + + test("does not transform file paths that look like slash commands", () => { + const result = transformContentForKiloCode("Check /dev/null or /tmp/file") + expect(result).toContain("/dev/null") + expect(result).toContain("/tmp/file") + }) +}) + +describe("normalizeName", () => { + test("lowercases and hyphenates spaces", () => { + expect(normalizeName("Security Reviewer")).toBe("security-reviewer") + }) + + test("replaces colons with hyphens", () => { + expect(normalizeName("workflows:plan")).toBe("workflows-plan") + }) + + test("collapses consecutive hyphens", () => { + expect(normalizeName("agent--with--double-hyphens")).toBe("agent-with-double-hyphens") + }) + + test("strips leading/trailing hyphens", () => { + expect(normalizeName("-leading-and-trailing-")).toBe("leading-and-trailing") + }) + + test("empty string returns item", () => { + expect(normalizeName("")).toBe("item") + }) + + test("non-letter start returns item", () => { + expect(normalizeName("123-agent")).toBe("item") + }) + + test("handles slashes and backslashes", () => { + expect(normalizeName("path/to/agent")).toBe("path-to-agent") + expect(normalizeName("path\\to\\agent")).toBe("path-to-agent") + }) +}) + +describe("agentMode option", () => { + test("agentMode: primary produces mode: primary in frontmatter", () => { + const options = { + agentMode: "primary" as const, + inferTemperature: false, + permissions: "none" as const, + } + + const bundle = convertClaudeToKiloCode(fixturePlugin, options) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.content).toContain("mode: primary") + expect(agent!.content).not.toContain("mode: subagent") + }) + + test("agentMode: subagent produces mode: subagent in frontmatter", () => { + const options = { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, + } + + const bundle = convertClaudeToKiloCode(fixturePlugin, options) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.content).toContain("mode: subagent") + expect(agent!.content).not.toContain("mode: primary") + }) +}) diff --git a/tests/kilocode-writer.test.ts b/tests/kilocode-writer.test.ts new file mode 100644 index 000000000..27fe2ee18 --- /dev/null +++ b/tests/kilocode-writer.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeKiloCodeBundle, resolveKiloCodePaths } from "../src/targets/kilocode" +import type { KiloCodeBundle } from "../src/types/kilocode" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +const emptyBundle: KiloCodeBundle = { + agents: [], + skillDirs: [], + mcpConfig: {}, +} + +describe("writeKiloCodeBundle", () => { + test("creates correct directory structure with all components", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-test-")) + const bundle: KiloCodeBundle = { + agents: [ + { + name: "security-reviewer", + content: "---\nname: security-reviewer\ndescription: Security-focused agent\n---\n\n# security-reviewer\n\nReview code for vulnerabilities.\n", + }, + ], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + mcpConfig: { + mcp: { + local: { type: "local", command: ["echo", "hello"] }, + }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".kilo"))).toBe(true) + expect(await exists(path.join(tempRoot, ".kilo", "agents"))).toBe(true) + expect(await exists(path.join(tempRoot, ".kilocode", "skills"))).toBe(true) + + const agentPath = path.join(tempRoot, ".kilo", "agents", "security-reviewer.md") + expect(await exists(agentPath)).toBe(true) + const agentContent = await fs.readFile(agentPath, "utf8") + expect(agentContent).toContain("name: security-reviewer") + expect(agentContent).toContain("description: Security-focused agent") + expect(agentContent).toContain("Review code for vulnerabilities.") + + expect(await exists(path.join(tempRoot, ".kilocode", "skills", "skill-one", "SKILL.md"))).toBe(true) + + const mcpPath = path.join(tempRoot, "kilo.json") + expect(await exists(mcpPath)).toBe(true) + const mcpContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(mcpContent.mcp.local.type).toBe("local") + expect(mcpContent.mcp.local.command).toEqual(["echo", "hello"]) + }) + + test("writes agents to agents directory with .md extension", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-agents-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + agents: [ + { + name: "code-reviewer", + content: "---\nname: code-reviewer\n---\n\n# Code Reviewer\n\nReviews code.", + }, + { + name: "test-writer", + content: "---\nname: test-writer\n---\n\n# Test Writer\n\nWrites tests.", + }, + ], + } + + await writeKiloCodeBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".kilo", "agents", "code-reviewer.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".kilo", "agents", "test-writer.md"))).toBe(true) + + const content = await fs.readFile(path.join(tempRoot, ".kilo", "agents", "code-reviewer.md"), "utf8") + expect(content).toContain("name: code-reviewer") + }) + + test("writes skill directories by copying", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-skills-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + } + + await writeKiloCodeBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".kilocode", "skills", "skill-one"))).toBe(true) + expect(await exists(path.join(tempRoot, ".kilocode", "skills", "skill-one", "SKILL.md"))).toBe(true) + }) + + test("writes MCP config to kilo.json with mcp key", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-mcp-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { + myserver: { type: "local", command: ["serve", "--port", "3000"] }, + }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const mcpPath = path.join(tempRoot, "kilo.json") + expect(await exists(mcpPath)).toBe(true) + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcp.myserver.type).toBe("local") + expect(content.mcp.myserver.command).toEqual(["serve", "--port", "3000"]) + }) + + test("MCP config backup before overwrite", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-backup-")) + const mcpPath = path.join(tempRoot, "kilo.json") + + await fs.writeFile(mcpPath, JSON.stringify({ mcp: {} })) + + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { new: { type: "local", command: ["new-tool"] } }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const files = await fs.readdir(tempRoot) + const backupFiles = files.filter((f) => f.startsWith("kilo.json.bak.")) + expect(backupFiles.length).toBeGreaterThanOrEqual(1) + }) + + test("MCP config merge with existing preserving user servers", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-merge-")) + const mcpPath = path.join(tempRoot, "kilo.json") + + await fs.writeFile( + mcpPath, + JSON.stringify({ + mcp: { + "user-server": { type: "local", command: ["my-tool", "--flag"] }, + }, + }, null, 2), + ) + + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { + "plugin-server": { type: "local", command: ["plugin-tool"] }, + }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcp["user-server"].command).toEqual(["my-tool", "--flag"]) + expect(content.mcp["plugin-server"].command).toEqual(["plugin-tool"]) + }) + + test("handles corrupted existing kilo.json with warning", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-corrupt-")) + const mcpPath = path.join(tempRoot, "kilo.json") + + await fs.writeFile(mcpPath, "not valid json{{{") + + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" ")) + + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { new: { type: "local", command: ["new-tool"] } }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("could not be parsed"))).toBe(true) + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcp.new.command).toEqual(["new-tool"]) + }) + + test("preserves non-mcp keys in existing kilo.json", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-preserve-")) + const mcpPath = path.join(tempRoot, "kilo.json") + + await fs.writeFile( + mcpPath, + JSON.stringify({ + customSetting: true, + version: 2, + mcp: { old: { type: "local", command: ["old-tool"] } }, + }, null, 2), + ) + + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { new: { type: "local", command: ["new-tool"] } }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.customSetting).toBe(true) + expect(content.version).toBe(2) + expect(content.mcp.new.command).toEqual(["new-tool"]) + expect(content.mcp.old.command).toEqual(["old-tool"]) + }) + + test("server name collision: plugin entry wins", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-collision-")) + const mcpPath = path.join(tempRoot, "kilo.json") + + await fs.writeFile( + mcpPath, + JSON.stringify({ + mcp: { shared: { type: "local", command: ["old-version"] } }, + }, null, 2), + ) + + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { shared: { type: "local", command: ["new-version"] } }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcp.shared.command).toEqual(["new-version"]) + }) + + test("kilo.json written with restrictive permissions (0o600)", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-perms-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { server: { type: "local", command: ["tool"] } }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const mcpPath = path.join(tempRoot, "kilo.json") + const stat = await fs.stat(mcpPath) + if (process.platform !== "win32") { + const mode = stat.mode & 0o777 + expect(mode).toBe(0o600) + } + }) + + test("handles empty bundle gracefully", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-empty-")) + + await writeKiloCodeBundle(tempRoot, emptyBundle) + expect(await exists(tempRoot)).toBe(true) + expect(await exists(path.join(tempRoot, "kilo.json"))).toBe(false) + }) + + test("path traversal in agent name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-traversal-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + agents: [{ name: "../escape", content: "Bad content." }], + } + + expect(writeKiloCodeBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) + + test("path traversal in skill directory name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-skill-escape-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + skillDirs: [{ name: "../escape", sourceDir: "/tmp/fake-skill" }], + } + + expect(writeKiloCodeBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) + + test("handles existing kilo.json with array at root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-array-")) + const mcpPath = path.join(tempRoot, "kilo.json") + + await fs.writeFile(mcpPath, "[1,2,3]") + + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { new: { type: "local", command: ["new-tool"] } }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcp.new.command).toEqual(["new-tool"]) + expect(Array.isArray(content)).toBe(false) + }) + + test("handles existing kilo.json with mcp as array", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-mcp-array-")) + const mcpPath = path.join(tempRoot, "kilo.json") + + await fs.writeFile(mcpPath, JSON.stringify({ mcp: [1, 2, 3] }, null, 2)) + + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { new: { type: "local", command: ["new-tool"] } }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcp.new.command).toEqual(["new-tool"]) + }) + + test("does not write kilo.json when mcpConfig.mcp is empty", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-no-mcp-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { mcp: {} }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, "kilo.json"))).toBe(false) + }) + + test("writes remote MCP server config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-remote-")) + const bundle: KiloCodeBundle = { + ...emptyBundle, + mcpConfig: { + mcp: { + remote: { + type: "remote", + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer token" }, + }, + }, + }, + } + + await writeKiloCodeBundle(tempRoot, bundle) + + const mcpPath = path.join(tempRoot, "kilo.json") + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcp.remote.type).toBe("remote") + expect(content.mcp.remote.url).toBe("https://mcp.example.com/mcp") + expect(content.mcp.remote.headers.Authorization).toBe("Bearer token") + }) +}) + +describe("resolveKiloCodePaths", () => { + test("workspace scope paths resolve correctly", () => { + const outputRoot = "/project/root" + const paths = resolveKiloCodePaths(outputRoot, "workspace") + + expect(paths.configDir).toBe("/project/root/.kilo") + expect(paths.agentsDir).toBe("/project/root/.kilo/agents") + expect(paths.skillsDir).toBe("/project/root/.kilocode/skills") + expect(paths.mcpPath).toBe("/project/root/kilo.json") + }) + + test("global scope paths resolve correctly", () => { + const outputRoot = "/project/root" + const originalHome = process.env.HOME + process.env.HOME = "/home/testuser" + + const paths = resolveKiloCodePaths(outputRoot, "global") + + expect(paths.configDir).toBe("/home/testuser/.config/kilo") + expect(paths.agentsDir).toBe("/home/testuser/.config/kilo/agents") + expect(paths.skillsDir).toBe("/home/testuser/.kilocode/skills") + expect(paths.mcpPath).toBe("/home/testuser/.config/kilo/kilo.json") + + process.env.HOME = originalHome + }) + + test("defaults to workspace scope when scope is undefined", () => { + const outputRoot = "/project/root" + const paths = resolveKiloCodePaths(outputRoot) + + expect(paths.configDir).toBe("/project/root/.kilo") + expect(paths.agentsDir).toBe("/project/root/.kilo/agents") + expect(paths.skillsDir).toBe("/project/root/.kilocode/skills") + expect(paths.mcpPath).toBe("/project/root/kilo.json") + }) + + test("global scope falls back to USERPROFILE when HOME is not set", () => { + const originalHome = process.env.HOME + const originalUserprofile = process.env.USERPROFILE + delete process.env.HOME + process.env.USERPROFILE = "C:\\Users\\testuser" + + const paths = resolveKiloCodePaths("/project/root", "global") + + expect(paths.configDir).toContain("testuser") + expect(paths.configDir).toContain(".config") + expect(paths.configDir).toContain("kilo") + + process.env.HOME = originalHome + process.env.USERPROFILE = originalUserprofile + }) +})