Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 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
a80126f
fix(extensions): preserve shared migrated commands during removal
mnriem Oct 7, 2026
cce66b5
fix(integrations): refuse the Kiro rename when prompt ownership is un…
kartsan03 Oct 7, 2026
24630bc
fix(extensions): check the ownership marker before removal deletes a …
kartsan03 Oct 7, 2026
c68b384
fix(extensions): keep unwritten registered names for Kiro CLI and Qod…
kartsan03 Oct 7, 2026
41ed86c
fix(extensions): let a disabled extension's manifest claim its files
kartsan03 Oct 8, 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. Headless workflow dispatch runs `kiro-cli chat --no-interactive --trust-all-tools`, which auto-approves every tool call: without it, Kiro denies all file writes in non-interactive mode and still exits 0. 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
21 changes: 21 additions & 0 deletions src/specify_cli/integrations/_command_upgrade_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ def _legacy_command_root_changed(
return old_had_legacy and new_has_canonical


def _command_file_names_changed(integration, old_manifest, new_manifest) -> bool:
"""Return True when core command files were renamed inside the command dir.

Kiro CLI moved from ``speckit.<cmd>.md`` to ``speckit-<cmd>.md`` in the
same ``.kiro/prompts`` directory (#4797). Only a removed file that matches
an added one up to ``.``/``-`` separators counts, so a release that just
adds and drops commands is not a rename. Enabled extension and preset
commands are tracked outside the manifest, so re-registering them alone
would leave their old names beside the new ones.
"""
commands_dir = (integration.registrar_config or {}).get("dir")
if not isinstance(commands_dir, str) or not commands_dir.strip():
return False
old = {rel for rel in old_manifest.files if _manifest_path_under(rel, commands_dir)}
new = {rel for rel in new_manifest.files if _manifest_path_under(rel, commands_dir)}
# Compare whole paths: skill layouts name every file SKILL.md.
removed = {rel.replace(".", "-") for rel in old - new}
added = {rel.replace(".", "-") for rel in new - old}
return bool(removed & added)


def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
"""Return True when the old manifest tracks command files under legacy_dir."""
config = integration.registrar_config or {}
Expand Down
65 changes: 65 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,71 @@ def _unregister_enabled_extension_commands_for_agent(
)


def _retire_renamed_command_files(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Remove enabled extension and preset command files under old names.

``upgrade`` calls this after re-registration when the core command files
were renamed in place, as Kiro CLI's ``speckit.<cmd>.md`` prompts became
``speckit-<cmd>.md`` (#4797). Extension and preset commands are tracked in
their registries, not the integration manifest, so stale cleanup leaves
their old files. An old file is removed only when the file under the new
name exists, so a command whose re-registration failed keeps its old file
and its registry entry (replacement before retirement, as in
``ExtensionManager._retire_legacy_flat_extension_commands``).

Best-effort: never aborts the surrounding integration operation.
"""
try:
from ..agents import CommandRegistrar
from ..extensions import ExtensionManager
from ..presets import PresetManager

registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(agent_key)
if not agent_config:
return
commands_dir = registrar._resolve_agent_dir(agent_key, agent_config, project_root)
suffix = agent_config["extension"]
for manager in (ExtensionManager(project_root), PresetManager(project_root)):
for metadata in manager.registry.list().values():
if not isinstance(metadata, dict) or not metadata.get("enabled", True):
continue
registered = metadata.get("registered_commands")
names = registered.get(agent_key) if isinstance(registered, dict) else None
if not isinstance(names, list):
continue
for name in names:
if not isinstance(name, str) or not registrar._is_safe_command_name(name):
continue
new_name = registrar._compute_output_name(agent_key, name, agent_config)
old_file = commands_dir / f"{name}{suffix}"
try:
registrar._ensure_inside(old_file, commands_dir)
except ValueError:
continue
if (
new_name != name
and old_file.is_file()
and (commands_dir / f"{new_name}{suffix}").is_file()
):
Comment thread
Copilot marked this conversation as resolved.
Outdated
old_file.unlink()
except Exception as exc:
from .. import _print_cli_warning

_print_cli_warning(
"clean up renamed command files for",
"integration",
agent_key,
exc,
continuing=continuing,
)


# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
Expand Down
12 changes: 11 additions & 1 deletion src/specify_cli/integrations/command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys
from ._command_upgrade_layout import (
_PresetRegistryUnreadableError,
_command_file_names_changed,
_installed_command_presets_affecting_agent,
_installed_presets_affecting_agent,
_legacy_command_root_changed,
_legacy_command_root_upgrade_pending,
_manifest_tracks_skill_layout,
)
from ._commands import integration_app
from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _resync_manifest_after_registration, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json
from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _resync_manifest_after_registration, _retire_renamed_command_files, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json


@integration_app.command("upgrade")
Expand Down Expand Up @@ -343,6 +344,15 @@ def integration_upgrade(
key,
continuing="The integration was upgraded, but installed presets may need re-registration.",
)
if _command_file_names_changed(integration, old_manifest, new_manifest):
_retire_renamed_command_files(
project_root,
key,
continuing=(
"The integration was upgraded, but extension and preset "
"command files under the old names may need manual cleanup."
),
)
_resync_manifest_after_registration(
new_manifest,
key,
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
Loading
Loading