Skip to content

Commit 86afed7

Browse files
committed
fix(integrations): install kiro-cli prompts with hyphenated names
Kiro CLI runs /name from .kiro/prompts/name.md only when the name has no dots, so the installed /speckit.plan is rejected as an unrecognized slash command. Install speckit-<command>.md and dispatch /speckit-<command>, following the Junie and Cline integrations, so workflow steps run the prompt instead of relying on the model to find the file. Upgrade stale-removes the old dotted prompts. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
1 parent 88c9c13 commit 86afed7

4 files changed

Lines changed: 276 additions & 11 deletions

File tree

‎docs/reference/integrations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
3131
| [Junie](https://junie.jetbrains.com/) | `junie` | |
3232
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
3333
| [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 |
34-
| [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` |
34+
| [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` |
3535
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
3636
| [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. |
3737
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |

‎src/specify_cli/integrations/kiro_cli/__init__.py‎

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from __future__ import annotations
44

5+
import re
56
from collections.abc import Mapping, Sequence
67
from pathlib import Path
78
from typing import Any
89

9-
from ..base import MarkdownIntegration
10+
from ..base import _HOOK_COMMAND_NOTE, MarkdownIntegration
11+
from ..manifest import IntegrationManifest
1012

1113

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

1921

22+
def format_kiro_command_name(cmd_name: str) -> str:
23+
"""Convert a command name to the hyphenated form Kiro CLI can invoke.
24+
25+
Kiro CLI runs ``/name`` from ``.kiro/prompts/name.md`` only when the name
26+
has no dots: ``/speckit.plan`` is rejected as an unrecognized slash
27+
command, while ``/speckit-plan`` runs the prompt (issue #4797).
28+
29+
The function is idempotent: already-formatted names are returned unchanged.
30+
31+
Examples:
32+
>>> format_kiro_command_name("plan")
33+
'speckit-plan'
34+
>>> format_kiro_command_name("speckit.plan")
35+
'speckit-plan'
36+
>>> format_kiro_command_name("speckit.git.commit")
37+
'speckit-git-commit'
38+
"""
39+
cmd_name = cmd_name.replace(".", "-")
40+
41+
if not cmd_name.startswith("speckit-"):
42+
cmd_name = f"speckit-{cmd_name}"
43+
44+
return cmd_name
45+
46+
2047
class KiroCliIntegration(MarkdownIntegration):
2148
key = "kiro-cli"
2249
# Kiro CLI keeps everything under a static, isolated agent root
@@ -39,7 +66,100 @@ class KiroCliIntegration(MarkdownIntegration):
3966
"format": "markdown",
4067
"args": _KIRO_ARG_FALLBACK,
4168
"extension": ".md",
69+
"format_name": format_kiro_command_name,
70+
"invoke_separator": "-",
4271
}
72+
invoke_separator = "-"
73+
74+
def command_filename(self, template_name: str) -> str:
75+
return format_kiro_command_name(template_name) + ".md"
76+
77+
def build_command_invocation(self, command_name: str, args: str = "") -> str:
78+
"""Kiro CLI installs hyphenated prompts (``/speckit-<name>``), so the
79+
dispatch invocation must match; the inherited dotted ``/speckit.<name>``
80+
is not a slash command Kiro recognizes.
81+
"""
82+
invocation = "/" + format_kiro_command_name(command_name)
83+
if args:
84+
invocation = f"{invocation} {args}"
85+
return invocation
86+
87+
def process_template(self, *args, **kwargs):
88+
"""Ensure shared templates render Kiro command references with hyphens."""
89+
kwargs.setdefault("invoke_separator", self.invoke_separator)
90+
return super().process_template(*args, **kwargs)
91+
92+
@staticmethod
93+
def _inject_hook_command_note(content: str) -> str:
94+
"""Insert a dot-to-hyphen note before each hook output instruction.
95+
96+
Hook command names come from ``extensions.yml`` in dot notation, so
97+
the model needs the mapping to the hyphenated prompt names. Skips
98+
instructions that already have the note immediately above them (the
99+
per-instruction check from the Cline fix in #4150).
100+
"""
101+
note = _HOOK_COMMAND_NOTE.rstrip("\n")
102+
103+
def repl(m: re.Match[str]) -> str:
104+
indent = m.group(1)
105+
instruction = m.group(2)
106+
previous_lines = content[:m.start()].splitlines()
107+
if previous_lines and previous_lines[-1] == indent + note:
108+
return m.group(0)
109+
# ``eol`` is empty when the instruction is the final line of a
110+
# file with no trailing newline.
111+
eol = m.group(3) or "\n"
112+
return indent + note + eol + indent + instruction + eol
113+
114+
return re.sub(
115+
r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
116+
repl,
117+
content,
118+
)
119+
120+
@staticmethod
121+
def _rewrite_handoff_references(content: str) -> str:
122+
"""Replace dot-notation agent references in handoffs with hyphens."""
123+
return re.sub(
124+
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
125+
lambda m: f"{m.group(1)}{format_kiro_command_name(m.group(2))}",
126+
content,
127+
)
128+
129+
def post_process_command_content(self, content: str) -> str:
130+
"""Apply the hook note and handoff rewrite to Kiro prompt content.
131+
132+
``CommandRegistrar.register_commands()`` calls this hook too, so
133+
extension and preset prompts get the same transforms as core ones.
134+
"""
135+
return self._rewrite_handoff_references(self._inject_hook_command_note(content))
136+
137+
def setup(
138+
self,
139+
project_root: Path,
140+
manifest: IntegrationManifest,
141+
parsed_options: dict[str, Any] | None = None,
142+
**opts: Any,
143+
) -> list[Path]:
144+
"""Install Kiro prompts and apply post-processing transformations."""
145+
created = super().setup(project_root, manifest, parsed_options, **opts)
146+
147+
dest_dir = self.commands_dest(project_root).resolve()
148+
for path in created:
149+
try:
150+
path.resolve().relative_to(dest_dir)
151+
except ValueError:
152+
continue
153+
if path.suffix != ".md":
154+
continue
155+
156+
content = path.read_bytes().decode("utf-8")
157+
updated = self.post_process_command_content(content)
158+
if updated != content:
159+
path.write_bytes(updated.encode("utf-8"))
160+
self.record_file_in_manifest(path, project_root, manifest)
161+
162+
return created
43163

44164
def build_exec_args(
45165
self,
@@ -56,7 +176,7 @@ def build_exec_args(
56176
The inherited ``kiro-cli -p <prompt>`` exits 2 at argument parsing
57177
(``unexpected argument '-p'``). Kiro CLI runs one prompt headless
58178
through ``chat --no-interactive`` with the prompt as its positional
59-
input, and a ``/speckit.*`` input there runs the matching
179+
input, and a ``/speckit-*`` input there runs the matching
60180
``.kiro/prompts`` file. Headless mode cannot ask for tool approval, so
61181
without ``--trust-all-tools`` every file write is denied while the run
62182
still exits 0 (same role as Copilot's ``--yolo`` / Cursor's

‎tests/integrations/test_integration_kiro_cli.py‎

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import os
44
import re
55

6+
import pytest
7+
68
from specify_cli.integrations import get_integration
7-
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK
9+
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK, format_kiro_command_name
810
from specify_cli.integrations.manifest import IntegrationManifest
911

1012
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -45,6 +47,73 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
4547
def test_declares_multi_install_safe(self):
4648
assert get_integration(self.KEY).multi_install_safe is True
4749

50+
@pytest.mark.parametrize(
51+
"cmd_name, expected",
52+
[
53+
("plan", "speckit-plan"),
54+
("speckit.plan", "speckit-plan"),
55+
("speckit.git.commit", "speckit-git-commit"),
56+
("speckit-plan", "speckit-plan"),
57+
],
58+
)
59+
def test_format_kiro_command_name(self, cmd_name, expected):
60+
assert format_kiro_command_name(cmd_name) == expected
61+
62+
def test_prompts_and_invocations_are_hyphenated(self):
63+
"""Kiro CLI rejects ``/speckit.plan`` as an unrecognized slash command
64+
but runs ``/speckit-plan`` from ``.kiro/prompts/speckit-plan.md``."""
65+
i = get_integration(self.KEY)
66+
assert i.command_filename("plan") == "speckit-plan.md"
67+
assert i.build_command_invocation("plan") == "/speckit-plan"
68+
assert (
69+
i.build_command_invocation("speckit.git.commit", "now")
70+
== "/speckit-git-commit now"
71+
)
72+
assert i.invoke_separator == "-"
73+
assert i.registrar_config["invoke_separator"] == "-"
74+
# Extension and preset prompts get the same names via the registrar.
75+
assert i.registrar_config["format_name"] is format_kiro_command_name
76+
77+
def test_command_step_dispatches_hyphenated_prompt(self, tmp_path):
78+
"""A workflow ``command: speckit.constitution`` step reaches Kiro CLI
79+
as ``/speckit-constitution`` through ``chat --no-interactive``."""
80+
from unittest.mock import MagicMock, patch
81+
82+
from specify_cli.workflows.base import StepContext, StepStatus
83+
from specify_cli.workflows.step.command import CommandStep
84+
85+
ctx = StepContext(
86+
inputs={}, default_integration=self.KEY, project_root=str(tmp_path)
87+
)
88+
config = {"id": "constitution", "command": "speckit.constitution"}
89+
done = MagicMock(returncode=0, stdout="", stderr="")
90+
91+
with patch("specify_cli.workflows.step.command.shutil.which", return_value="/usr/local/bin/kiro-cli"), \
92+
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/kiro-cli"), \
93+
patch("subprocess.run", return_value=done) as mock_run:
94+
result = CommandStep().execute(config, ctx)
95+
96+
assert result.status == StepStatus.COMPLETED
97+
assert mock_run.call_args[0][0] == [
98+
"/usr/local/bin/kiro-cli",
99+
"chat",
100+
"--no-interactive",
101+
"--trust-all-tools",
102+
"/speckit-constitution",
103+
]
104+
105+
def test_post_process_adds_hook_note_and_rewrites_handoffs(self):
106+
i = get_integration(self.KEY)
107+
content = (
108+
"---\nhandoffs:\n - label: Plan\n agent: speckit.plan\n---\n"
109+
"When normalizing table names, replace dots with underscores.\n"
110+
"- For each executable hook, output the following:\n"
111+
)
112+
processed = i.post_process_command_content(content)
113+
assert " agent: speckit-plan\n" in processed
114+
assert "`/speckit-git-commit`" in processed
115+
assert i.post_process_command_content(processed) == processed
116+
48117
def test_build_exec_args_uses_headless_chat(self):
49118
"""``kiro-cli -p`` exits 2 (``unexpected argument '-p'``).
50119
@@ -53,22 +122,22 @@ def test_build_exec_args_uses_headless_chat(self):
53122
set, while still exiting 0.
54123
"""
55124
args = get_integration(self.KEY).build_exec_args(
56-
"/speckit.plan", output_json=False
125+
"/speckit-plan", output_json=False
57126
)
58127

59128
assert args == [
60129
"kiro-cli",
61130
"chat",
62131
"--no-interactive",
63132
"--trust-all-tools",
64-
"/speckit.plan",
133+
"/speckit-plan",
65134
]
66135
assert "-p" not in args
67136

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

74143
assert args == [
@@ -80,7 +149,7 @@ def test_build_exec_args_forwards_model_and_stream_json(self):
80149
"claude-sonnet-4.5",
81150
"--output-format",
82151
"stream-json",
83-
"/speckit.plan",
152+
"/speckit-plan",
84153
]
85154

86155
def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch):
@@ -89,7 +158,7 @@ def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch):
89158
"SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--agent reviewer"
90159
)
91160
args = get_integration(self.KEY).build_exec_args(
92-
"/speckit.plan", output_json=False
161+
"/speckit-plan", output_json=False
93162
)
94163

95164
assert args == [
@@ -99,7 +168,7 @@ def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch):
99168
"--trust-all-tools",
100169
"--agent",
101170
"reviewer",
102-
"/speckit.plan",
171+
"/speckit-plan",
103172
]
104173

105174
def test_registrar_config(self):
@@ -181,6 +250,53 @@ def test_rendered_prompts_contain_kiro_arg_placeholder(self, tmp_path):
181250
f"({expected!r})"
182251
)
183252

253+
# -- Overrides for MarkdownIntegrationTests (hyphenated prompt names) --
254+
255+
def test_setup_creates_files(self, tmp_path):
256+
i = get_integration(self.KEY)
257+
created = i.setup(tmp_path, IntegrationManifest(self.KEY, tmp_path))
258+
cmd_files = [f for f in created if "scripts" not in f.parts]
259+
assert cmd_files
260+
for f in cmd_files:
261+
assert f.exists()
262+
assert f.name.startswith("speckit-")
263+
assert f.name.endswith(".md")
264+
265+
specify = (tmp_path / self.REGISTRAR_DIR / "speckit-specify.md").read_text(
266+
encoding="utf-8"
267+
)
268+
assert "/speckit-plan" in specify
269+
assert "/speckit.plan" not in specify
270+
271+
def test_integration_flag_creates_files(self, tmp_path):
272+
from typer.testing import CliRunner
273+
from specify_cli import app
274+
275+
project = tmp_path / f"int-{self.KEY}"
276+
project.mkdir()
277+
old_cwd = os.getcwd()
278+
try:
279+
os.chdir(project)
280+
result = CliRunner().invoke(app, [
281+
"init", "--here", "--integration", self.KEY, "--script", "sh",
282+
"--ignore-agent-tools",
283+
], catch_exceptions=False)
284+
finally:
285+
os.chdir(old_cwd)
286+
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
287+
cmd_dir = get_integration(self.KEY).commands_dest(project)
288+
assert sorted(cmd_dir.glob("speckit-*.md"))
289+
assert not sorted(cmd_dir.glob("speckit.*"))
290+
291+
def _expected_files(self, script_variant: str) -> list[str]:
292+
# Kiro prompts are hyphenated, so remap the base mixin's
293+
# ``speckit.<stem>.md`` expectations under the prompts dir.
294+
prefix = f"{self.REGISTRAR_DIR}/speckit."
295+
return sorted(
296+
f"{self.REGISTRAR_DIR}/speckit-{f[len(prefix):]}" if f.startswith(prefix) else f
297+
for f in super()._expected_files(script_variant)
298+
)
299+
184300

185301
class TestKiroIntegration:
186302
"""--integration kiro-cli creates expected files."""
@@ -205,4 +321,4 @@ def test_integration_kiro_cli_creates_files(self, tmp_path):
205321
os.chdir(old_cwd)
206322

207323
assert result.exit_code == 0
208-
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
324+
assert (target / ".kiro" / "prompts" / "speckit-plan.md").exists()

0 commit comments

Comments
 (0)