diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ba886b9..2207fb62b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `apm install --target kiro` deploys Kiro IDE steering, skills, hooks, + and MCP config to the documented `.kiro/` layout. (by @TibRib; closes #702) (#1741) - APM now catches accidental subpath embeds in git URLs (for example, `org/repo/skills/name.git`) and points at the supported `path:` key form. (#1740) - SHA-pinned dependencies now stay current automatically: `apm update` resolves the newest annotated release tag, rewrites the pin, and `apm outdated` reports drift. (#1738) - `apm install --target hermes` and `apm compile -t hermes` add the Hermes diff --git a/README.md b/README.md index 595f9315a..423089383 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Think `package.json`, `requirements.txt`, or `Cargo.toml` — but for AI agent configuration. -GitHub Copilot · Claude Code · Cursor · OpenCode · Codex · Gemini · Windsurf +GitHub Copilot · Claude Code · Cursor · OpenCode · Codex · Gemini · Windsurf · Kiro **[Documentation](https://microsoft.github.io/apm/)** · **[Quick Start](https://microsoft.github.io/apm/getting-started/quick-start/)** · **[CLI Reference](https://microsoft.github.io/apm/reference/cli-commands/)** · **[Roadmap](https://github.com/orgs/microsoft/projects/2304)** @@ -67,7 +67,7 @@ One command, no configuration -- VS Code and GitHub Copilot read the file automa One `apm.yml` describes every primitive your agents need — instructions, skills, prompts, agents, hooks, plugins, MCP servers — and `apm install` reproduces the exact same setup across every client on every machine. `apm.lock.yaml` pins the resolved tree the way `package-lock.json` does for npm. -- **[One manifest for everything](https://microsoft.github.io/apm/reference/primitive-types/)** — declared once, deployed across Copilot, Claude, Cursor, OpenCode, Codex, Gemini, Windsurf +- **[One manifest for everything](https://microsoft.github.io/apm/reference/primitive-types/)** — declared once, deployed across Copilot, Claude, Cursor, OpenCode, Codex, Gemini, Windsurf, Kiro - **[Install from anywhere](https://microsoft.github.io/apm/guides/dependencies/)** — GitHub, GitLab, Bitbucket, Azure DevOps, GitHub Enterprise, Gitea, Gogs, any git host - **[Transitive dependencies](https://microsoft.github.io/apm/guides/dependencies/)** — packages can depend on packages; APM resolves the full tree - **[Author plugins](https://microsoft.github.io/apm/guides/plugins/)** — build Copilot, Claude, and Cursor plugins with dependency management, then export standard `plugin.json` @@ -146,7 +146,7 @@ apm marketplace add github/awesome-copilot apm install azure-cloud-development@awesome-copilot ``` -Or add an MCP server (wired into Copilot, Claude, Cursor, Codex, OpenCode, Gemini, and Windsurf): +Or add an MCP server (wired into Copilot, Claude, Cursor, Codex, OpenCode, Gemini, Windsurf, and Kiro): ```bash apm install --mcp io.github.github/github-mcp-server --transport http # connects over HTTPS diff --git a/docs/src/content/docs/concepts/glossary.md b/docs/src/content/docs/concepts/glossary.md index a59d4f808..b3ad9c89b 100644 --- a/docs/src/content/docs/concepts/glossary.md +++ b/docs/src/content/docs/concepts/glossary.md @@ -252,7 +252,7 @@ Source: `src/apm_cli/models/apm_package.py`, The `targets:` field in `apm.yml` (or legacy `target:`). Names which harnesses the package compiles for (`copilot`, `claude`, `cursor`, `codex`, `gemini`, -`opencode`, `windsurf`, or `all`). Drives which integrator runs and +`opencode`, `windsurf`, `kiro`, or `all`). Drives which integrator runs and which directories receive output during `apm compile`. NOT the harness itself. Target is the declaration; the harness is the diff --git a/docs/src/content/docs/concepts/lifecycle.md b/docs/src/content/docs/concepts/lifecycle.md index 1f57644d1..9bb6f8df3 100644 --- a/docs/src/content/docs/concepts/lifecycle.md +++ b/docs/src/content/docs/concepts/lifecycle.md @@ -30,7 +30,7 @@ Scaffolds a new APM project in the current directory. `apm init` writes an `apm.yml` manifest with sensible defaults for `name`, `author`, and `description`, plus empty dependency and script blocks. It records selected targets in `targets:`; author `.apm/` primitives yourself and run `apm install` or `apm compile` to create target output directories. -Targets are picked in priority order. An explicit `--target copilot,claude` flag wins. Otherwise an interactive checklist runs. Otherwise APM scans the working tree for signal directories (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`) and pre-checks every harness it finds. With `-y` and no flag, all detected harnesses are written into `apm.yml`. See [primitives and targets](/apm/concepts/primitives-and-targets/) for what each target actually receives. +Targets are picked in priority order. An explicit `--target copilot,claude` flag wins. Otherwise an interactive checklist runs. Otherwise APM scans the working tree for signal directories (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`, `.kiro/`) and pre-checks every harness it finds. With `-y` and no flag, all detected harnesses are written into `apm.yml`. See [primitives and targets](/apm/concepts/primitives-and-targets/) for what each target actually receives. **Common surprises** @@ -74,11 +74,11 @@ Order of operations is deterministic and worth memorizing: apm compile [--target ] ``` -Transforms the primitives in `.apm/` (and dependencies under `apm_modules/`) into harness-native files: `AGENTS.md` for Codex, `GEMINI.md` for Gemini, populated `.cursor/`, `.opencode/`, `.windsurf/` directories, and so on. +Transforms the primitives in `.apm/` (and dependencies under `apm_modules/`) into harness-native files: `AGENTS.md` for Codex, `GEMINI.md` for Gemini, populated `.cursor/`, `.opencode/`, `.windsurf/`, `.kiro/` directories, and so on. Most users never call `apm compile` directly. `apm install` runs it as part of the integrate phase, and `apm run` auto-compiles any `.prompt.md` files referenced by a script just before execution. Reach for `apm compile` when you want to inspect what will be deployed without changing dependencies, when you are iterating on local primitives between installs, or when you only need output for one harness. -The `--target` flag accepts a comma-separated list (`copilot,claude,cursor,opencode,codex,gemini,windsurf,agent-skills`) or `all`. `--dry-run` prints placement decisions without writing files. `--validate` checks primitive frontmatter and structure without producing output. `--watch` re-runs compilation on every change. +The `--target` flag accepts a comma-separated list (`copilot,claude,cursor,opencode,codex,gemini,windsurf,kiro,agent-skills`) or `all`. `--dry-run` prints placement decisions without writing files. `--validate` checks primitive frontmatter and structure without producing output. `--watch` re-runs compilation on every change. **Common surprises** diff --git a/docs/src/content/docs/concepts/primitives-and-targets.md b/docs/src/content/docs/concepts/primitives-and-targets.md index 06e2e9956..da9eba233 100644 --- a/docs/src/content/docs/concepts/primitives-and-targets.md +++ b/docs/src/content/docs/concepts/primitives-and-targets.md @@ -81,6 +81,7 @@ Each target is identified by a slug used in `apm.yml`'s `targets:` field and on | `gemini` | `.gemini/` | gemini | | `opencode` | `.opencode/` (project), `~/.config/opencode/` (user) | agents | | `windsurf` | `.windsurf/` (project), `~/.codeium/windsurf/` (user) | agents | +| `kiro` | `.kiro/` (project and user) | agents | Notes per target: @@ -91,6 +92,7 @@ Notes per target: - **gemini** -- Gemini CLI. Commands are TOML. Hooks merge into `.gemini/settings.json`. No native agents or instructions primitives -- both arrive via compiled context files. - **opencode** -- OpenCode. No hooks support. - **windsurf** -- Windsurf / Cascade. No native agents primitive -- Cascade auto-invokes any `SKILL.md` by its `description:` frontmatter, so personas ship as skills. Workflows are the harness's name for commands. +- **kiro** -- Kiro IDE. Instructions become steering files, skills stay as `SKILL.md` folders, hooks are individual JSON files, and MCP lands in `.kiro/settings/mcp.json`. ## The compatibility matrix @@ -101,16 +103,16 @@ Rows are primitives, columns are harnesses. Cell legend: - **unsupported** -- APM does not deliver this primitive to this harness. - **gated** -- delivered behind an explicit declaration or trust flag. -| Primitive | Copilot | Claude | Cursor | Codex | Gemini | OpenCode | Windsurf | -|---|---|---|---|---|---|---|---| -| instructions | native | native | native | compiled | compiled | compiled | native | -| prompts | native | compiled | compiled | unsupported | compiled | compiled | compiled | -| agents | native | native | compiled | compiled | unsupported | native | unsupported | -| skills | native | native | native | native | native | native | native | -| hooks | native | native | native | native | native | unsupported | native | -| commands | unsupported | native | compiled | unsupported | compiled | compiled | compiled | -| plugins | compiled | compiled | compiled | compiled | compiled | compiled | compiled | -| MCP servers | native | native | native | native | native | native | native | +| Primitive | Copilot | Claude | Cursor | Codex | Gemini | OpenCode | Windsurf | Kiro | +|---|---|---|---|---|---|---|---|---| +| instructions | native | native | native | compiled | compiled | compiled | native | native | +| prompts | native | compiled | compiled | unsupported | compiled | compiled | compiled | unsupported | +| agents | native | native | compiled | compiled | unsupported | native | unsupported | unsupported | +| skills | native | native | native | native | native | native | native | native | +| hooks | native | native | native | native | native | unsupported | native | native | +| commands | unsupported | native | compiled | unsupported | compiled | compiled | compiled | unsupported | +| plugins | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | +| MCP servers | native | native | native | native | native | native | native | native | How to read a cell: @@ -158,7 +160,7 @@ Full pattern, the three pack-time gotchas, and verification steps: [Dev-only pri 1. Explicit `--target ` flag, when passed. 2. The `targets:` field in `apm.yml`, when present. -3. Auto-detection: any harness whose root directory (`.github/`, `.claude/`, `.cursor/`, `.codex/`, `.gemini/`, `.opencode/`, `.windsurf/`) already exists in the workspace is selected. +3. Auto-detection: any harness whose root directory (`.github/`, `.claude/`, `.cursor/`, `.codex/`, `.gemini/`, `.opencode/`, `.windsurf/`, `.kiro/`) already exists in the workspace is selected. 4. Fallback: `minimal` -- APM writes `AGENTS.md` only and skips folder integration. Create one of the harness folders above (or set `targets:` explicitly) for full integration. diff --git a/docs/src/content/docs/concepts/what-is-apm.md b/docs/src/content/docs/concepts/what-is-apm.md index 96d55a533..704236c54 100644 --- a/docs/src/content/docs/concepts/what-is-apm.md +++ b/docs/src/content/docs/concepts/what-is-apm.md @@ -15,7 +15,7 @@ APM borrows the manifest-plus-lockfile shape from `npm`, `pip`, and `cargo` and `apm.yml` is the manifest. It lists agentic dependencies (skills, prompts, agents, plugins, full APM packages) and MCP servers. `apm.lock.yaml` is the lockfile. It pins every resolved package to an exact source ref and content hash, so two developers running `apm install` against the same lockfile get byte-identical context. Source authoring lives in `.apm/` inside your repo. -The compiled output lives in the directories each harness already reads: `.github/` for Copilot, `.claude/` for Claude Code, `.cursor/` for Cursor, `.codex/` and `AGENTS.md` for Codex, `.gemini/` for Gemini, `.opencode/` for OpenCode, `.windsurf/` for Windsurf. APM does not invent a runtime format. It writes the files each tool already understands and stays out of the way at agent runtime. +The compiled output lives in the directories each harness already reads: `.github/` for Copilot, `.claude/` for Claude Code, `.cursor/` for Cursor, `.codex/` and `AGENTS.md` for Codex, `.gemini/` for Gemini, `.opencode/` for OpenCode, `.windsurf/` for Windsurf, and `.kiro/` for Kiro. APM does not invent a runtime format. It writes the files each tool already understands and stays out of the way at agent runtime. ## What APM manages diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index a98b25f33..8c7966e59 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -91,6 +91,7 @@ for the full required-vs-optional runtime config rule. | Gemini CLI | `.gemini/settings.json` (project, only if `.gemini/` exists) or `~/.gemini/settings.json` (`-g`) | both | JSON `mcpServers` | | OpenCode | `opencode.json` | project (only if `.opencode/` exists) | JSON `mcp` | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | global | JSON `mcpServers` | +| Kiro IDE | `.kiro/settings/mcp.json` (project, only if `.kiro/` exists) or `~/.kiro/settings/mcp.json` (`-g`) | both | JSON `mcpServers` | ## How `targets:` gates which configs get written @@ -126,10 +127,12 @@ fails closed with the same `[x]` voice -- consistent with how declare one in `apm.yml`. (#1335) `apm install -g --mcp NAME` is a deliberate carve-out: it routes the -write to each runtime's user-scope MCP config (Copilot CLI to -`~/.copilot/mcp-config.json`, Codex CLI to `~/.codex/config.toml`, -Gemini CLI to `~/.gemini/settings.json`) and does not consult the -project-scope `targets:` whitelist -- user-scope writes are by +write to each runtime's user-scope MCP config (for example, Copilot CLI to +`~/.copilot/mcp-config.json`, Claude Code to `~/.claude.json`, Codex CLI to +`~/.codex/config.toml`, Gemini CLI to `~/.gemini/settings.json`, Windsurf to +`~/.codeium/windsurf/mcp_config.json`, Kiro to `~/.kiro/settings/mcp.json`, +and JetBrains Copilot to its OS-specific user config). It does not consult +the project-scope `targets:` whitelist -- user-scope writes are by definition not project-bound. Workspace-only runtimes (VS Code, Cursor, OpenCode) are skipped at user scope. diff --git a/docs/src/content/docs/consumer/install-packages.md b/docs/src/content/docs/consumer/install-packages.md index be5265ab0..e87dd8963 100644 --- a/docs/src/content/docs/consumer/install-packages.md +++ b/docs/src/content/docs/consumer/install-packages.md @@ -81,7 +81,7 @@ The pipeline is deterministic. Each phase must pass before the next runs. `--force`. 4. **Integrate.** Write primitives into each target harness's native directory (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, - `.codex/`, `.gemini/`, `.windsurf/`) and the cross-tool + `.codex/`, `.gemini/`, `.windsurf/`, `.kiro/`) and the cross-tool `.agents/skills/` directory. 5. **Lockfile.** Write `apm.lock.yaml` with pinned versions, content hashes, and the resolved dependency set. @@ -105,7 +105,7 @@ them. Detection priority: 1. `--target ` flag (highest). 2. The `targets:` field in `apm.yml`. 3. Auto-detect: any harness directory (`.github/`, `.claude/`, - `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`) + `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`, `.kiro/`) that already exists in the workspace. 4. Fallback: minimal output to `AGENTS.md` only. @@ -122,7 +122,7 @@ targets: For the full reach map of which primitive lands where on each harness, see [Primitives and targets](../../concepts/primitives-and-targets/). -Rule sync to Cursor (`.cursor/rules/`), Claude Code (`.claude/rules/`), and Windsurf (`.windsurf/rules/`) is automatic and idempotent -- re-running `apm install` adopts unchanged rules without rewriting them. +Rule sync to Cursor (`.cursor/rules/`), Claude Code (`.claude/rules/`), Windsurf (`.windsurf/rules/`), and Kiro (`.kiro/steering/`) is automatic and idempotent -- re-running `apm install` adopts unchanged rules without rewriting them. ## Transitive dependencies and the lockfile diff --git a/docs/src/content/docs/consumer/run-scripts.md b/docs/src/content/docs/consumer/run-scripts.md index 3ae07c55b..f911830be 100644 --- a/docs/src/content/docs/consumer/run-scripts.md +++ b/docs/src/content/docs/consumer/run-scripts.md @@ -35,7 +35,7 @@ scripts: Each value is a literal shell command. The canonical pattern is shelling out to a runtime CLI -- `copilot`, `claude`, `codex`, `cursor-agent`, -`gemini`, `opencode`, `windsurf`, or `llm` -- with a prompt file argument. +`gemini`, `opencode`, `windsurf`, `kiro`, or `llm` -- with a prompt file argument. APM does not bundle these runtimes; you install them yourself and APM invokes whichever the script names. diff --git a/docs/src/content/docs/enterprise/policy-reference.md b/docs/src/content/docs/enterprise/policy-reference.md index 900c09473..9105a5cd1 100644 --- a/docs/src/content/docs/enterprise/policy-reference.md +++ b/docs/src/content/docs/enterprise/policy-reference.md @@ -40,7 +40,7 @@ mcp: compilation: target: - allow: [] # copilot | claude | cursor | opencode | codex | gemini | vscode | windsurf | agent-skills | all + allow: [] # copilot | claude | cursor | opencode | codex | gemini | vscode | windsurf | kiro | agent-skills | all enforce: null # Enforce specific target (must be present in list) strategy: enforce: null # distributed | single-file diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index f0a9c7744..4fdb311d8 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -208,7 +208,7 @@ APM deploys files only to controlled subdirectories within the project root. All deploy paths are validated before any file operation: 1. **No `..` segments.** Any path containing `..` is rejected outright. -2. **Allowed prefixes only.** Paths must start with an allowed target-integrator prefix (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`, `.agents/`). In addition, the local-bundle install path stages instructions for compile-only targets under `apm_modules//.apm/instructions/` with its own containment check (the resolved path must remain within `apm_modules/`) and `` validation rejecting traversal sequences and characters outside `[A-Za-z0-9._-]`. +2. **Allowed prefixes only.** Paths must start with an allowed target-integrator prefix (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`, `.kiro/`, `.agents/`). In addition, the local-bundle install path stages instructions for compile-only targets under `apm_modules//.apm/instructions/` with its own containment check (the resolved path must remain within `apm_modules/`) and `` validation rejecting traversal sequences and characters outside `[A-Za-z0-9._-]`. 3. **Resolution containment.** The fully resolved path must remain within the project root directory. A path must pass all three checks. Failure on any check prevents the file from being written. diff --git a/docs/src/content/docs/getting-started/first-package.md b/docs/src/content/docs/getting-started/first-package.md index 1d62e8169..737598408 100644 --- a/docs/src/content/docs/getting-started/first-package.md +++ b/docs/src/content/docs/getting-started/first-package.md @@ -17,8 +17,8 @@ Otherwise, start here. - APM installed -- see [Installation](./installation/). - A GitHub account and an empty repo for publishing (step 5). -- A runtime where you can try the result: GitHub Copilot, Claude Code, or - Cursor. +- A runtime where you can try the result: GitHub Copilot, Claude Code, Kiro, + or Cursor. ## 1. Scaffold @@ -209,10 +209,10 @@ team-skills/ `apm install` resolves which harness directories to populate using a strict priority chain: `--target` flag > `apm.yml` `targets:` > auto-detect from filesystem signals (`.claude/`, `CLAUDE.md`, `.cursor/`, `.github/copilot-instructions.md`, -`.codex/`, `.gemini/`, `GEMINI.md`, `.opencode/`, `.windsurf/`). The example layout +`.codex/`, `.gemini/`, `GEMINI.md`, `.opencode/`, `.windsurf/`, `.kiro/`). The example layout above shows `.github/` because `.github/copilot-instructions.md` exists in the -project; if you also have `.claude/`, `.cursor/`, `.opencode/`, or `.gemini/`, those -directories get populated too. With no signal at all, `apm install` exits with +project; if you also have `.claude/`, `.cursor/`, `.opencode/`, `.gemini/`, or +`.kiro/`, those directories get populated too. With no signal at all, `apm install` exits with code 2 and a teaching message instead of silently picking a target -- declare an intent explicitly via `--target copilot` (or another harness), or by adding `targets: [copilot]` to `apm.yml`. Run `apm targets` to inspect what APM detects diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 13f419596..9576058fc 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -22,6 +22,7 @@ The full slot-by-slot capability table lives in [Targets matrix](../reference/ta | Gemini CLI | `.gemini/` or `GEMINI.md` | Single-file or distributed | | OpenCode | `.opencode/` | Skills, MCP | | Windsurf | `.windsurf/` | Rules + Skills + Workflows + MCP | +| Kiro | `.kiro/` | Steering + Skills + Hooks + MCP | | JetBrains Copilot | user-scope config dir (global) | MCP only (user-scope path, `${env:VAR}` env substitution) | | Agent-Skills (cross) | `.agents/skills/` | Vendor-neutral skill sharing | @@ -59,7 +60,7 @@ Each primitive type maps to a target-specific slot: .apm/prompts/ -> per target: prompt files / commands .apm/agents/ -> per target: agent definitions (or skill conversion) .apm/skills/ -> per target: skills directory (Claude, Codex, OpenCode, .agents) -.apm/hooks/ -> per target: lifecycle hooks (Claude only today) +.apm/hooks/ -> per target: lifecycle hooks / tool hooks (varies by target) mcp: in apm.yml -> per target: .mcp.json / settings.json / equivalent ``` @@ -107,6 +108,7 @@ MCP servers declared in `apm.yml` (under `dependencies.mcp:` or `devDependencies - `opencode.json` at the repo root when `.opencode/` exists (OpenCode) - `.gemini/settings.json` (Gemini) - `~/.codeium/windsurf/mcp_config.json` (Windsurf) +- `.kiro/settings/mcp.json` and `~/.kiro/settings/mcp.json` (Kiro IDE) - OS-specific `github-copilot/intellij/mcp.json` (JetBrains Copilot -- uses `"servers"` key, user-scope global path): - `%LOCALAPPDATA%\github-copilot\intellij\mcp.json` (Windows) @@ -115,6 +117,19 @@ MCP servers declared in `apm.yml` (under `dependencies.mcp:` or `devDependencies For server installation patterns, registry resolution, and trust model, see [MCP servers guide](../consumer/install-mcp-servers/) and [`apm mcp`](../reference/cli/mcp/). +### Kiro IDE + +[Kiro](https://kiro.dev) reads project configuration from `.kiro/`. APM maps +instructions to `.kiro/steering/` and converts `applyTo:` scoping into Kiro +steering frontmatter (`inclusion: fileMatch`); unscoped instructions become +`inclusion: always`. Skills are copied verbatim to `.kiro/skills/`, hooks +become one JSON file per hook action in `.kiro/hooks/`, and MCP servers are +written to `.kiro/settings/mcp.json` or `~/.kiro/settings/mcp.json` for +`--global`. + +This target covers the documented Kiro IDE layout. Kiro CLI configuration +differences are tracked separately; see [the targets matrix](../reference/targets-matrix/#kiro). + ### JetBrains (IntelliJ IDEA, PyCharm, GoLand, and others) GitHub Copilot for JetBrains reads MCP servers from a single user-scope diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index 24e586e5c..2a0f23cca 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -78,7 +78,7 @@ key is present but not a JSON object fails closed with a warning; a file that parses cleanly but contributes zero entries also logs a warning so authors notice empty merges during development. -The `${PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`, and `${CURSOR_PLUGIN_ROOT}` +The `${PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, and `${KIRO_PLUGIN_ROOT}` tokens resolve to the installed package root and are rewritten per target. Plain `./script.sh` resolves relative to the hook file. @@ -92,6 +92,7 @@ Supported targets and where the integrator writes: | gemini | `.gemini/settings.json` | merged | | codex | `.codex/hooks.json` | merged | | windsurf | `.windsurf/hooks.json` | merged | +| kiro | `.kiro/hooks/---.json` | one file per hook action | | opencode | -- not supported -- | silently skipped | Copilot hook files are namespaced with the source package name to avoid diff --git a/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md b/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md index 0b9b23d0b..a5dc2e87d 100644 --- a/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md +++ b/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md @@ -82,8 +82,8 @@ applyTo: "**/*.{css,scss},**/*.tsx" ``` On Copilot the comma-list is preserved verbatim (Copilot splits it -natively). On Claude, Cursor, and Windsurf the list is expanded to a -YAML array under `paths:` / `globs:`. +natively). On Claude, Cursor, Windsurf, and Kiro the list is expanded to a +YAML array under `paths:` / `globs:` / `fileMatchPattern:`. ### Body conventions @@ -103,6 +103,7 @@ YAML array under `paths:` / `globs:`. | claude | `.claude/rules/.md` | `applyTo` -> `paths:` list (comma-lists expanded to YAML array) | | cursor | `.cursor/rules/.mdc` | `applyTo` -> `globs:` (scalar for single glob, YAML array for comma-lists); description auto-derived if missing | | windsurf | `.windsurf/rules/.md` | `applyTo` -> `trigger: glob` + `globs:` (scalar or YAML array); missing `applyTo` -> `trigger: always_on` | +| kiro | `.kiro/steering/.md` | `applyTo` -> `inclusion: fileMatch` + `fileMatchPattern:`; missing `applyTo` -> `inclusion: always` | | codex | folded into `AGENTS.md` | compile-only, no per-file deploy | | gemini | folded into `GEMINI.md` | compile-only, no per-file deploy | | opencode | folded into `AGENTS.md` | compile-only, no per-file deploy | @@ -192,6 +193,7 @@ offending package and field so you can fix the source. | opencode | `.opencode/agents/.md` | verbatim | | codex | `.codex/agents/.toml` | YAML frontmatter -> TOML; body becomes `developer_instructions` | | windsurf | not deployed | Windsurf has no agents primitive -- author personas as skills (Cascade auto-invokes by description) | +| kiro | not deployed | Kiro target v1 ships personas as skills, not `.agent.md` files | | gemini | not deployed | Gemini CLI has no agents primitive | :::caution[Migration] diff --git a/docs/src/content/docs/producer/author-primitives/skills.md b/docs/src/content/docs/producer/author-primitives/skills.md index 1f2484f31..d159e94c1 100644 --- a/docs/src/content/docs/producer/author-primitives/skills.md +++ b/docs/src/content/docs/producer/author-primitives/skills.md @@ -100,6 +100,7 @@ in `references/`; keep `SKILL.md` to the always-relevant flow. |-------------------|----------------------------------------------| | `claude` | `.claude/skills//SKILL.md` | | `windsurf` | `.windsurf/skills//SKILL.md` | +| `kiro` | `.kiro/skills//SKILL.md` | | `copilot` | `.agents/skills//SKILL.md` | | `cursor` | `.agents/skills//SKILL.md` | | `codex` | `.agents/skills//SKILL.md` | @@ -109,8 +110,8 @@ in `references/`; keep `SKILL.md` to the always-relevant flow. Five harnesses converge on the cross-tool `.agents/skills/` directory. Claude keeps its harness-native path because Claude Code's -default scan is `.claude/skills/`; Windsurf currently uses -`.windsurf/skills/` for the same reason, though Cascade also +default scan is `.claude/skills/`; Windsurf and Kiro currently use +`.windsurf/skills/` and `.kiro/skills/` for the same reason. Windsurf's Cascade also [discovers `.agents/skills/`](https://docs.windsurf.com/windsurf/cascade/skills#skill-scopes) natively for cross-agent compatibility (convergence tracked in [#1520](https://github.com/microsoft/apm/issues/1520)). The whole diff --git a/docs/src/content/docs/producer/compile.md b/docs/src/content/docs/producer/compile.md index a45ccdf31..1b530b0a8 100644 --- a/docs/src/content/docs/producer/compile.md +++ b/docs/src/content/docs/producer/compile.md @@ -17,11 +17,13 @@ aggregated `AGENTS.md` / `copilot-instructions.md` it produces are a nice-to-have, not a requirement. Compile is **recommended for every other target** (`claude`, -`cursor`, `codex`, `gemini`, `opencode`, `windsurf`) -- those +`cursor`, `codex`, `gemini`, `opencode`, `windsurf`, `kiro`) -- those harnesses load instructions through the root context file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`) or a harness-specific rules -folder that compile generates. Without it, your instructions are -on disk but the harness will not pick them up. +folder that compile generates. Kiro receives `.kiro/steering/` files; +its `AGENTS.md` output remains the cross-harness fallback. Without +compile, your instructions are on disk but the harness will not pick +them up. ::: ```bash @@ -32,13 +34,14 @@ Concretely, that command rolls your `instructions/*.instructions.md` (see [Instructions](./author-primitives/instructions-and-agents/#1-instructions)) into the native rules surface each target expects: -- `AGENTS.md` -- the cross-harness root context file (Copilot, Codex, - OpenCode, Windsurf all read this). +- `AGENTS.md` -- the cross-harness root context file. Copilot, Codex, + OpenCode, and Windsurf read it directly; Kiro primarily uses the + `.kiro/steering/` files that compile also emits. - `CLAUDE.md` -- Claude Code's root context file. - `GEMINI.md` -- Gemini CLI's root context file. - per-harness rules trees that mirror each instruction's `applyTo:` glob: `.github/instructions/`, `.claude/rules/`, - `.cursor/rules/*.mdc`, `.windsurf/rules/`. + `.cursor/rules/*.mdc`, `.windsurf/rules/`, `.kiro/steering/`. Other primitive types -- prompts, skills, agents, chatmodes, hooks, commands -- are NOT compiled by this command. They are deployed by @@ -87,7 +90,7 @@ apm compile --all # every canonical target ``` Accepted values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, -`gemini`, `windsurf`, `agent-skills`, `all`. The `agent-skills` slug +`gemini`, `windsurf`, `kiro`, `agent-skills`, `all`. The `agent-skills` slug is a no-op for compile (skills are deployed by `apm install`); it is accepted in target lists for symmetry only. Unknown slugs are rejected before any work runs. @@ -107,7 +110,7 @@ order: 1. Explicit `--target ` flag. 2. The `targets:` field in your `apm.yml`. 3. Auto-detect: any harness root directory (`.github/`, `.claude/`, - `.cursor/`, `.codex/`, `.gemini/`, `.opencode/`, `.windsurf/`) that + `.cursor/`, `.codex/`, `.gemini/`, `.opencode/`, `.windsurf/`, `.kiro/`) that already exists. 4. Fallback: `minimal` -- writes a single `AGENTS.md` and skips per- harness rules folders. @@ -129,6 +132,7 @@ Per target, with the rules shape on disk after compile: | `gemini` | `GEMINI.md` (folded) | none -- compile-only, no per-file deploy | Yes -- folded into `GEMINI.md` | | `opencode` | `AGENTS.md` (folded) | none -- compile-only, no per-file deploy | Yes -- folded into `AGENTS.md` | | `windsurf` | -- | `.windsurf/rules/.md` | Yes -- compiled to Windsurf rules | +| `kiro` | `AGENTS.md` (fallback) | `.kiro/steering/.md` | Yes -- compiled to Kiro steering | ## compile vs install diff --git a/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index 7cfc734f9..35ca8119d 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -35,7 +35,7 @@ for the full reach map. `.github/instructions/*.instructions.md` (with their `applyTo:` frontmatter) that `apm install` already deploys. Compile is **recommended for every other target** (`claude`, `cursor`, `codex`, -`gemini`, `opencode`, `windsurf`), which load instructions through a +`gemini`, `opencode`, `windsurf`, `kiro`), which load instructions through a root context file or harness-specific rules folder that compile generates. @@ -44,7 +44,7 @@ Resolution order for which targets to compile: 1. `--target` / `--all` on the command line 2. `targets:` field in `apm.yml` 3. Auto-detection from existing folders (`.github/`, `.claude/`, - `.codex/`, `.gemini/`, `.windsurf/`) + `.codex/`, `.gemini/`, `.windsurf/`, `.kiro/`) Use [`apm targets`](../targets/) to preview what auto-detection resolves to before compiling. @@ -59,7 +59,7 @@ written. Critical findings cause the command to exit non-zero. See | Flag | Description | |------|-------------| -| `-t, --target VALUE` | Target(s) to compile. Comma-separated. Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `agent-skills`, `all`. | +| `-t, --target VALUE` | Target(s) to compile. Comma-separated. Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `kiro`, `agent-skills`, `all`. | | `--all` | Compile for all canonical targets. Equivalent to `--target all` and mutually exclusive with `--target`. Preferred form. | `vscode` and `agents` are accepted as deprecated aliases for `copilot` @@ -199,6 +199,7 @@ one-shot `apm compile`; `--output` only applies in single-file mode. | `opencode` | `AGENTS.md` | | `gemini` | `AGENTS.md`, `GEMINI.md` | | `windsurf` | `AGENTS.md` | +| `kiro` | `AGENTS.md` | | `agent-skills` | none | | `all` | `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md` | diff --git a/docs/src/content/docs/reference/cli/deps.md b/docs/src/content/docs/reference/cli/deps.md index 49daaeb2b..ee2119bca 100644 --- a/docs/src/content/docs/reference/cli/deps.md +++ b/docs/src/content/docs/reference/cli/deps.md @@ -107,7 +107,7 @@ apm deps update [PACKAGES...] [OPTIONS] |---|---| | `-v, --verbose` | Show detailed update information. | | `--force` | Overwrite locally-authored files on collision. | -| `-t, --target` | Force deployment to specific targets. Comma-separated. Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `agent-skills`, `all`. `copilot-cowork` is also accepted when its experimental flag is enabled. `agent-skills` deploys to `.agents/skills/` (cross-client). `all` covers every per-client target but excludes `agent-skills`; combine to get both. | +| `-t, --target` | Force deployment to specific targets. Comma-separated. Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `kiro`, `agent-skills`, `all`. `copilot-cowork` is also accepted when its experimental flag is enabled. `agent-skills` deploys to `.agents/skills/` (cross-client). `all` covers every per-client target but excludes `agent-skills`; combine to get both. | | `--parallel-downloads N` | Max concurrent downloads. Default `4`. `0` disables parallelism. | | `-g, --global` | Update user-scope dependencies in `~/.apm/`. | | `--legacy-skill-paths` | Deploy skill files to per-client paths (`.cursor/skills/`, etc.) instead of the shared `.agents/skills/` directory. | diff --git a/docs/src/content/docs/reference/cli/init.md b/docs/src/content/docs/reference/cli/init.md index ddea010c8..d4ca6786c 100644 --- a/docs/src/content/docs/reference/cli/init.md +++ b/docs/src/content/docs/reference/cli/init.md @@ -35,7 +35,7 @@ and [`apm marketplace init`](../marketplace/) instead. | `-y`, `--yes` | off | Skip interactive prompts; use auto-detected defaults. Overwrites an existing `apm.yml` without confirmation. | | `--plugin` | off | **Deprecated.** Use [`apm plugin init`](../plugin/) instead. Scaffold a plugin authoring project: also writes `plugin.json` and adds a `devDependencies` block to `apm.yml`. Plugin name must be kebab-case, max 64 chars. | | `--marketplace` | off | **Deprecated.** Use [`apm marketplace init`](../marketplace/) instead. Append a `marketplace:` authoring block to `apm.yml`. See [Publish to a marketplace](../../../producer/publish-to-a-marketplace/). | -| `--target` | (prompt) | Comma-separated target list. Skips the interactive target prompt and writes targets directly. Valid values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`. | +| `--target` | (prompt) | Comma-separated target list. Skips the interactive target prompt and writes targets directly. Valid values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `kiro`. | | `-v`, `--verbose` | off | Show detailed output. | Target precedence: `--target` flag > interactive prompt > auto-detect at diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 4cbaf385c..c6a190d35 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -46,11 +46,11 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA | Flag | Default | Description | |---|---|---| -| `--target`, `-t VALUE` | auto-detect | Force deployment targets. Comma-separated for multiple (`-t claude,cursor`). Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `intellij`, `agent-skills`, `all`; experimental `copilot-cowork` and `copilot-app` are also accepted when enabled. `all` expands to every harness above except `agent-skills`; combine `all,agent-skills` for both. Highest precedence in the chain `--target` > `apm.yml targets:` > auto-detect. With nothing to detect, install exits `2` with a teaching message. | +| `--target`, `-t VALUE` | auto-detect | Force deployment targets. Comma-separated for multiple (`-t claude,cursor`). Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `kiro`, `intellij`, `agent-skills`, `all`; experimental `copilot-cowork` and `copilot-app` are also accepted when enabled. `all` expands to every harness above except `agent-skills`; combine `all,agent-skills` for both. Highest precedence in the chain `--target` > `apm.yml targets:` > auto-detect. With nothing to detect, install exits `2` with a teaching message. | | `--runtime VALUE` | unset | Legacy alias for `--target` (single value only). Still accepted; prefer `--target`. | | `--exclude VALUE` | unset | Skip a single runtime that auto-detect or `targets:` would otherwise enable. | | `--only apm\|mcp` | both | Install only APM packages or only MCP servers. | -| `-g`, `--global` | off | Install to user scope (`~/.apm/`) instead of the current project. MCP servers deploy only to global-capable runtimes (Copilot CLI, Codex CLI, JetBrains Copilot). | +| `-g`, `--global` | off | Install to user scope (`~/.apm/`) instead of the current project. MCP servers deploy only to global-capable runtimes, such as Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Kiro, Windsurf, and JetBrains Copilot. | | `--legacy-skill-paths` | off | Deploy skills to per-client paths (`.cursor/skills/`, `.github/skills/`, ...) instead of the converged `.agents/skills/`. Env: `APM_LEGACY_SKILL_PATHS=1`. | ### Policy and trust @@ -199,7 +199,7 @@ apm install owner/skill-bundle --skill '*' # reset to all skills - **`--force` is dual-purpose.** It overwrites locally-authored files on collision **and** disables the critical-finding block from the built-in security scan. It does **not** suppress general install errors -- any error reported in the diagnostic summary still exits `1` (matches `npm` / `pip` / `cargo`). It does **not** refresh remote refs -- for routine ref updates, run [`apm update`](../update/). To remediate findings, prefer `apm audit --strip`. See [Drift and secure by default](../../../consumer/drift-and-secure-by-default/). - **Claude target prompt rewrite.** When deploying to `.claude/commands/`, prompt files with an `input:` front-matter key are rewritten to Claude's `arguments:` shape and `${input:name}` placeholders become `$name`. Argument names must match `^[A-Za-z][\w-]{0,63}$`; rejected names are dropped with a warning. -- **MCP env-var passthrough.** Copilot CLI translates `${env:VAR}` and `` to `${VAR}` in `~/.copilot/mcp-config.json`. JetBrains Copilot preserves env references as `${env:VAR}` in `github-copilot/intellij/mcp.json`. Plaintext secrets are never written to disk for these runtime-resolved targets; legacy targets resolve placeholders at install time. +- **MCP env-var passthrough.** Copilot CLI and Kiro translate `${env:VAR}` and `` to `${VAR}` in their MCP configs. Kiro writes `.kiro/settings/mcp.json` and `~/.kiro/settings/mcp.json` with `0o600` permissions. JetBrains Copilot preserves env references as `${env:VAR}` in `github-copilot/intellij/mcp.json`. Plaintext secrets are never written to disk for these runtime-resolved targets; legacy targets resolve placeholders at install time. ### Install from a private registry (experimental) diff --git a/docs/src/content/docs/reference/cli/targets.md b/docs/src/content/docs/reference/cli/targets.md index 2bade9d03..ef53af7e4 100644 --- a/docs/src/content/docs/reference/cli/targets.md +++ b/docs/src/content/docs/reference/cli/targets.md @@ -75,6 +75,7 @@ Sample output in a project with `CLAUDE.md` and `.cursor/`: gemini inactive needs GEMINI.md .gemini/ opencode inactive needs .opencode/ .opencode/ windsurf inactive needs .windsurf/ .windsurf/ + kiro inactive needs .kiro/ .kiro/ ``` Machine-readable form: @@ -98,6 +99,7 @@ match per target is enough to activate it. | `gemini` | `.gemini/` directory, or `GEMINI.md` file | `.gemini/` | | `opencode` | `.opencode/` directory | `.opencode/` | | `windsurf` | `.windsurf/` directory | `.windsurf/` | +| `kiro` | `.kiro/` directory | `.kiro/` | | `agent-skills` | Meta-target; never auto-detected. Opt in via `targets:` in `apm.yml` or `--target agent-skills` on `apm install` / `apm deps update` (compile is a no-op for this target). | `.agents/` | Notes: diff --git a/docs/src/content/docs/reference/cli/uninstall.md b/docs/src/content/docs/reference/cli/uninstall.md index cef8219b9..080346a0a 100644 --- a/docs/src/content/docs/reference/cli/uninstall.md +++ b/docs/src/content/docs/reference/cli/uninstall.md @@ -78,8 +78,8 @@ What gets removed, in order: 1. The package entry in `apm.yml` under `dependencies.apm` or `devDependencies.apm`. 2. The package folder under `apm_modules/owner/repo/`. 3. Transitive dependencies that no remaining package depends on (npm-style pruning, computed from `apm.lock.yaml`). -4. Every file in the lockfile's `deployed_files` for the removed packages and pruned orphans, across all harness folders (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.gemini/`, `.codex/`, `.windsurf/`). -5. Hook entries inside `.claude/settings.json`, `.cursor/hooks.json`, and `.gemini/settings.json` that the removed packages contributed. +4. Every file in the lockfile's `deployed_files` for the removed packages and pruned orphans, across all harness folders (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.gemini/`, `.codex/`, `.windsurf/`, `.kiro/`). +5. Hook entries inside `.claude/settings.json`, `.cursor/hooks.json`, `.gemini/settings.json`, and `.kiro/hooks/` that the removed packages contributed. 6. MCP servers contributed only by the removed packages. 7. The lockfile entries themselves. If no dependencies remain, `apm.lock.yaml` is deleted. 8. Empty parent directories left behind by the cleanup. diff --git a/docs/src/content/docs/reference/cli/update.md b/docs/src/content/docs/reference/cli/update.md index 26489f945..fb6e7b1e4 100644 --- a/docs/src/content/docs/reference/cli/update.md +++ b/docs/src/content/docs/reference/cli/update.md @@ -43,7 +43,7 @@ For a read-only install that pins to whatever is already in `apm.lock.yaml` -- t | `--global`, `-g` | off | Refresh user-scope dependencies under `~/.apm/` instead of the current project (mirrors `apm install -g`). | | `--force` | off | Overwrite locally-authored files on collision. | | `--parallel-downloads N` | `4` | Max concurrent package downloads. `0` disables parallelism. | -| `--target TARGET`, `-t TARGET` | auto-detect | Agent harness(es) to update for. Accepts a single value (`claude`, `copilot`, `cursor`, `windsurf`, `codex`, `opencode`, `gemini`) or comma-separated list (`--target claude,cursor`). Overrides `apm.yml targets:` and auto-detection. | +| `--target TARGET`, `-t TARGET` | auto-detect | Agent harness(es) to update for. Accepts a single value (`claude`, `copilot`, `cursor`, `windsurf`, `kiro`, `codex`, `opencode`, `gemini`) or comma-separated list (`--target claude,cursor`). Overrides `apm.yml targets:` and auto-detection. | ## Examples diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 6f4866779..ffcb58541 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -124,11 +124,11 @@ Newly initialised projects (`apm init`) are scaffolded by the CLI; see [`apm ini | **Type** | `string` or `list` | | **Required** | OPTIONAL | | **Default** | Auto-detect from folder presence (see below). | -| **Allowed values** | `vscode`, `agents`, `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `all` | +| **Allowed values** | `vscode`, `agents`, `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `kiro`, `all` | Controls which output targets are generated during compilation, installation, and packing. Accepts a single string or a YAML list. Unknown values MUST raise a parse error at load time, naming the offending token. -When `target:` is omitted, a conforming resolver SHOULD auto-detect: `vscode` if `.github/` exists, `claude` if `.claude/` exists, `codex` if `.codex/` exists, `windsurf` if `.windsurf/` exists, `all` if multiple are present, `minimal` if none. Auto-detection applies only when `target:` is unset; once set, the field is authoritative. +When `target:` is omitted, a conforming resolver SHOULD auto-detect: `vscode` if `.github/` exists, `claude` if `.claude/` exists, `codex` if `.codex/` exists, `windsurf` if `.windsurf/` exists, `kiro` if `.kiro/` exists, `all` if multiple are present, `minimal` if none. Auto-detection applies only when `target:` is unset; once set, the field is authoritative. ```yaml # Single target @@ -158,6 +158,7 @@ A plural alias `targets:` (YAML list only) is also accepted and takes precedence | `codex` | Emits `AGENTS.md` and deploys skills to `.agents/skills/`, agents to `.codex/agents/`. | | `gemini` | Emits `GEMINI.md` and deploys to `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json`. | | `windsurf` | Emits `AGENTS.md` and deploys to `.windsurf/rules/`, `.windsurf/skills/`, `.windsurf/workflows/`, `.windsurf/hooks.json`. | +| `kiro` | Emits `AGENTS.md` and deploys to `.kiro/steering/`, `.kiro/skills/`, `.kiro/hooks/`, `.kiro/settings/mcp.json`. | | `all` | All targets. Cannot be combined with other values in a list. | | `minimal` | `AGENTS.md` only at project root. **Auto-detected only**: this value MUST NOT be set explicitly in manifests; it is an internal fallback when no target folder is detected. | @@ -536,7 +537,7 @@ dependencies: Values in `headers` and `env` may contain three placeholder syntaxes. APM resolves them per-target so secrets stay out of generated config files where possible. -| Syntax | Source | VS Code | JetBrains Copilot | Copilot CLI | Codex / Gemini / Cursor | +| Syntax | Source | VS Code | JetBrains Copilot | Copilot CLI / Kiro | Codex / Gemini / Cursor | |---|---|---|---|---|---| | `${VAR}` | host environment | Translated to `${env:VAR}` (resolved at server-start by VS Code) | Translated to `${env:VAR}` | Native; passed through verbatim | Resolved at install time from env (or interactive prompt) | | `${env:VAR}` | host environment | Native; passed through verbatim | Native; passed through verbatim | Translated to `${VAR}` | Resolved at install time from env (or interactive prompt) | @@ -545,7 +546,7 @@ Values in `headers` and `env` may contain three placeholder syntaxes. APM resolv - **VS Code** has native `${env:VAR}` and `${input:VAR}` interpolation, so APM emits placeholders rather than baking secrets into `mcp.json`. Bare `${VAR}` is normalized to `${env:VAR}` for you. - **JetBrains Copilot** has native `${env:VAR}` interpolation in `mcp.json`; APM normalizes `${VAR}` and legacy `` to `${env:VAR}`. -- **Copilot CLI** has native `${VAR}` interpolation in `~/.copilot/mcp-config.json`; APM normalizes `${env:VAR}` and legacy `` to `${VAR}`. +- **Copilot CLI and Kiro** have native `${VAR}` interpolation in their MCP config files; APM normalizes `${env:VAR}` and legacy `` to `${VAR}`. - **Codex, Gemini, and Cursor** have no runtime interpolation, so APM resolves `${VAR}`, `${env:VAR}`, and the legacy `` at install time using `os.environ` (or an interactive prompt when missing). Resolved values are not re-scanned, so a value containing literal `${...}` text is preserved. - **Recommended:** Use `${VAR}` or `${env:VAR}` in all new manifests - they work on every target that supports remote MCP servers. `` is legacy; in VS Code it would silently render as literal text in the generated config. - **Registry-backed servers** - APM auto-generates input prompts from registry metadata only for required variables. Optional variables do not generate prompts or runtime config entries when no value is available. If a user has already edited an optional value in runtime config, reinstall preserves that value rather than overwriting it. diff --git a/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index cc762e874..87207a1dc 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -26,11 +26,12 @@ see [Primitive types](./primitive-types/). | gemini | `.gemini/` | [ ] | [ ] | [ ] | [x] | [x] | [x] | [x] | | opencode | `.opencode/` | [ ] | [ ] | [x] | [x] | [x] | [ ] | [x] | | windsurf | `.windsurf/` | [x] | [ ] | [ ] | [x] | [x] | [x] | [x] | +| kiro | `.kiro/` | [x] | [ ] | [ ] | [x] | [ ] | [x] | [x] | | agent-skills | `.agents/` | [ ] | [ ] | [ ] | [x] | [ ] | [ ] | [ ] | Skills deploy to `.agents/skills/` for Copilot, Cursor, OpenCode, Gemini, and Codex by default (see [Skills convergence](#skills-convergence) -below). Claude and Windsurf keep target-native skill directories. +below). Claude, Windsurf, and Kiro keep target-native skill directories. `copilot-cowork` (Microsoft 365 Copilot), `copilot-app` (GitHub Copilot desktop App), and `openclaw` (OpenClaw agent runtime) are @@ -61,6 +62,7 @@ list before `compile` or `install`. | gemini | `.gemini/` directory, or `GEMINI.md` file | | opencode | `.opencode/` directory | | windsurf | `.windsurf/` directory | +| kiro | `.kiro/` directory | `agent-skills` is never auto-detected but is a canonical target: select it with `--target` or list it in a project's `apm.yml` `targets:` field so @@ -172,6 +174,21 @@ Windsurf / Cascade. - **Agents.** Not deployed. Cascade auto-invokes any `SKILL.md` by its `description:` frontmatter, so a separate agents primitive would collide with skills on the same path. Ship personas as skills under `.apm/skills//SKILL.md` instead. - **User scope.** Partial. `instructions` is excluded at user scope; Windsurf stores global memory in a single `~/.codeium/windsurf/memories/global_rules.md` file with a different format. +## kiro + +Kiro IDE. + +- **Detection.** `.kiro/` directory. +- **Deploy directory.** `.kiro/` (project and user scope). +- **Supported primitives.** instructions, skills, hooks, mcp. +- **File conventions.** + - instructions: `.kiro/steering/.md` with `inclusion: always` or `inclusion: fileMatch` frontmatter + - skills: `.kiro/skills//SKILL.md` + - hooks: one JSON file per hook action under `.kiro/hooks/` + - mcp: `.kiro/settings/mcp.json` (project) or `~/.kiro/settings/mcp.json` (user) +- **MCP shape.** JSON `mcpServers` entries use `command`/`args`/`env` for stdio and `url`/`headers` for remote servers. Kiro resolves `${VAR}` placeholders at runtime, so APM preserves them rather than writing secrets to disk. +- **Scope.** This is the documented Kiro IDE layout only. Kiro CLI differences are tracked separately and are not part of this target. + ## agent-skills Cross-client shared skills directory. @@ -209,7 +226,7 @@ To restore the pre-convergence per-target layout (skills land under each target' MCP is not a `TargetProfile` primitive; it is wired by a separate integrator that writes per-client config files (e.g. -`.vscode/mcp.json`, `.cursor/mcp.json`, `.claude.json`) for every +`.vscode/mcp.json`, `.cursor/mcp.json`, `.claude.json`, `.kiro/settings/mcp.json`) for every target in the active set that has an MCP client adapter. Active set follows the same `--target` > `targets:` > auto-detect chain as `apm install`: a runtime with an adapter but outside the active set diff --git a/docs/src/content/docs/troubleshooting/compile-zero-output-warning.md b/docs/src/content/docs/troubleshooting/compile-zero-output-warning.md index 8f0bbc934..f285cb5d4 100644 --- a/docs/src/content/docs/troubleshooting/compile-zero-output-warning.md +++ b/docs/src/content/docs/troubleshooting/compile-zero-output-warning.md @@ -25,7 +25,7 @@ apm targets If the active list is empty, no harness was detected and no `target:` was pinned. APM has nothing to write against. Either: -- Create one of the canonical signals (`.claude/`, `.github/copilot-instructions.md` or any of `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/`, `.cursor/`, `.codex/`, `.gemini/` or `GEMINI.md`, `.opencode/`, `.windsurf/`). +- Create one of the canonical signals (`.claude/`, `.github/copilot-instructions.md` or any of `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/`, `.cursor/`, `.codex/`, `.gemini/` or `GEMINI.md`, `.opencode/`, `.windsurf/`, `.kiro/`). - Pin targets in `apm.yml` (`target: [claude, copilot]`) or pass `--target` to `apm compile`. See [`apm targets`](../reference/cli/targets/) and the [manifest schema](../reference/manifest-schema/). diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 856ba6c93..9ee665313 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -10,7 +10,7 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Codex CLI, JetBrains Copilot), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | +| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | | `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). | | `apm uninstall PKGS...` | Remove packages (accepts `owner/repo` or `name@marketplace`) | `--dry-run`, `-g` global | | `apm prune` | Remove orphaned packages | `--dry-run` | @@ -34,7 +34,7 @@ 1. `--target` flag (highest; CSV form: `--target claude,cursor`). 2. `apm.yml` `targets:` list (or singular `target:` sugar). -3. Auto-detect from filesystem signals (`.claude/` or `CLAUDE.md` -> claude, `.cursor/` -> cursor, `.github/copilot-instructions.md` or any of `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/` -> copilot, `.codex/` -> codex, `.gemini/` or `GEMINI.md` -> gemini, `.opencode/` -> opencode, `.windsurf/` -> windsurf, the user-scope JetBrains Copilot MCP config directory `github-copilot/intellij/` -- `%LOCALAPPDATA%\github-copilot\intellij\` on Windows, `~/Library/Application Support/github-copilot/intellij/` on macOS, `~/.local/share/github-copilot/intellij/` on Linux -> intellij). All signals except JetBrains are project-scoped repo markers; the JetBrains signal is a machine-global user-scope directory, so once the Copilot plugin is installed it is detected for every project on that machine. +3. Auto-detect from filesystem signals (`.claude/` or `CLAUDE.md` -> claude, `.cursor/` -> cursor, `.github/copilot-instructions.md` or any of `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/` -> copilot, `.codex/` -> codex, `.gemini/` or `GEMINI.md` -> gemini, `.opencode/` -> opencode, `.windsurf/` -> windsurf, `.kiro/` -> kiro, the user-scope JetBrains Copilot MCP config directory `github-copilot/intellij/` -- `%LOCALAPPDATA%\github-copilot\intellij\` on Windows, `~/Library/Application Support/github-copilot/intellij/` on macOS, `~/.local/share/github-copilot/intellij/` on Linux -> intellij). All signals except JetBrains are project-scoped repo markers; the JetBrains signal is a machine-global user-scope directory, so once the Copilot plugin is installed it is detected for every project on that machine. `apm install` prints a one-line provenance summary before any mutation: diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index a87788b06..fdc1923fe 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -327,6 +327,7 @@ dependencies: # from host env at server-start (no plaintext on disk). # VS Code and JetBrains: rewritten to ${env:VAR} # and resolved at runtime. + # Kiro: preserved as ${VAR} and resolved at runtime. # Cursor/Windsurf/OpenCode/Claude/Gemini: resolved at install time. # Codex: passed through unchanged. # ${input:} -> VS Code prompts user at runtime diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index b6cd85842..54171786f 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -98,6 +98,7 @@ hooks: | `*-codex-hooks.json` | Codex CLI only | | `*-gemini-hooks.json` | Gemini CLI only | | `*-windsurf-hooks.json` | Windsurf only | +| `*-kiro-hooks.json` | Kiro only | | Any other name (e.g. `hooks.json`, `telemetry-hooks.json`) | All targets | Example directory tree for a multi-target hook package: @@ -109,19 +110,21 @@ my-hooks-pkg/ copilot-hooks.json # Copilot only cursor-hooks.json # Cursor only claude-hooks.json # Claude Code only + kiro-hooks.json # Kiro only ``` APM automatically normalises event names per target (e.g. `postToolUse` becomes `PostToolUse` in Claude) and rewrites path variables (`${PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`) to -the correct target-specific form. +the correct target-specific form. Kiro materializes one JSON document per +hook action under `.kiro/hooks/`. ### Hook command paths: project-scope stays repo-relative `apm install` (project-scope, no `-g`) keeps hook `command` paths **repo-relative** in checked-in configs (`/.claude/settings.json`, `/.codex/hooks.json`, the `/.claude/apm-hooks.json` -sidecar, and equivalents for Cursor / Gemini / Windsurf) so clones, +sidecar, and equivalents for Cursor / Gemini / Windsurf / Kiro) so clones, contributors, and CI runners do not see the installer's machine-local absolute prefix. `apm install -g` (user-scope, e.g. `~/.claude/settings.json`) rewrites `${PLUGIN_ROOT}` and relative `./` @@ -149,11 +152,11 @@ Both `apm.yml`'s `targets:`/`target:` and the `--target` CLI flag share the same | Form | Behaviour | |------|-----------| | `targets: [claude, copilot]` | Canonical list form; only listed targets are compiled/installed | -| `target: copilot` | Singular sugar; allowed values: `vscode`, `agents`, `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `all` | +| `target: copilot` | Singular sugar; allowed values: `vscode`, `agents`, `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `windsurf`, `kiro`, `all` | | `target: claude,copilot` | CSV-string sugar; parses identically to the list form (the shared validator splits on `,`) | | `targets:` and `target:` both set | **Parse error** -- pick one | | `targets: []` (empty list) | **Parse error** -- remove the line if you meant auto-detect | -| `targets:`/`target:` omitted | Resolution falls through to auto-detect from filesystem signals (`.claude/`, `CLAUDE.md`, `.cursor/`, `.cursorrules`, `.github/copilot-instructions.md`, `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/`, `.codex/`, `.gemini/`, `GEMINI.md`, `.opencode/`, `.windsurf/`) | +| `targets:`/`target:` omitted | Resolution falls through to auto-detect from filesystem signals (`.claude/`, `CLAUDE.md`, `.cursor/`, `.cursorrules`, `.github/copilot-instructions.md`, `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/`, `.codex/`, `.gemini/`, `GEMINI.md`, `.opencode/`, `.windsurf/`, `.kiro/`) | | `target: bogus` (unknown token) | **Parse error** -- fix the typo | | `target: [all, claude]` (`all` mixed with other targets) | **Parse error** -- use `all` alone | @@ -180,8 +183,8 @@ tags: [security, validation] accepted; when multiple sequence elements are given, the first is used. Commas inside brace alternation (`**/*.{css,scss}`) are part of the glob and are NOT separators -- only top-level commas split the list. On Copilot -the value is preserved verbatim; on Claude/Cursor/Windsurf comma-lists are -expanded to a YAML array under `paths:` / `globs:`. +the value is preserved verbatim; on Claude/Cursor/Windsurf/Kiro comma-lists are +expanded to a YAML array under `paths:` / `globs:` / `fileMatchPattern:`. ### 2. Chatmode (`*.chatmode.md`) diff --git a/src/apm_cli/adapters/client/kiro.py b/src/apm_cli/adapters/client/kiro.py new file mode 100644 index 000000000..e82814c1e --- /dev/null +++ b/src/apm_cli/adapters/client/kiro.py @@ -0,0 +1,226 @@ +"""Kiro IDE implementation of MCP client adapter. + +Kiro reads MCP configuration from ``.kiro/settings/mcp.json`` at project +scope and ``~/.kiro/settings/mcp.json`` at user scope. The schema uses a +``mcpServers`` object whose server entries support stdio ``command`` / +``args`` / ``env`` and remote ``url`` / ``headers`` entries. Kiro resolves +``${VAR}`` environment placeholders at runtime, so this adapter preserves +placeholders instead of writing host secrets to disk. +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Any + +from ...utils.atomic_io import atomic_write_text +from ...utils.console import _rich_error, _rich_success +from .copilot import CopilotClientAdapter + +logger = logging.getLogger(__name__) + + +class KiroClientAdapter(CopilotClientAdapter): + """Kiro IDE MCP client adapter.""" + + supports_user_scope: bool = True + _client_label: str = "Kiro" + target_name: str = "kiro" + mcp_servers_key: str = "mcpServers" + _supports_runtime_env_substitution: bool = True + + def _get_kiro_root(self) -> Path: + """Return the ``.kiro`` directory for the active scope.""" + if self.user_scope: + return Path.home() / ".kiro" + return self.project_root / ".kiro" + + def get_config_path(self) -> str: + """Return the Kiro MCP config path for the active scope.""" + return str(self._get_kiro_root() / "settings" / "mcp.json") + + def get_current_config(self) -> dict[str, Any]: + """Read the current Kiro MCP config.""" + config_path = Path(self.get_config_path()) + if not config_path.exists(): + return {} + try: + with open(config_path, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Could not read %s: %s", config_path, exc) + return {} + + def update_config(self, config_updates: dict[str, dict[str, Any]]) -> bool | None: + """Merge *config_updates* into Kiro's ``mcpServers`` object. + + Project scope is opt-in: the workspace must already contain ``.kiro/``. + User scope creates ``~/.kiro/settings/`` on demand. + """ + kiro_root = self._get_kiro_root() + if not self.user_scope and not kiro_root.is_dir(): + logger.debug("Skipping Kiro project-scope write -- %s does not exist", kiro_root) + return None + + config_path = Path(self.get_config_path()) + current_config = self.get_current_config() + if not isinstance(current_config.get(self.mcp_servers_key), dict): + current_config[self.mcp_servers_key] = {} + current_config[self.mcp_servers_key].update(config_updates) + + config_path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_text( + config_path, + json.dumps(current_config, indent=2), + new_file_mode=0o600, + ) + # Keep existing config files private after updates too. + os.chmod(config_path, 0o600) + return True + + @staticmethod + def _header_mapping(remote: dict[str, Any]) -> dict[str, str]: + """Return registry remote headers as string key-value pairs.""" + headers = remote.get("headers", {}) + if isinstance(headers, list): + return { + str(h["name"]): str(h["value"]) + for h in headers + if isinstance(h, dict) and "name" in h and "value" in h + } + if isinstance(headers, dict): + return {str(name): str(value) for name, value in headers.items()} + return {} + + @staticmethod + def _copy_kiro_extensions(config: dict[str, Any], server_info: dict[str, Any]) -> None: + """Carry Kiro-specific MCP fields when the registry supplies them.""" + for key in ("autoApprove", "disabledTools", "disabled"): + if key in server_info and server_info[key] is not None: + config[key] = server_info[key] + + def _format_server_config( + self, + server_info: dict[str, Any], + env_overrides: dict[str, str] | None = None, + runtime_vars: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Format registry or self-defined server info for Kiro.""" + if runtime_vars is None: + runtime_vars = {} + + raw = server_info.get("_raw_stdio") + if raw: + config: dict[str, Any] = {"command": raw["command"]} + resolved_env_for_args: dict[str, Any] = {} + if raw.get("env"): + resolved_env_for_args = self._resolve_environment_variables( + raw["env"], env_overrides=env_overrides + ) + config["env"] = resolved_env_for_args + self._warn_input_variables(raw["env"], server_info.get("name", ""), "Kiro") + config["args"] = [ + self._resolve_variable_placeholders(arg, resolved_env_for_args, runtime_vars) + if isinstance(arg, str) + else arg + for arg in raw.get("args") or [] + ] + self._copy_kiro_extensions(config, server_info) + return config + + remotes = server_info.get("remotes", []) + if remotes: + remote = self._select_remote_with_url(remotes) or remotes[0] + transport = (remote.get("transport_type") or "").strip() + if not transport: + transport = "http" + elif transport not in ("sse", "http", "streamable-http"): + raise ValueError( + f"Unsupported remote transport '{transport}' for Kiro. " + f"Server: {server_info.get('name', 'unknown')}. " + "Supported transports: http, sse, streamable-http." + ) + + config = {"url": (remote.get("url") or "").strip()} + headers = { + name: self._resolve_env_variable(name, value, env_overrides) + if isinstance(value, str) + else value + for name, value in self._header_mapping(remote).items() + if name + } + if headers: + config["headers"] = headers + self._warn_input_variables(headers, server_info.get("name", ""), "Kiro") + self._copy_kiro_extensions(config, server_info) + return config + + packages = server_info.get("packages", []) + if not packages: + raise ValueError( + "MCP server has incomplete configuration in registry - " + "no package information or remote endpoints available. " + f"Server: {server_info.get('name', 'unknown')}" + ) + + config = {} + package = self._select_and_dispatch_best_package( + config, + packages, + env_overrides, + runtime_vars, + ) + if not package: + raise ValueError( + f"No supported package type found for Kiro. " + f"Server: {server_info.get('name', 'unknown')}." + ) + self._copy_kiro_extensions(config, server_info) + return config + + def configure_mcp_server( + self, + server_url: str, + server_name: str | None = None, + enabled: bool = True, + env_overrides: dict[str, str] | None = None, + server_info_cache: dict[str, Any] | None = None, + runtime_vars: dict[str, str] | None = None, + ) -> bool: + """Configure an MCP server in Kiro's MCP config.""" + if not server_url: + _rich_error("server_url cannot be empty", symbol="error") + return False + + if not self.user_scope and not self._get_kiro_root().is_dir(): + logger.debug( + "Kiro opt-in gate: %s absent, skipping configure_mcp_server", + self._get_kiro_root(), + ) + return True + + config_key = self._determine_config_key(server_url, server_name) + + try: + server_info = self._fetch_server_info(server_url, server_info_cache) + if server_info is None: + return False + + self._last_env_placeholder_keys = set() + self._last_legacy_angle_vars = set() + + server_config = self._format_server_config(server_info, env_overrides, runtime_vars) + if not enabled: + server_config["disabled"] = True + self.update_config({config_key: server_config}) + + _rich_success(f"Configured MCP server '{config_key}' for Kiro", symbol="success") + return True + except Exception as exc: + logger.debug("Kiro MCP configuration failed: %s", exc) + _rich_error(f"Failed to configure MCP server '{config_key}' for Kiro", symbol="error") + return False diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 697ec4770..19f319256 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -791,7 +791,7 @@ def _coerce_provenance_targets(value): "-t", type=TargetParamType(), default=None, - help="Target platform (comma-separated). Values: copilot, claude, cursor, opencode, codex, gemini, windsurf, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf (excludes agent-skills); combine with 'agent-skills' for both.", + help="Target platform (comma-separated). Values: copilot, claude, cursor, opencode, codex, gemini, windsurf, kiro, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro (excludes agent-skills); combine with 'agent-skills' for both.", ) @click.option( "--dry-run", diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 6df1e5c6d..539b6bf3e 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -885,7 +885,7 @@ def _handle_mcp_install( @click.option( "--runtime", help=( - "Target specific runtime only (copilot, codex, vscode, cursor, opencode, gemini, claude, windsurf, intellij)" + "Target specific runtime only (copilot, claude, codex, cursor, gemini, intellij, kiro, opencode, vscode, windsurf)" ), ) @click.option("--exclude", help="Exclude specific runtime from installation") @@ -935,7 +935,7 @@ def _handle_mcp_install( "target", type=TargetParamType(), default=None, - help="Target harness(es) to deploy to. Comma-separated for multiple: --target claude,cursor. Repeating the flag (e.g. '-t a -t b') is NOT supported -- only the last value wins; use commas. Highest-priority entry in the resolution chain (--target > apm.yml targets: > auto-detect). Values: copilot, claude, cursor, opencode, codex, gemini, windsurf, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf (excludes agent-skills); combine with 'agent-skills' for both. 'copilot-cowork' is also accepted when the copilot-cowork experimental flag is enabled (run 'apm experimental enable copilot-cowork'). 'copilot-app' is also accepted when the copilot-app experimental flag is enabled (run 'apm experimental enable copilot-app'). Note: '--target all' on 'apm compile' is deprecated; use 'apm compile --all' instead.", + help="Target harness(es) to deploy to. Comma-separated for multiple: --target claude,cursor. Repeating the flag (e.g. '-t a -t b') is NOT supported -- only the last value wins; use commas. Highest-priority entry in the resolution chain (--target > apm.yml targets: > auto-detect). Values: copilot, claude, cursor, opencode, codex, gemini, windsurf, kiro, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro (excludes agent-skills); combine with 'agent-skills' for both. 'copilot-cowork' is also accepted when the copilot-cowork experimental flag is enabled (run 'apm experimental enable copilot-cowork'). 'copilot-app' is also accepted when the copilot-app experimental flag is enabled (run 'apm experimental enable copilot-app'). Note: '--target all' on 'apm compile' is deprecated; use 'apm compile --all' instead.", ) @click.option( "--allow-insecure", @@ -958,7 +958,7 @@ def _handle_mcp_install( "global_", is_flag=True, default=False, - help="Install to user scope (~/.apm/) instead of the current project. MCP servers target global-capable runtimes only (Copilot CLI, Codex CLI, JetBrains Copilot).", + help="Install to user scope (~/.apm/) instead of the current project. MCP servers target global-capable runtimes only (Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Kiro, Windsurf, JetBrains Copilot).", ) @click.option( "--ssh", diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 497e9adc7..0bedc28ca 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -48,6 +48,7 @@ "agent-skills", "gemini", "windsurf", + "kiro", "all", "minimal", ) + _VSCODE_TARGET_ALIASES diff --git a/src/apm_cli/core/apm_yml.py b/src/apm_cli/core/apm_yml.py index 5a3aca33d..62dd89547 100644 --- a/src/apm_cli/core/apm_yml.py +++ b/src/apm_cli/core/apm_yml.py @@ -31,6 +31,7 @@ "codex", "gemini", "windsurf", + "kiro", "agent-skills", } ) diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index ca34ef0fd..0b8a5415e 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -1,7 +1,7 @@ """Target detection for auto-selecting compilation and integration targets. This module implements the auto-detection pattern for determining which agent -targets (Copilot, Claude, Cursor, OpenCode, Codex, Gemini) should be used +targets (Copilot, Claude, Cursor, OpenCode, Codex, Gemini, Kiro) should be used based on existing project structure and configuration. Detection priority (highest to lowest): @@ -58,6 +58,7 @@ def agents_alias_was_detected() -> bool: "codex", "gemini", "windsurf", + "kiro", "agent-skills", "all", "minimal", @@ -97,6 +98,7 @@ def agents_alias_was_detected() -> bool: "codex", "gemini", "windsurf", + "kiro", "agent-skills", "all", "minimal", @@ -136,6 +138,8 @@ def detect_target( # noqa: PLR0911 return "gemini", "explicit --target flag" elif explicit_target == "windsurf": return "windsurf", "explicit --target flag" + elif explicit_target == "kiro": + return "kiro", "explicit --target flag" elif explicit_target == "agent-skills": return "agent-skills", "explicit --target flag" elif explicit_target == "all": @@ -157,6 +161,8 @@ def detect_target( # noqa: PLR0911 return "gemini", "apm.yml target" elif config_target == "windsurf": return "windsurf", "apm.yml target" + elif config_target == "kiro": + return "kiro", "apm.yml target" elif config_target == "agent-skills": return "agent-skills", "apm.yml target" elif config_target == "all": @@ -170,6 +176,7 @@ def detect_target( # noqa: PLR0911 codex_exists = (project_root / ".codex").is_dir() gemini_exists = (project_root / ".gemini").is_dir() windsurf_exists = (project_root / ".windsurf").is_dir() + kiro_exists = (project_root / ".kiro").is_dir() detected = [] if github_exists: detected.append(".github/") @@ -185,6 +192,8 @@ def detect_target( # noqa: PLR0911 detected.append(".gemini/") if windsurf_exists: detected.append(".windsurf/") + if kiro_exists: + detected.append(".kiro/") if len(detected) >= 2: return "all", f"detected {' and '.join(detected)} folders" @@ -202,6 +211,8 @@ def detect_target( # noqa: PLR0911 return "gemini", "detected .gemini/ folder" elif windsurf_exists: return "windsurf", "detected .windsurf/ folder" + elif kiro_exists: + return "kiro", "detected .kiro/ folder" else: return "minimal", REASON_NO_TARGET_FOLDER @@ -227,6 +238,7 @@ def should_compile_agents_md(target: CompileTargetType) -> bool: "codex", "gemini", "windsurf", + "kiro", "hermes", "all", "minimal", @@ -337,10 +349,11 @@ def get_target_description(target: UserTargetType) -> str: "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", "gemini": "GEMINI.md + .gemini/commands/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", "windsurf": "AGENTS.md + .windsurf/rules/ + .windsurf/skills/ + .windsurf/workflows/ + .windsurf/hooks.json", + "kiro": "AGENTS.md + .kiro/steering/ + .kiro/skills/ + .kiro/hooks/ + .kiro/settings/mcp.json", "agent-skills": ".agents/skills/ only (cross-client shared skills -- no agents, hooks, or commands)", "openclaw": ".agents/skills/ (project) or ~/.openclaw/skills/ (--global) -- experimental", "hermes": "AGENTS.md + .agents/skills/ (project) or ~/.hermes/skills/ + config.yaml MCP (--global) -- experimental", - "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .windsurf/ + .agents/", + "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .windsurf/ + .kiro/ + .agents/", "minimal": "AGENTS.md only (create .github/, .claude/, or .gemini/ for full integration)", } return descriptions.get(normalized, "unknown target") @@ -353,7 +366,7 @@ def get_target_description(target: UserTargetType) -> str: #: The complete set of real (non-pseudo) canonical targets. #: "minimal" is intentionally excluded -- it is a fallback pseudo-target. ALL_CANONICAL_TARGETS = frozenset( - {"vscode", "claude", "cursor", "opencode", "codex", "gemini", "windsurf"} + {"vscode", "claude", "cursor", "opencode", "codex", "gemini", "windsurf", "kiro"} ) #: Targets that the parser must accept but that are gated at runtime by @@ -674,6 +687,7 @@ class ResolvedTargets: ("gemini", "file", "GEMINI.md"), ("opencode", "dir", ".opencode"), ("windsurf", "dir", ".windsurf"), + ("kiro", "dir", ".kiro"), ] # Ordered list of targets for display (excludes agent-skills meta-target). @@ -685,6 +699,7 @@ class ResolvedTargets: "gemini", "opencode", "windsurf", + "kiro", ] # Canonical deploy directories for each target. @@ -696,6 +711,7 @@ class ResolvedTargets: "gemini": ".gemini/", "opencode": ".opencode/", "windsurf": ".windsurf/", + "kiro": ".kiro/", } # The primary (lowest-friction) signal for each target, used in @@ -708,6 +724,7 @@ class ResolvedTargets: "gemini": "GEMINI.md", "opencode": ".opencode/", "windsurf": ".windsurf/", + "kiro": ".kiro/", } diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py index ac9924757..ad2c525aa 100644 --- a/src/apm_cli/factory.py +++ b/src/apm_cli/factory.py @@ -9,6 +9,7 @@ from .adapters.client.gemini import GeminiClientAdapter from .adapters.client.hermes import HermesClientAdapter from .adapters.client.intellij import IntelliJClientAdapter +from .adapters.client.kiro import KiroClientAdapter from .adapters.client.opencode import OpenCodeClientAdapter from .adapters.client.vscode import VSCodeClientAdapter from .adapters.client.windsurf import WindsurfClientAdapter @@ -27,6 +28,7 @@ "cursor": CursorClientAdapter, "gemini": GeminiClientAdapter, "intellij": IntelliJClientAdapter, + "kiro": KiroClientAdapter, "opencode": OpenCodeClientAdapter, "windsurf": WindsurfClientAdapter, "claude": ClaudeClientAdapter, diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 122fe4f05..eaac2744b 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -112,6 +112,24 @@ class _MergeHookConfig: "postToolUse": "AfterTool", "Stop": "SessionEnd", }, + "kiro": { + # Copilot / Claude -> Kiro camelCase events + "PreToolUse": "preToolUse", + "preToolUse": "preToolUse", + "PostToolUse": "postToolUse", + "postToolUse": "postToolUse", + "UserPromptSubmit": "promptSubmit", + "userPromptSubmit": "promptSubmit", + "promptSubmit": "promptSubmit", + "Stop": "agentStop", + "stop": "agentStop", + "AgentStop": "agentStop", + "agentStop": "agentStop", + "PreTaskExecution": "preTaskExecution", + "preTaskExecution": "preTaskExecution", + "PostTaskExecution": "postTaskExecution", + "postTaskExecution": "postTaskExecution", + }, } # Expected hook event naming convention per target. @@ -125,6 +143,7 @@ class _MergeHookConfig: "codex": "PascalCase", "gemini": "PascalCase", "windsurf": "PascalCase", + "kiro": "camelCase", } @@ -318,6 +337,7 @@ def _reinject_apm_source_from_sidecar(hooks: dict, sidecar_data: dict) -> None: "codex-hooks": {"codex"}, "gemini-hooks": {"gemini"}, "windsurf-hooks": {"windsurf"}, + "kiro-hooks": {"kiro"}, } @@ -607,6 +627,9 @@ def _rewrite_command_for_target( elif target == "windsurf": base_root = root_dir or ".windsurf" scripts_base = f"{base_root}/hooks/{package_name}" + elif target == "kiro": + base_root = root_dir or ".kiro" + scripts_base = f"{base_root}/hooks/{package_name}" else: base_root = root_dir or ".claude" scripts_base = f"{base_root}/hooks/{package_name}" @@ -615,7 +638,8 @@ def _rewrite_command_for_target( # Match both forward-slash and backslash separators (Windows hook JSON # may use backslashes: ${CLAUDE_PLUGIN_ROOT}\scripts\scan.ps1) plugin_root_pattern = ( - r"\$\{(?:CLAUDE_PLUGIN_ROOT|CURSOR_PLUGIN_ROOT|PLUGIN_ROOT)\}([\\/][^\s\"']+)" + r"\$\{(?:CLAUDE_PLUGIN_ROOT|CURSOR_PLUGIN_ROOT|KIRO_PLUGIN_ROOT|PLUGIN_ROOT)\}" + r"([\\/][^\s\"']+)" ) for match in re.finditer(plugin_root_pattern, command): full_var = match.group(0) @@ -1664,6 +1688,20 @@ def integrate_hooks_for_target( target=target, ) + if target.name == "kiro": + from apm_cli.integration.kiro_hook_integrator import integrate_kiro_hooks + + return integrate_kiro_hooks( + self, + package_info, + project_root, + force=force, + managed_files=managed_files, + diagnostics=diagnostics, + target=target, + user_scope=user_scope, + ) + config = _MERGE_HOOK_TARGETS.get(target.name) if config is not None: return self._integrate_merged_hooks( diff --git a/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index 7b80e648f..35da488e9 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -41,6 +41,7 @@ class InstructionIntegrator(BaseIntegrator): "cursor_rules": "_convert_to_cursor_rules", "claude_rules": "_convert_to_claude_rules", "windsurf_rules": "_convert_to_windsurf_rules", + "kiro_steering": "_convert_to_kiro_steering", } def find_instruction_files(self, package_path: Path) -> list[Path]: @@ -263,6 +264,9 @@ def sync_for_target( # Do not use a broad legacy glob for Claude rules to avoid # deleting user-authored .md files under .claude/rules/. legacy_pattern = None + elif mapping.format_id == "kiro_steering": + # Do not delete user-authored steering markdown under .kiro/steering/. + legacy_pattern = None else: legacy_pattern = "*.instructions.md" return self.sync_remove_files( @@ -609,6 +613,54 @@ def copy_instruction_windsurf(self, source: Path, target: Path) -> int: target.write_text(content, encoding="utf-8") return links_resolved + # ------------------------------------------------------------------ + # Kiro Steering (.md with inclusion frontmatter) + # ------------------------------------------------------------------ + + @staticmethod + def _convert_to_kiro_steering(content: str) -> str: + """Convert APM instructions to Kiro steering format. + + Kiro steering files use ``inclusion: always`` for unconditional + guidance and ``inclusion: fileMatch`` plus ``fileMatchPattern`` for + path-scoped guidance. APM's ``applyTo`` frontmatter is the source of + truth for that scoping. + """ + import yaml + + body = content + apply_to = "" + + fm_match = re.match(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", content, re.DOTALL) + if fm_match: + body = content[fm_match.end() :] + try: + fm = yaml.safe_load(fm_match.group(1)) or {} + except Exception: + fm = {} + raw_apply_to = fm.get("applyTo", "") + if isinstance(raw_apply_to, list): + apply_to = ",".join(str(item) for item in raw_apply_to) + else: + apply_to = str(raw_apply_to).strip() + + safe_apply_to = apply_to.replace("\n", " ").replace("\r", " ").strip() + globs = parse_apply_to(safe_apply_to) + + parts = ["---"] + if globs: + parts.append("inclusion: fileMatch") + if len(globs) == 1: + parts.append(f"fileMatchPattern: {yaml_double_quote(globs[0])}") + else: + parts.append("fileMatchPattern:") + parts.extend(f" - {yaml_double_quote(g)}" for g in globs) + else: + parts.append("inclusion: always") + parts.append("---") + + return "\n".join(parts) + "\n\n" + body.lstrip("\n") + # ------------------------------------------------------------------ # Claude Code Rules (.md with paths: frontmatter) # ------------------------------------------------------------------ diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py new file mode 100644 index 000000000..cb9265b55 --- /dev/null +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -0,0 +1,322 @@ +"""Kiro hook transformation helpers. + +Kiro stores each hook as its own JSON document under ``.kiro/hooks/``. +This module keeps the target-specific expansion out of ``hook_integrator.py`` +so the shared integrator stays under the source-length guardrail. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +from pathlib import Path +from typing import TYPE_CHECKING + +from apm_cli.integration.hook_integrator import ( + _HOOK_EVENT_MAP, + HookIntegrationResult, + _emit_hook_event_diagnostics, + _filter_hook_files_for_target, +) +from apm_cli.utils.atomic_io import atomic_write_text +from apm_cli.utils.path_security import ensure_path_within +from apm_cli.utils.paths import portable_relpath + +if TYPE_CHECKING: + from apm_cli.integration.hook_integrator import HookIntegrator + +_KIRO_EVENT_MAP = _HOOK_EVENT_MAP["kiro"] + + +def _safe_hook_slug(value: str, fallback: str = "hook") -> str: + """Return a stable lowercase slug for generated Kiro hook filenames.""" + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip(".-_").lower() + return safe or fallback + + +def _kiro_patterns_from_matcher(matcher: dict) -> list[str]: + """Extract Kiro file patterns from an APM hook matcher, if present.""" + patterns = matcher.get("patterns") + if isinstance(patterns, str) and patterns.strip(): + return [patterns.strip()] + if isinstance(patterns, list): + return [str(item).strip() for item in patterns if str(item).strip()] + matcher_value = matcher.get("matcher") + if isinstance(matcher_value, str) and matcher_value.strip(): + return [matcher_value.strip()] + return [] + + +def _kiro_then_from_action(action: dict, command_keys: tuple[str, ...]) -> dict | None: + """Convert one APM hook action to Kiro's ``then`` object.""" + prompt = action.get("prompt") + if action.get("type") == "askAgent" or isinstance(prompt, str): + prompt_text = prompt if isinstance(prompt, str) else action.get("command") + if isinstance(prompt_text, str) and prompt_text.strip(): + return {"type": "askAgent", "prompt": prompt_text} + return None + + for key in command_keys: + command = action.get(key) + if isinstance(command, str) and command.strip(): + return {"type": "runCommand", "command": command} + return None + + +def _kiro_actions_from_matcher(matcher: dict, command_keys: tuple[str, ...]) -> list[dict]: + """Return flat action dicts from both Copilot-flat and Claude-nested shapes.""" + actions: list[dict] = [] + if any(isinstance(matcher.get(key), str) for key in command_keys): + actions.append(matcher) + if isinstance(matcher.get("prompt"), str): + actions.append(matcher) + nested_hooks = matcher.get("hooks", []) + if isinstance(nested_hooks, list): + actions.extend(hook for hook in nested_hooks if isinstance(hook, dict)) + return actions + + +def _kiro_hook_document( + *, + name: str, + description: str | None, + event_name: str, + patterns: list[str], + then: dict, +) -> dict: + """Build one Kiro hook JSON document.""" + when: dict[str, object] = {"type": event_name} + if patterns: + when["patterns"] = patterns + doc = { + "name": name, + "version": "1.0.0", + "when": when, + "then": then, + } + if description: + doc["description"] = description + return doc + + +def _write_kiro_hook_docs( + integrator: HookIntegrator, + hook_file: Path, + rewritten: dict, + hooks_dir: Path, + project_root: Path, + package_name: str, + force: bool, + managed_files: set | None, + diagnostics, + target_paths: list[Path], + display_payloads: list, +) -> tuple[int, int, int]: + """Write Kiro hook docs from one source hook file.""" + files_integrated = 0 + files_skipped = 0 + files_adopted = 0 + hooks = rewritten.get("hooks", {}) + _emit_hook_event_diagnostics(list(hooks.keys()), "kiro", _KIRO_EVENT_MAP) + description = rewritten.get("description") + if not isinstance(description, str) or not description.strip(): + description = None + + per_event_counts: dict[str, int] = {} + for raw_event_name, matchers in hooks.items(): + if not isinstance(matchers, list): + continue + event_name = _KIRO_EVENT_MAP.get(raw_event_name, raw_event_name) + event_slug = _safe_hook_slug(event_name) + for matcher in matchers: + if not isinstance(matcher, dict): + continue + patterns = _kiro_patterns_from_matcher(matcher) + for action in _kiro_actions_from_matcher(matcher, integrator.HOOK_COMMAND_KEYS): + then = _kiro_then_from_action(action, integrator.HOOK_COMMAND_KEYS) + if then is None: + continue + per_event_counts[event_name] = per_event_counts.get(event_name, 0) + 1 + index = per_event_counts[event_name] + doc = _kiro_hook_document( + name=f"{package_name} {event_name} {index}", + description=description, + event_name=event_name, + patterns=patterns, + then=then, + ) + target_filename = ( + f"{_safe_hook_slug(package_name)}-{_safe_hook_slug(hook_file.stem)}-" + f"{event_slug}-{index}.json" + ) + target_path = hooks_dir / target_filename + ensure_path_within(target_path, hooks_dir) + rel_path = portable_relpath(target_path, project_root) + rendered = json.dumps(doc, indent=2) + "\n" + + if target_path.exists() and target_path.read_text(encoding="utf-8") == rendered: + os.chmod(target_path, 0o600) + files_adopted += 1 + target_paths.append(target_path) + continue + if integrator.check_collision( + target_path, + rel_path, + managed_files, + force, + diagnostics=diagnostics, + ): + files_skipped += 1 + continue + + atomic_write_text(target_path, rendered, new_file_mode=0o600) + # Keep existing hook files private after updates too. + os.chmod(target_path, 0o600) + files_integrated += 1 + target_paths.append(target_path) + display_payloads.append( + _display_payload( + integrator, target_filename, hook_file, event_name, then, rendered + ) + ) + return files_integrated, files_skipped, files_adopted + + +def _display_payload( + integrator: HookIntegrator, + target_filename: str, + hook_file: Path, + event_name: str, + then: dict, + rendered: str, +) -> dict: + """Build install-log metadata for one generated Kiro hook file.""" + summary = ( + integrator._summarize_command({"command": then.get("command", "")}) + if then.get("type") == "runCommand" + else "asks agent" + ) + return { + "target_label": ".kiro/hooks/", + "output_path": target_filename, + "source_hook_file": hook_file.name, + "actions": [{"event": event_name, "summary": summary}], + "rendered_json": rendered.rstrip("\n"), + } + + +def _copy_scripts( + integrator: HookIntegrator, + scripts, + project_root: Path, + managed_files, + force: bool, + diagnostics, + target_paths: list[Path], +) -> tuple[int, int]: + """Copy Kiro hook scripts and return copied/adopted counts.""" + scripts_copied = 0 + scripts_adopted = 0 + for source_file, target_rel in scripts: + target_script = project_root / target_rel + ensure_path_within(target_script, project_root) + if integrator.try_adopt_identical(target_script, source_file, target_paths): + scripts_adopted += 1 + continue + if integrator.check_collision( + target_script, + target_rel, + managed_files, + force, + diagnostics=diagnostics, + ): + continue + target_script.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_file, target_script) + scripts_copied += 1 + target_paths.append(target_script) + return scripts_copied, scripts_adopted + + +def integrate_kiro_hooks( + integrator: HookIntegrator, + package_info, + project_root: Path, + *, + force: bool = False, + managed_files: set | None = None, + diagnostics=None, + target=None, + user_scope: bool = False, +) -> HookIntegrationResult: + """Integrate hooks as one Kiro JSON file per hook action.""" + root_dir = target.root_dir if target else ".kiro" + target_dir = project_root / root_dir + if not target_dir.exists(): + return HookIntegrationResult(0, 0, 0, []) + + hook_files = integrator.find_hook_files(package_info.install_path) + hook_files = _filter_hook_files_for_target(hook_files, "kiro") + if not hook_files: + return HookIntegrationResult(0, 0, 0, []) + + hooks_dir = target_dir / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + package_name = integrator._get_package_name(package_info, project_root) + deploy_root_for_rewrite = project_root if user_scope else None + + files_integrated = 0 + files_skipped = 0 + files_adopted = 0 + scripts_copied = 0 + scripts_adopted = 0 + target_paths: list[Path] = [] + display_payloads: list = [] + + for hook_file in hook_files: + data = integrator._parse_hook_json(hook_file) + if data is None: + continue + + rewritten, scripts = integrator._rewrite_hooks_data( + data, + package_info.install_path, + package_name, + "kiro", + hook_file_dir=hook_file.parent, + root_dir=root_dir, + deploy_root=deploy_root_for_rewrite, + ) + written, skipped, adopted = _write_kiro_hook_docs( + integrator, + hook_file, + rewritten, + hooks_dir, + project_root, + package_name, + force, + managed_files, + diagnostics, + target_paths, + display_payloads, + ) + files_integrated += written + files_skipped += skipped + files_adopted += adopted + copied, adopted_scripts = _copy_scripts( + integrator, scripts, project_root, managed_files, force, diagnostics, target_paths + ) + scripts_copied += copied + scripts_adopted += adopted_scripts + + return HookIntegrationResult( + files_integrated=files_integrated, + files_updated=0, + files_skipped=files_skipped, + target_paths=target_paths, + scripts_copied=scripts_copied, + files_adopted=files_adopted + scripts_adopted, + display_payloads=display_payloads, + ) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 162fb9e07..8987a7d95 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -664,6 +664,24 @@ def remove_stale( use_rich=True, ) + if "kiro" in target_runtimes: + from apm_cli.factory import ClientFactory + + kiro_cfg = Path( + ClientFactory.create_client( + "kiro", + project_root=project_root_path, + user_scope=user_scope or scope is InstallScope.USER, + ).get_config_path() + ) + _clean_json_mcp_config( + kiro_cfg, + expanded_stale, + logger, + "Kiro MCP config", + use_rich=True, + ) + # Clean JetBrains Copilot user-scope mcp.json if "intellij" in target_runtimes: from apm_cli.adapters.client.intellij import _intellij_config_dir @@ -799,6 +817,8 @@ def _detect_runtimes(scripts: dict) -> list[str]: detected.add("llm") if re.search(r"\bwindsurf\b", command): detected.add("windsurf") + if re.search(r"\bkiro\b", command): + detected.add("kiro") return builtins.list(detected) @@ -917,10 +937,11 @@ def _install_for_runtime( logger.progress(f"Dependencies for {runtime}: {', '.join(mcp_deps)}") return False except ValueError as e: + from apm_cli.factory import ClientFactory + + supported_runtimes = ", ".join(sorted(ClientFactory.supported_clients())) logger.warning(f"Runtime {runtime} not supported: {e}") - logger.progress( - "Supported runtimes: vscode, copilot, codex, cursor, opencode, gemini, claude, windsurf, intellij, llm" - ) + logger.progress(f"Supported runtimes: {supported_runtimes}") return False except Exception as e: _log.debug("Unexpected error installing for runtime %s", runtime, exc_info=True) diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index 4678817a6..4ddb4cfe8 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -203,7 +203,7 @@ def _hermes_runtime_opted_in() -> bool: return False -def _discover_installed_runtimes(project_root_path) -> list[str]: +def _discover_installed_runtimes(project_root_path, *, user_scope: bool) -> list[str]: """Detect which MCP-capable runtimes are installed on the host. Each runtime is opt-in via a binary-on-PATH and/or directory-presence @@ -213,12 +213,13 @@ def _discover_installed_runtimes(project_root_path) -> list[str]: """ from apm_cli.integration.mcp_integrator import _is_vscode_available - # directory-signal opt-in runtimes: name -> required project dir. - _dir_signal = { + # Directory-signal opt-in runtimes: name -> required project dir. + dir_signal = { "cursor": ".cursor", "opencode": ".opencode", "gemini": ".gemini", "windsurf": ".windsurf", + "kiro": ".kiro", } try: from apm_cli.factory import ClientFactory @@ -235,28 +236,41 @@ def _discover_installed_runtimes(project_root_path) -> list[str]: "opencode", "gemini", "windsurf", + "kiro", "claude", "intellij", "hermes", ]: try: - if not _runtime_is_present(runtime_name, project_root_path, manager, _dir_signal): + if not _runtime_is_present( + runtime_name, project_root_path, manager, dir_signal, user_scope=user_scope + ): continue - ClientFactory.create_client(runtime_name) + ClientFactory.create_client( + runtime_name, + project_root=project_root_path, + user_scope=user_scope, + ) installed_runtimes.append(runtime_name) except (ValueError, ImportError): continue return installed_runtimes except ImportError: - return _discover_installed_runtimes_fallback(project_root_path, _is_vscode_available) + return _discover_installed_runtimes_fallback( + project_root_path, _is_vscode_available, user_scope=user_scope + ) -def _runtime_is_present(runtime_name, project_root_path, manager, dir_signal) -> bool: +def _runtime_is_present( + runtime_name, project_root_path, manager, dir_signal, *, user_scope: bool +) -> bool: """Return ``True`` when *runtime_name*'s opt-in presence signal fires.""" from apm_cli.integration.mcp_integrator import _is_vscode_available if runtime_name == "vscode": return _is_vscode_available(project_root=project_root_path) + if runtime_name == "kiro" and user_scope: + return True if runtime_name in dir_signal: return (project_root_path / dir_signal[runtime_name]).is_dir() if runtime_name == "claude": @@ -274,7 +288,9 @@ def _runtime_is_present(runtime_name, project_root_path, manager, dir_signal) -> return manager.is_runtime_available(runtime_name) -def _discover_installed_runtimes_fallback(project_root_path, _is_vscode_available) -> list[str]: +def _discover_installed_runtimes_fallback( + project_root_path, _is_vscode_available, *, user_scope: bool +) -> list[str]: """Binary/directory-only runtime probe used when adapters fail to import.""" installed_runtimes = [rt for rt in ["copilot", "codex"] if find_runtime_binary(rt) is not None] if _is_vscode_available(project_root=project_root_path): @@ -284,8 +300,9 @@ def _discover_installed_runtimes_fallback(project_root_path, _is_vscode_availabl ("opencode", ".opencode"), ("gemini", ".gemini"), ("windsurf", ".windsurf"), + ("kiro", ".kiro"), ): - if (project_root_path / signal).is_dir(): + if (name == "kiro" and user_scope) or (project_root_path / signal).is_dir(): installed_runtimes.append(name) # Claude Code: directory-presence OR binary-on-PATH if (project_root_path / ".claude").is_dir() or find_runtime_binary("claude") is not None: @@ -344,7 +361,7 @@ def _resolve_target_runtimes( apm_config = None # Step 1: Get all installed runtimes on the system - installed_runtimes = _discover_installed_runtimes(project_root_path) + installed_runtimes = _discover_installed_runtimes(project_root_path, user_scope=user_scope) # Step 2: Get runtimes referenced in apm.yml scripts script_runtimes = MCPIntegrator._detect_runtimes( @@ -452,7 +469,7 @@ def _resolve_target_runtimes( logger.warning(msg) if not target_runtimes: logger.warning( - "No runtimes support user-scope MCP installation (supported: copilot, codex, gemini)" + "No runtimes support user-scope MCP installation (supported: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Kiro, Windsurf, JetBrains Copilot)" ) return None diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 0d9eebc80..409e49853 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -25,7 +25,9 @@ if TYPE_CHECKING: from pathlib import Path -RULE_FORMATS: frozenset[str] = frozenset({"cursor_rules", "claude_rules", "windsurf_rules"}) +RULE_FORMATS: frozenset[str] = frozenset( + {"cursor_rules", "claude_rules", "windsurf_rules", "kiro_steering"} +) """Canonical set of format-transforming rule ``format_id``s. Single home for "which instruction formats transform their source on @@ -68,7 +70,7 @@ class PrimitiveMapping: rendered *output* rather than the source bytes. This is the single source of truth for the rule-dir formats - (``cursor_rules``, ``claude_rules``, ``windsurf_rules``). When ``True``: + (``cursor_rules``, ``claude_rules``, ``windsurf_rules``, ``kiro_steering``). When ``True``: * The deployed file is never byte-identical to its source, so a source-based adopt always misses (apm#1662). The integrator instead @@ -560,6 +562,34 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: compile_family="agents", hooks_config_display=".cursor/hooks.json", ), + # Kiro IDE -- spec-driven development editor. + # Steering files use Kiro frontmatter under .kiro/steering/. + # Skills use the open Agent Skills SKILL.md layout under .kiro/skills/. + # Hooks are individual JSON files under .kiro/hooks/. + # MCP config lives at .kiro/settings/mcp.json and ~/.kiro/settings/mcp.json. + # Kiro CLI config divergence is intentionally out of scope for this v1 target. + # Ref: https://kiro.dev/docs/steering/ + # Ref: https://kiro.dev/docs/skills/ + # Ref: https://kiro.dev/docs/hooks/ + "kiro": TargetProfile( + name="kiro", + root_dir=".kiro", + primitives={ + "instructions": PrimitiveMapping( + "steering", + ".md", + "kiro_steering", + output_compare=True, + ), + "skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"), + "hooks": PrimitiveMapping("hooks", ".json", "kiro_hooks"), + }, + auto_create=False, + detect_by_dir=True, + user_supported=True, + user_root_dir=".kiro", + compile_family="agents", + ), # OpenCode -- at user scope, ~/.config/opencode/ supports skills, agents, # and commands. OpenCode has no hooks concept, so "hooks" is excluded. "opencode": TargetProfile( diff --git a/src/apm_cli/policy/policy_checks.py b/src/apm_cli/policy/policy_checks.py index 2b9554fb3..dc566cebe 100644 --- a/src/apm_cli/policy/policy_checks.py +++ b/src/apm_cli/policy/policy_checks.py @@ -679,6 +679,7 @@ def _check_scripts_policy( ".cursor/rules", ".claude", ".opencode", + ".kiro", ] diff --git a/tests/unit/core/test_scope.py b/tests/unit/core/test_scope.py index a71e3625b..1a49cd915 100644 --- a/tests/unit/core/test_scope.py +++ b/tests/unit/core/test_scope.py @@ -165,6 +165,7 @@ def test_all_known_targets_present(self): "codex", "gemini", "windsurf", + "kiro", "copilot-cowork", "copilot-app", "agent-skills", @@ -195,6 +196,10 @@ def test_opencode_is_partially_supported(self): assert KNOWN_TARGETS["opencode"].user_root_dir == ".config/opencode" assert "hooks" in KNOWN_TARGETS["opencode"].unsupported_user_primitives + def test_kiro_is_supported_at_user_scope(self): + assert KNOWN_TARGETS["kiro"].user_supported is True + assert KNOWN_TARGETS["kiro"].user_root_dir == ".kiro" + def test_copilot_user_root_dir(self): assert KNOWN_TARGETS["copilot"].user_root_dir == ".copilot" diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index c545e542b..69d8894da 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -81,6 +81,16 @@ def test_explicit_target_all_wins(self, tmp_path): assert target == "all" assert reason == "explicit --target flag" + def test_explicit_target_kiro_wins(self, tmp_path): + """Explicit --target kiro always wins.""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target="kiro", + ) + + assert target == "kiro" + assert reason == "explicit --target flag" + def test_config_target_copilot(self, tmp_path): """Config target copilot maps to vscode.""" target, reason = detect_target( @@ -165,6 +175,19 @@ def test_auto_detect_both_folders(self, tmp_path): assert target == "all" assert ".github/" in reason and ".claude/" in reason + def test_auto_detect_kiro_only(self, tmp_path): + """Auto-detect kiro when only .kiro/ exists.""" + (tmp_path / ".kiro").mkdir() + + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + + assert target == "kiro" + assert "detected .kiro/ folder" in reason + def test_auto_detect_neither_folder(self, tmp_path): """Auto-detect minimal when neither folder exists.""" target, reason = detect_target( @@ -200,6 +223,10 @@ def test_gemini_target(self): """AGENTS.md compiled for gemini target (GEMINI.md imports it).""" assert should_compile_agents_md("gemini") is True + def test_kiro_target(self): + """AGENTS.md compiled for kiro as a cross-harness fallback.""" + assert should_compile_agents_md("kiro") is True + class TestShouldCompileClaudeMd: """Tests for should_compile_claude_md function.""" @@ -328,6 +355,14 @@ def test_opencode_description(self): assert "AGENTS.md" in desc assert ".opencode/" in desc + def test_kiro_description_includes_mcp_config_path(self): + """Description for kiro target names its MCP config path.""" + desc = get_target_description("kiro") + assert ".kiro/steering/" in desc + assert ".kiro/skills/" in desc + assert ".kiro/hooks/" in desc + assert ".kiro/settings/mcp.json" in desc + class TestDetectTargetCursor: """Tests for auto-detection and explicit cursor target.""" diff --git a/tests/unit/core/test_target_resolution_v2.py b/tests/unit/core/test_target_resolution_v2.py index 7f0cd42c0..19832638a 100644 --- a/tests/unit/core/test_target_resolution_v2.py +++ b/tests/unit/core/test_target_resolution_v2.py @@ -102,6 +102,11 @@ def test_signal_whitelist_gemini_md_is_signal(tmp_path): assert "gemini" in _signal_targets(tmp_path) +def test_signal_whitelist_kiro_dir_is_signal(tmp_path): + (tmp_path / ".kiro").mkdir() + assert "kiro" in _signal_targets(tmp_path) + + def test_signal_whitelist_cursorrules_is_signal(tmp_path): _touch(tmp_path / ".cursorrules", "# Cursor\n") assert "cursor" in _signal_targets(tmp_path) diff --git a/tests/unit/install/test_no_policy_flag.py b/tests/unit/install/test_no_policy_flag.py index a38efebe8..36d53739a 100644 --- a/tests/unit/install/test_no_policy_flag.py +++ b/tests/unit/install/test_no_policy_flag.py @@ -117,6 +117,13 @@ def test_update_help_does_not_show_no_policy(self): assert result.exit_code == 0 assert "--no-policy" not in result.output + def test_install_help_lists_kiro_runtime_and_global_scope(self): + result = self.runner.invoke(cli, ["install", "--help"]) + assert result.exit_code == 0 + normalized = " ".join(result.output.split()) + assert "gemini, intellij, kiro" in normalized + assert "Claude Code, Codex CLI, Gemini CLI, Kiro, Windsurf" in normalized + def test_help_text_is_plain_ascii(self): """Help text must be plain ASCII per cli.instructions.md.""" result = self.runner.invoke(cli, ["install", "--help"]) diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index b728aaf61..f84bea922 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -318,6 +318,7 @@ def test_partition_parity_with_old_buckets(self): "commands_windsurf", "instructions", # was instructions_copilot, aliased "instructions_windsurf", + "instructions_kiro", "rules_cursor", # was instructions_cursor, aliased "rules_claude", # was instructions_claude, aliased "skills", # cross-target bucket diff --git a/tests/unit/integration/test_kiro_target.py b/tests/unit/integration/test_kiro_target.py new file mode 100644 index 000000000..b2dcc5782 --- /dev/null +++ b/tests/unit/integration/test_kiro_target.py @@ -0,0 +1,304 @@ +"""Acceptance tests for the Kiro target profile and transforms (#702).""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from click.testing import CliRunner + +from apm_cli.cli import cli +from apm_cli.integration.hook_integrator import HookIntegrator +from apm_cli.integration.instruction_integrator import InstructionIntegrator +from apm_cli.integration.skill_integrator import SkillIntegrator +from apm_cli.integration.targets import KNOWN_TARGETS +from apm_cli.models.apm_package import ( + APMPackage, + GitReferenceType, + PackageInfo, + PackageType, + ResolvedReference, +) + + +def _make_package_info( + package_dir: Path, + name: str = "test-pkg", + package_type: PackageType | None = None, +) -> PackageInfo: + package = APMPackage( + name=name, + version="1.0.0", + package_path=package_dir, + source=f"github.com/test/{name}", + ) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main", + ) + return PackageInfo( + package=package, + install_path=package_dir, + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat(), + package_type=package_type, + ) + + +def test_kiro_is_discoverable_in_target_help() -> None: + runner = CliRunner() + + install = runner.invoke(cli, ["install", "--help"]) + compile_result = runner.invoke(cli, ["compile", "--help"]) + + expected_all_targets = "copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro" + install_help = "".join(install.output.split()) + compile_help = "".join(compile_result.output.split()) + + assert install.exit_code == 0 + assert compile_result.exit_code == 0 + assert expected_all_targets in install_help + assert expected_all_targets in compile_help + assert "ClaudeCode" in install_help + assert "Windsurf" in install_help + + +def test_kiro_runtime_discovered_in_user_scope_without_project_dir(tmp_path: Path) -> None: + from apm_cli.integration.mcp_integrator_install import _discover_installed_runtimes + + assert not (tmp_path / ".kiro").exists() + + runtimes = _discover_installed_runtimes(tmp_path, user_scope=True) + + assert "kiro" in runtimes + + +def test_kiro_target_profile_matches_ratified_layout() -> None: + target = KNOWN_TARGETS["kiro"] + + assert target.root_dir == ".kiro" + assert target.auto_create is False + assert target.detect_by_dir is True + assert target.user_supported is True + assert target.user_root_dir == ".kiro" + assert set(target.primitives) == {"instructions", "skills", "hooks"} + + instructions = target.primitives["instructions"] + assert instructions.subdir == "steering" + assert instructions.extension == ".md" + assert instructions.format_id == "kiro_steering" + assert instructions.output_compare is True + + skills = target.primitives["skills"] + assert skills.subdir == "skills" + assert skills.extension == "/SKILL.md" + assert skills.format_id == "skill_standard" + + hooks = target.primitives["hooks"] + assert hooks.subdir == "hooks" + assert hooks.extension == ".json" + assert hooks.format_id == "kiro_hooks" + + +def test_kiro_steering_maps_apply_to_to_file_match(tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + package_dir = tmp_path / "pkg" + instructions_dir = package_dir / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + (instructions_dir / "python.instructions.md").write_text( + "---\n" + "description: Python rules\n" + 'applyTo: "src/**/*.py,tests/**/*.py"\n' + "---\n\n" + "# Python\n\nUse type hints.\n", + encoding="utf-8", + ) + + result = InstructionIntegrator().integrate_instructions_for_target( + KNOWN_TARGETS["kiro"], + _make_package_info(package_dir), + tmp_path, + ) + + assert result.files_integrated == 1 + target = tmp_path / ".kiro" / "steering" / "python.md" + assert target.exists() + assert target.read_text(encoding="utf-8") == ( + "---\n" + "inclusion: fileMatch\n" + "fileMatchPattern:\n" + ' - "src/**/*.py"\n' + ' - "tests/**/*.py"\n' + "---\n\n" + "# Python\n\nUse type hints.\n" + ) + + +def test_kiro_steering_defaults_unscoped_instructions_to_always(tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + package_dir = tmp_path / "pkg" + instructions_dir = package_dir / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + (instructions_dir / "global.instructions.md").write_text( + "# Global\n\nUse this everywhere.\n", + encoding="utf-8", + ) + + result = InstructionIntegrator().integrate_instructions_for_target( + KNOWN_TARGETS["kiro"], + _make_package_info(package_dir), + tmp_path, + ) + + assert result.files_integrated == 1 + target = tmp_path / ".kiro" / "steering" / "global.md" + assert target.read_text(encoding="utf-8") == ( + "---\ninclusion: always\n---\n\n# Global\n\nUse this everywhere.\n" + ) + + +def test_kiro_skills_deploy_skill_md_to_kiro_skills_dir(tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + package_dir = tmp_path / "skill-pkg" + package_dir.mkdir() + (package_dir / "SKILL.md").write_text( + "---\nname: skill-pkg\ndescription: Demo skill\n---\n\n# Demo\n", + encoding="utf-8", + ) + + result = SkillIntegrator().integrate_package_skill( + _make_package_info(package_dir, "skill-pkg", PackageType.CLAUDE_SKILL), + tmp_path, + targets=[KNOWN_TARGETS["kiro"]], + ) + + target = tmp_path / ".kiro" / "skills" / "skill-pkg" / "SKILL.md" + assert result.skill_created is True + assert target.read_text(encoding="utf-8") == ( + "---\nname: skill-pkg\ndescription: Demo skill\n---\n\n# Demo\n" + ) + + +def test_kiro_hooks_expand_each_apm_hook_to_individual_json(tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + package_dir = tmp_path / "hookify" + hooks_dir = package_dir / "hooks" + hooks_dir.mkdir(parents=True) + hook_data = { + "description": "Validate before tool use", + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python ${PLUGIN_ROOT}/hooks/check.py", + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python ${PLUGIN_ROOT}/hooks/prompt.py", + } + ] + } + ], + }, + } + (hooks_dir / "hooks.json").write_text(json.dumps(hook_data), encoding="utf-8") + (hooks_dir / "check.py").write_text("# check\n", encoding="utf-8") + (hooks_dir / "prompt.py").write_text("# prompt\n", encoding="utf-8") + + result = HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["kiro"], + _make_package_info(package_dir, "hookify"), + tmp_path, + ) + + assert result.files_integrated == 2 + assert result.scripts_copied == 2 + + pre_tool = tmp_path / ".kiro" / "hooks" / "hookify-hooks-pretooluse-1.json" + prompt_submit = tmp_path / ".kiro" / "hooks" / "hookify-hooks-promptsubmit-1.json" + assert pre_tool.exists() + assert prompt_submit.exists() + + pre_data = json.loads(pre_tool.read_text(encoding="utf-8")) + assert pre_data["when"] == {"type": "preToolUse"} + assert pre_data["then"]["type"] == "runCommand" + assert pre_data["then"]["command"] == "python .kiro/hooks/hookify/hooks/check.py" + assert pre_data["description"] == "Validate before tool use" + assert "hooks" not in pre_data + assert pre_tool.stat().st_mode & 0o777 == 0o600 + + prompt_data = json.loads(prompt_submit.read_text(encoding="utf-8")) + assert prompt_data["when"] == {"type": "promptSubmit"} + assert prompt_data["then"]["command"] == "python .kiro/hooks/hookify/hooks/prompt.py" + assert prompt_submit.stat().st_mode & 0o777 == 0o600 + + assert (tmp_path / ".kiro" / "hooks" / "hookify" / "hooks" / "check.py").exists() + assert (tmp_path / ".kiro" / "hooks" / "hookify" / "hooks" / "prompt.py").exists() + + +def test_kiro_hooks_convert_prompt_actions_to_ask_agent(tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + package_dir = tmp_path / "prompt-hooks" + hooks_dir = package_dir / "hooks" + hooks_dir.mkdir(parents=True) + hook_data = { + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "askAgent", + "prompt": "Review the submitted prompt for policy drift.", + } + ] + } + ] + } + } + (hooks_dir / "hooks.json").write_text(json.dumps(hook_data), encoding="utf-8") + + result = HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["kiro"], + _make_package_info(package_dir, "prompt-hooks"), + tmp_path, + ) + + target = tmp_path / ".kiro" / "hooks" / "prompt-hooks-hooks-promptsubmit-1.json" + assert result.files_integrated == 1 + data = json.loads(target.read_text(encoding="utf-8")) + assert data["when"] == {"type": "promptSubmit"} + assert data["then"] == { + "type": "askAgent", + "prompt": "Review the submitted prompt for policy drift.", + } + assert target.stat().st_mode & 0o777 == 0o600 + + +def test_kiro_hooks_skip_when_project_has_no_kiro_dir(tmp_path: Path) -> None: + package_dir = tmp_path / "hookify" + hooks_dir = package_dir / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "hooks.json").write_text( + json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": "echo hi"}]}]}}), + encoding="utf-8", + ) + + result = HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["kiro"], + _make_package_info(package_dir, "hookify"), + tmp_path, + ) + + assert result.files_integrated == 0 + assert not (tmp_path / ".kiro").exists() diff --git a/tests/unit/integration/test_targets_registry_completeness.py b/tests/unit/integration/test_targets_registry_completeness.py index 472f124f2..86a504a02 100644 --- a/tests/unit/integration/test_targets_registry_completeness.py +++ b/tests/unit/integration/test_targets_registry_completeness.py @@ -23,6 +23,7 @@ from apm_cli.adapters.client.gemini import GeminiClientAdapter from apm_cli.adapters.client.hermes import HermesClientAdapter from apm_cli.adapters.client.intellij import IntelliJClientAdapter +from apm_cli.adapters.client.kiro import KiroClientAdapter from apm_cli.adapters.client.opencode import OpenCodeClientAdapter from apm_cli.adapters.client.vscode import VSCodeClientAdapter from apm_cli.adapters.client.windsurf import WindsurfClientAdapter @@ -55,6 +56,7 @@ CodexClientAdapter, GeminiClientAdapter, IntelliJClientAdapter, + KiroClientAdapter, OpenCodeClientAdapter, VSCodeClientAdapter, WindsurfClientAdapter, diff --git a/tests/unit/test_kiro_mcp.py b/tests/unit/test_kiro_mcp.py new file mode 100644 index 000000000..bee005a4c --- /dev/null +++ b/tests/unit/test_kiro_mcp.py @@ -0,0 +1,221 @@ +"""Acceptance tests for Kiro MCP adapter support (#702).""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from apm_cli.adapters.client.kiro import KiroClientAdapter +from apm_cli.factory import ClientFactory + + +class TestKiroClientFactory: + """Verify KiroClientAdapter registration.""" + + def test_factory_creates_kiro_adapter(self) -> None: + adapter = ClientFactory.create_client("kiro") + assert isinstance(adapter, KiroClientAdapter) + + def test_factory_accepts_case_insensitive_name(self) -> None: + adapter = ClientFactory.create_client("Kiro") + assert isinstance(adapter, KiroClientAdapter) + + +class TestKiroClientAdapter: + """Core config operations for KiroClientAdapter.""" + + def test_project_config_path_uses_kiro_settings(self, tmp_path: Path) -> None: + adapter = KiroClientAdapter(project_root=tmp_path) + assert adapter.get_config_path() == str(tmp_path / ".kiro" / "settings" / "mcp.json") + + def test_user_scope_config_path_uses_home_kiro_settings(self, tmp_path: Path) -> None: + with patch("pathlib.Path.home", return_value=tmp_path): + adapter = KiroClientAdapter(user_scope=True) + assert adapter.get_config_path() == str(tmp_path / ".kiro" / "settings" / "mcp.json") + + def test_update_config_skips_project_without_kiro_dir(self, tmp_path: Path) -> None: + adapter = KiroClientAdapter(project_root=tmp_path) + adapter.update_config({"srv": {"command": "node"}}) + assert not (tmp_path / ".kiro" / "settings" / "mcp.json").exists() + + def test_update_config_creates_settings_inside_existing_kiro_dir(self, tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + adapter = KiroClientAdapter(project_root=tmp_path) + + adapter.update_config({"srv": {"command": "node", "args": ["server.js"]}}) + + mcp_json = tmp_path / ".kiro" / "settings" / "mcp.json" + data = json.loads(mcp_json.read_text(encoding="utf-8")) + assert data["mcpServers"]["srv"]["command"] == "node" + assert data["mcpServers"]["srv"]["args"] == ["server.js"] + assert mcp_json.stat().st_mode & 0o777 == 0o600 + + def test_user_scope_writes_without_existing_kiro_dir(self, tmp_path: Path) -> None: + with patch("pathlib.Path.home", return_value=tmp_path): + adapter = KiroClientAdapter(user_scope=True) + adapter.update_config({"srv": {"command": "node"}}) + + mcp_json = tmp_path / ".kiro" / "settings" / "mcp.json" + data = json.loads(mcp_json.read_text(encoding="utf-8")) + assert data["mcpServers"]["srv"]["command"] == "node" + assert mcp_json.stat().st_mode & 0o777 == 0o600 + + def test_remote_config_uses_kiro_url_headers_and_tool_extensions(self) -> None: + adapter = KiroClientAdapter() + server_info = { + "id": "registry-id-is-not-kiro-config", + "name": "remote-server", + "remotes": [ + { + "transport_type": "http", + "url": "https://mcp.example.com/server", + "headers": [{"name": "Authorization", "value": "Bearer ${KIRO_TOKEN}"}], + } + ], + "autoApprove": ["search"], + "disabledTools": ["delete"], + } + + with patch.dict("os.environ", {"KIRO_TOKEN": "literal-secret"}, clear=False): + config = adapter._format_server_config(server_info) + + assert config == { + "url": "https://mcp.example.com/server", + "headers": {"Authorization": "Bearer ${KIRO_TOKEN}"}, + "autoApprove": ["search"], + "disabledTools": ["delete"], + } + assert "literal-secret" not in json.dumps(config) + assert "type" not in config + assert "tools" not in config + assert "id" not in config + + def test_remote_header_values_are_string_coerced(self) -> None: + adapter = KiroClientAdapter() + server_info = { + "name": "remote-server", + "remotes": [ + { + "transport_type": "http", + "url": "https://mcp.example.com/server", + "headers": [{"name": "X-Retry", "value": 3}], + } + ], + } + + config = adapter._format_server_config(server_info) + + assert config["headers"] == {"X-Retry": "3"} + + def test_remote_unsupported_transport_raises_for_kiro(self) -> None: + adapter = KiroClientAdapter() + server_info = { + "name": "remote-server", + "remotes": [ + { + "transport_type": "websocket", + "url": "https://mcp.example.com/server", + } + ], + } + + try: + adapter._format_server_config(server_info) + except ValueError as exc: + message = str(exc) + else: + raise AssertionError("Expected ValueError for unsupported Kiro transport") + + assert "Unsupported remote transport" in message + assert "websocket" in message + assert "Kiro" in message + + def test_stdio_env_literals_are_written_as_runtime_placeholders(self) -> None: + adapter = KiroClientAdapter() + server_info = { + "name": "stdio-server", + "_raw_stdio": { + "command": "node", + "args": ["server.js", "--token", "${KIRO_TOKEN}"], + "env": {"KIRO_TOKEN": "literal-secret"}, + }, + } + + with patch.dict("os.environ", {"KIRO_TOKEN": "ignored-os-secret"}, clear=False): + config = adapter._format_server_config(server_info) + + assert config["command"] == "node" + assert config["args"] == ["server.js", "--token", "${KIRO_TOKEN}"] + assert config["env"] == {"KIRO_TOKEN": "${KIRO_TOKEN}"} + assert "literal-secret" not in json.dumps(config) + assert "ignored-os-secret" not in json.dumps(config) + + def test_configure_mcp_server_writes_project_config(self, tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + adapter = KiroClientAdapter(project_root=tmp_path) + adapter.registry_client = MagicMock() + adapter.registry_client.find_server_by_reference.return_value = { + "packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}] + } + + assert adapter.configure_mcp_server("scope/server", server_name="srv") is True + + data = json.loads((tmp_path / ".kiro" / "settings" / "mcp.json").read_text()) + assert data["mcpServers"]["srv"]["command"] == "npx" + + def test_configure_mcp_server_error_names_config_key(self, tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + adapter = KiroClientAdapter(project_root=tmp_path) + adapter.registry_client = MagicMock() + adapter.registry_client.find_server_by_reference.return_value = { + "name": "broken-server", + "remotes": [{"transport_type": "websocket", "url": "https://mcp.example.com"}], + } + + with patch("apm_cli.adapters.client.kiro._rich_error") as rich_error: + assert adapter.configure_mcp_server("scope/server", server_name="srv") is False + + rich_error.assert_called_once_with( + "Failed to configure MCP server 'srv' for Kiro", + symbol="error", + ) + + def test_configure_mcp_server_sets_disabled_when_enabled_false(self, tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + adapter = KiroClientAdapter(project_root=tmp_path) + adapter.registry_client = MagicMock() + adapter.registry_client.find_server_by_reference.return_value = { + "packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}] + } + + assert ( + adapter.configure_mcp_server("scope/server", server_name="srv", enabled=False) is True + ) + + data = json.loads((tmp_path / ".kiro" / "settings" / "mcp.json").read_text()) + assert data["mcpServers"]["srv"]["disabled"] is True + + def test_remove_stale_kiro_project_config(self, tmp_path: Path) -> None: + from apm_cli.integration.mcp_integrator import MCPIntegrator + + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + mcp_json = settings_dir / "mcp.json" + mcp_json.write_text( + json.dumps( + { + "mcpServers": { + "keep": {"command": "node"}, + "stale": {"command": "python"}, + } + } + ), + encoding="utf-8", + ) + + MCPIntegrator.remove_stale({"stale"}, runtime="kiro", project_root=tmp_path) + + data = json.loads(mcp_json.read_text(encoding="utf-8")) + assert "keep" in data["mcpServers"] + assert "stale" not in data["mcpServers"]