Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
88c9c13
fix(integrations): dispatch kiro-cli through chat --no-interactive
kartsan03 Sep 30, 2026
86afed7
fix(integrations): install kiro-cli prompts with hyphenated names
kartsan03 Sep 30, 2026
cb898ce
test(integrations): restore the kiro prompt as bytes in the upgrade test
kartsan03 Sep 30, 2026
270f740
fix(integrations): remove dotted kiro extension prompts on upgrade
kartsan03 Sep 30, 2026
569a1bd
fix(integrations): only treat real renames as a Kiro filename migration
kartsan03 Sep 30, 2026
268a908
fix(integrations): keep disabled preset prompts and SKILL.md paths ou…
kartsan03 Sep 30, 2026
cc91560
fix(integrations): retire dotted Kiro prompts only after their replac…
kartsan03 Sep 30, 2026
f53b21f
fix(integrations): refuse the Kiro prompt rename while presets are in…
kartsan03 Sep 30, 2026
ec15335
fix(integrations): stop treating the Kiro IDE launcher as Kiro CLI
kartsan03 Oct 1, 2026
ed42004
test(workflows): pin the zip entry time in catalog release archives
kartsan03 Oct 1, 2026
a5334ea
Merge branch 'main' into fix/kiro-cli-dispatch
kartsan03 Oct 2, 2026
234bbd2
fix(integrations): retire dotted Kiro extension prompts on every regi…
kartsan03 Oct 2, 2026
3bcee7e
fix(integrations): keep Kiro prompts tracked when a command source is…
kartsan03 Oct 3, 2026
62732e8
fix(integrations): keep Kiro prompts that hyphenate to one file
kartsan03 Oct 5, 2026
7034726
Merge branch 'main' into fix/kiro-cli-dispatch
kartsan03 Oct 6, 2026
8570f0a
fix(integrations): skip Kiro prompts that share a file instead of rew…
kartsan03 Oct 6, 2026
fdb8d75
fix(extensions): count unreadable owners at install, remove skipped Q…
kartsan03 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Installs prompts as `.kiro/prompts/speckit-<command>.md` and invokes them as `/speckit-<command>`, because Kiro CLI does not run slash commands whose names contain dots. Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [MiniMax Code](https://github.com/MiniMax-AI/minimax-code) | `mcode` | Skills-based integration; installs skills into `.minimax/skills` and invokes them as `/speckit-<command>`. Headless workflow dispatch uses `--permission full` so tool actions are not blocked by headless permission gating; set `SPECKIT_INTEGRATION_MCODE_EXTRA_ARGS="--permission smart"` to use MiniMax Code's stricter policy. |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
Expand Down
160 changes: 159 additions & 1 deletion src/specify_cli/integrations/kiro_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
"""Kiro CLI integration."""

from ..base import MarkdownIntegration
from __future__ import annotations

import re
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any

from ..base import _HOOK_COMMAND_NOTE, MarkdownIntegration
from ..manifest import IntegrationManifest


# Kiro CLI file-based prompts do NOT support any argument-substitution syntax,
Expand All @@ -11,6 +19,31 @@
_KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"


def format_kiro_command_name(cmd_name: str) -> str:
"""Convert a command name to the hyphenated form Kiro CLI can invoke.

Kiro CLI runs ``/name`` from ``.kiro/prompts/name.md`` only when the name
has no dots: ``/speckit.plan`` is rejected as an unrecognized slash
command, while ``/speckit-plan`` runs the prompt (issue #4797).

The function is idempotent: already-formatted names are returned unchanged.

Examples:
>>> format_kiro_command_name("plan")
'speckit-plan'
>>> format_kiro_command_name("speckit.plan")
'speckit-plan'
>>> format_kiro_command_name("speckit.git.commit")
'speckit-git-commit'
"""
cmd_name = cmd_name.replace(".", "-")

if not cmd_name.startswith("speckit-"):
cmd_name = f"speckit-{cmd_name}"
Comment thread
kartsan03 marked this conversation as resolved.

return cmd_name


class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
# Kiro CLI keeps everything under a static, isolated agent root
Expand All @@ -33,4 +66,129 @@ class KiroCliIntegration(MarkdownIntegration):
"format": "markdown",
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
"format_name": format_kiro_command_name,
"invoke_separator": "-",
Comment thread
Copilot marked this conversation as resolved.
}
invoke_separator = "-"

def command_filename(self, template_name: str) -> str:
return format_kiro_command_name(template_name) + ".md"

def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Kiro CLI installs hyphenated prompts (``/speckit-<name>``), so the
dispatch invocation must match; the inherited dotted ``/speckit.<name>``
is not a slash command Kiro recognizes.
"""
invocation = "/" + format_kiro_command_name(command_name)
if args:
invocation = f"{invocation} {args}"
return invocation

def process_template(self, *args, **kwargs):
"""Ensure shared templates render Kiro command references with hyphens."""
kwargs.setdefault("invoke_separator", self.invoke_separator)
return super().process_template(*args, **kwargs)

@staticmethod
def _inject_hook_command_note(content: str) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.

Hook command names come from ``extensions.yml`` in dot notation, so
the model needs the mapping to the hyphenated prompt names. Skips
instructions that already have the note immediately above them (the
per-instruction check from the Cline fix in #4150).
"""
note = _HOOK_COMMAND_NOTE.rstrip("\n")

def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
previous_lines = content[:m.start()].splitlines()
if previous_lines and previous_lines[-1] == indent + note:
return m.group(0)
# ``eol`` is empty when the instruction is the final line of a
# file with no trailing newline.
eol = m.group(3) or "\n"
return indent + note + eol + indent + instruction + eol

return re.sub(
r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)

@staticmethod
def _rewrite_handoff_references(content: str) -> str:
"""Replace dot-notation agent references in handoffs with hyphens."""
return re.sub(
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
lambda m: f"{m.group(1)}{format_kiro_command_name(m.group(2))}",
content,
)

def post_process_command_content(self, content: str) -> str:
"""Apply the hook note and handoff rewrite to Kiro prompt content.

``CommandRegistrar.register_commands()`` calls this hook too, so
extension and preset prompts get the same transforms as core ones.
"""
return self._rewrite_handoff_references(self._inject_hook_command_note(content))

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Kiro prompts and apply post-processing transformations."""
created = super().setup(project_root, manifest, parsed_options, **opts)

dest_dir = self.commands_dest(project_root).resolve()
for path in created:
try:
path.resolve().relative_to(dest_dir)
except ValueError:
continue
if path.suffix != ".md":
continue

content = path.read_bytes().decode("utf-8")
updated = self.post_process_command_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)

return created

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
project_root: Path | None = None,
) -> list[str] | None:
"""Build CLI arguments for headless ``kiro-cli chat`` execution.

The inherited ``kiro-cli -p <prompt>`` exits 2 at argument parsing
(``unexpected argument '-p'``). Kiro CLI runs one prompt headless
through ``chat --no-interactive`` with the prompt as its positional
input, and a ``/speckit-*`` input there runs the matching
``.kiro/prompts`` file. Headless mode cannot ask for tool approval, so
without ``--trust-all-tools`` every file write is denied while the run
still exits 0 (same role as Copilot's ``--yolo`` / Cursor's
``--force``). Kiro has no ``json`` output format; its structured output
is ``--output-format stream-json`` (JSON Lines).
"""
self.validate_runtime_config(integration_args, integration_options)
args = [self._resolve_executable(), "chat", "--no-interactive", "--trust-all-tools"]
self._apply_extra_args_env_var(args)
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "stream-json"])
args.append(prompt)
return args
177 changes: 175 additions & 2 deletions tests/integrations/test_integration_kiro_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import os
import re

import pytest

from specify_cli.integrations import get_integration
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK, format_kiro_command_name
from specify_cli.integrations.manifest import IntegrationManifest

from .test_integration_base_markdown import MarkdownIntegrationTests
Expand Down Expand Up @@ -45,6 +47,130 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
def test_declares_multi_install_safe(self):
assert get_integration(self.KEY).multi_install_safe is True

@pytest.mark.parametrize(
"cmd_name, expected",
[
("plan", "speckit-plan"),
("speckit.plan", "speckit-plan"),
("speckit.git.commit", "speckit-git-commit"),
("speckit-plan", "speckit-plan"),
],
)
def test_format_kiro_command_name(self, cmd_name, expected):
assert format_kiro_command_name(cmd_name) == expected

def test_prompts_and_invocations_are_hyphenated(self):
"""Kiro CLI rejects ``/speckit.plan`` as an unrecognized slash command
but runs ``/speckit-plan`` from ``.kiro/prompts/speckit-plan.md``."""
i = get_integration(self.KEY)
assert i.command_filename("plan") == "speckit-plan.md"
assert i.build_command_invocation("plan") == "/speckit-plan"
assert (
i.build_command_invocation("speckit.git.commit", "now")
== "/speckit-git-commit now"
)
assert i.invoke_separator == "-"
assert i.registrar_config["invoke_separator"] == "-"
# Extension and preset prompts get the same names via the registrar.
assert i.registrar_config["format_name"] is format_kiro_command_name

def test_command_step_dispatches_hyphenated_prompt(self, tmp_path):
"""A workflow ``command: speckit.constitution`` step reaches Kiro CLI
as ``/speckit-constitution`` through ``chat --no-interactive``."""
from unittest.mock import MagicMock, patch

from specify_cli.workflows.base import StepContext, StepStatus
from specify_cli.workflows.step.command import CommandStep

ctx = StepContext(
inputs={}, default_integration=self.KEY, project_root=str(tmp_path)
)
config = {"id": "constitution", "command": "speckit.constitution"}
done = MagicMock(returncode=0, stdout="", stderr="")

with patch("specify_cli.workflows.step.command.shutil.which", return_value="/usr/local/bin/kiro-cli"), \
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/kiro-cli"), \
patch("subprocess.run", return_value=done) as mock_run:
result = CommandStep().execute(config, ctx)

assert result.status == StepStatus.COMPLETED
assert mock_run.call_args[0][0] == [
"/usr/local/bin/kiro-cli",
"chat",
"--no-interactive",
"--trust-all-tools",
"/speckit-constitution",
]

def test_post_process_adds_hook_note_and_rewrites_handoffs(self):
i = get_integration(self.KEY)
content = (
"---\nhandoffs:\n - label: Plan\n agent: speckit.plan\n---\n"
"When normalizing table names, replace dots with underscores.\n"
"- For each executable hook, output the following:\n"
)
processed = i.post_process_command_content(content)
assert " agent: speckit-plan\n" in processed
assert "`/speckit-git-commit`" in processed
assert i.post_process_command_content(processed) == processed

def test_build_exec_args_uses_headless_chat(self):
"""``kiro-cli -p`` exits 2 (``unexpected argument '-p'``).

Kiro CLI runs one prompt through ``chat --no-interactive``, and in
that mode it denies every tool call unless ``--trust-all-tools`` is
set, while still exiting 0.
"""
args = get_integration(self.KEY).build_exec_args(
"/speckit-plan", output_json=False
)

assert args == [
"kiro-cli",
"chat",
"--no-interactive",
"--trust-all-tools",
"/speckit-plan",
]
assert "-p" not in args

def test_build_exec_args_forwards_model_and_stream_json(self):
"""Kiro has no ``json`` output format; structured output is ``stream-json``."""
args = get_integration(self.KEY).build_exec_args(
"/speckit-plan", model="claude-sonnet-4.5", output_json=True
)

assert args == [
"kiro-cli",
"chat",
"--no-interactive",
"--trust-all-tools",
"--model",
"claude-sonnet-4.5",
"--output-format",
"stream-json",
"/speckit-plan",
]

def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch):
"""Operator-injected flags reach ``chat`` ahead of the positional prompt."""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--agent reviewer"
)
args = get_integration(self.KEY).build_exec_args(
"/speckit-plan", output_json=False
)

assert args == [
"kiro-cli",
"chat",
"--no-interactive",
"--trust-all-tools",
"--agent",
"reviewer",
"/speckit-plan",
]

def test_registrar_config(self):
"""Override base assertion: kiro-cli uses a prose fallback for args
because Kiro CLI file-based prompts do not natively substitute
Expand Down Expand Up @@ -124,6 +250,53 @@ def test_rendered_prompts_contain_kiro_arg_placeholder(self, tmp_path):
f"({expected!r})"
)

# -- Overrides for MarkdownIntegrationTests (hyphenated prompt names) --

def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
created = i.setup(tmp_path, IntegrationManifest(self.KEY, tmp_path))
cmd_files = [f for f in created if "scripts" not in f.parts]
assert cmd_files
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit-")
assert f.name.endswith(".md")

specify = (tmp_path / self.REGISTRAR_DIR / "speckit-specify.md").read_text(
encoding="utf-8"
)
assert "/speckit-plan" in specify
assert "/speckit.plan" not in specify

def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app

project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
cmd_dir = get_integration(self.KEY).commands_dest(project)
assert sorted(cmd_dir.glob("speckit-*.md"))
assert not sorted(cmd_dir.glob("speckit.*"))

def _expected_files(self, script_variant: str) -> list[str]:
# Kiro prompts are hyphenated, so remap the base mixin's
# ``speckit.<stem>.md`` expectations under the prompts dir.
prefix = f"{self.REGISTRAR_DIR}/speckit."
return sorted(
f"{self.REGISTRAR_DIR}/speckit-{f[len(prefix):]}" if f.startswith(prefix) else f
for f in super()._expected_files(script_variant)
)


class TestKiroIntegration:
"""--integration kiro-cli creates expected files."""
Expand All @@ -148,4 +321,4 @@ def test_integration_kiro_cli_creates_files(self, tmp_path):
os.chdir(old_cwd)

assert result.exit_code == 0
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
assert (target / ".kiro" / "prompts" / "speckit-plan.md").exists()
Loading
Loading