From 88c9c1328b75a9731ce64a195482dee4b3cfa95f Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 09:13:28 +0000 Subject: [PATCH 01/11] fix(integrations): dispatch kiro-cli through chat --no-interactive Refs #4797 --- .../integrations/kiro_cli/__init__.py | 38 +++++++++++++ .../integrations/test_integration_kiro_cli.py | 57 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 4c90d030a1..ffc0662c98 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -1,5 +1,11 @@ """Kiro CLI integration.""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + from ..base import MarkdownIntegration @@ -34,3 +40,35 @@ class KiroCliIntegration(MarkdownIntegration): "args": _KIRO_ARG_FALLBACK, "extension": ".md", } + + 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 `` 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 diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index a871e79a46..02504c8b50 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -45,6 +45,63 @@ class TestKiroCliIntegration(MarkdownIntegrationTests): def test_declares_multi_install_safe(self): assert get_integration(self.KEY).multi_install_safe is True + 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 From 86afed73419a145e71ce2e4cb6a2f3e2c025cd1d Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 14:11:30 +0000 Subject: [PATCH 02/11] 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-.md and dispatch /speckit-, 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) --- docs/reference/integrations.md | 2 +- .../integrations/kiro_cli/__init__.py | 124 +++++++++++++++- .../integrations/test_integration_kiro_cli.py | 132 ++++++++++++++++-- .../integrations/test_command_upgrade.py | 29 ++++ 4 files changed, 276 insertions(+), 11 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 7ae12d9468..575c5fb051 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -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-.md` and invokes them as `/speckit-`, 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-`. 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` | | diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index ffc0662c98..0b724b9c70 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations +import re from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any -from ..base import MarkdownIntegration +from ..base import _HOOK_COMMAND_NOTE, MarkdownIntegration +from ..manifest import IntegrationManifest # Kiro CLI file-based prompts do NOT support any argument-substitution syntax, @@ -17,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}" + + return cmd_name + + class KiroCliIntegration(MarkdownIntegration): key = "kiro-cli" # Kiro CLI keeps everything under a static, isolated agent root @@ -39,7 +66,100 @@ class KiroCliIntegration(MarkdownIntegration): "format": "markdown", "args": _KIRO_ARG_FALLBACK, "extension": ".md", + "format_name": format_kiro_command_name, + "invoke_separator": "-", } + 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-``), so the + dispatch invocation must match; the inherited dotted ``/speckit.`` + 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, @@ -56,7 +176,7 @@ def build_exec_args( The inherited ``kiro-cli -p `` 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 + 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 diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index 02504c8b50..8f715202d5 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -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 @@ -45,6 +47,73 @@ 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'``). @@ -53,7 +122,7 @@ def test_build_exec_args_uses_headless_chat(self): set, while still exiting 0. """ args = get_integration(self.KEY).build_exec_args( - "/speckit.plan", output_json=False + "/speckit-plan", output_json=False ) assert args == [ @@ -61,14 +130,14 @@ def test_build_exec_args_uses_headless_chat(self): "chat", "--no-interactive", "--trust-all-tools", - "/speckit.plan", + "/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 + "/speckit-plan", model="claude-sonnet-4.5", output_json=True ) assert args == [ @@ -80,7 +149,7 @@ def test_build_exec_args_forwards_model_and_stream_json(self): "claude-sonnet-4.5", "--output-format", "stream-json", - "/speckit.plan", + "/speckit-plan", ] 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): "SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--agent reviewer" ) args = get_integration(self.KEY).build_exec_args( - "/speckit.plan", output_json=False + "/speckit-plan", output_json=False ) assert args == [ @@ -99,7 +168,7 @@ def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch): "--trust-all-tools", "--agent", "reviewer", - "/speckit.plan", + "/speckit-plan", ] def test_registrar_config(self): @@ -181,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..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.""" @@ -205,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() diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 42d5ea0a6c..ac526300d8 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -297,6 +297,35 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): f"after upgrade, found: {[f.name for f in core_remaining]}" ) + def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): + """Kiro installs used to write ``.kiro/prompts/speckit..md``, + which Kiro CLI cannot invoke (#4797). Upgrade stale-removes them and + installs ``speckit-.md``, but a user-modified one blocks it.""" + from specify_cli.integrations.base import MarkdownIntegration + from specify_cli.integrations.kiro_cli import KiroCliIntegration + + with monkeypatch.context() as m: + m.setattr( + KiroCliIntegration, "command_filename", + MarkdownIntegration.command_filename, + ) + project = _init_project(tmp_path, "kiro-cli") + prompts = project / ".kiro" / "prompts" + dotted_plan = prompts / "speckit.plan.md" + original = dotted_plan.read_text(encoding="utf-8") + + dotted_plan.write_text(original + "my note\n", encoding="utf-8") + blocked = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert blocked.exit_code != 0 + assert "speckit.plan.md" in blocked.output + assert dotted_plan.read_text(encoding="utf-8") == original + "my note\n" + + dotted_plan.write_text(original, encoding="utf-8") + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert sorted(prompts.glob("speckit.*.md")) == [] + assert (prompts / "speckit-plan.md").is_file() + def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): """Qoder upgrade retires old extension commands after skills exist.""" project = _init_project(tmp_path, "qodercli") From cb898ce1c3d9340b46a515eb4b7ba2924e169741 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 14:57:40 +0000 Subject: [PATCH 03/11] test(integrations): restore the kiro prompt as bytes in the upgrade test test_upgrade_replaces_dotted_kiro_prompts restored the edited prompt with write_text(), which writes CRLF on Windows. Integration files are written as LF bytes, so the restored file no longer matched its manifest hash and the second upgrade was still blocked as modified (pytest on windows-latest). Read and write the prompt as bytes. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- tests/specify_cli/integrations/test_command_upgrade.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index ac526300d8..696412f858 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -312,15 +312,17 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "kiro-cli") prompts = project / ".kiro" / "prompts" dotted_plan = prompts / "speckit.plan.md" - original = dotted_plan.read_text(encoding="utf-8") + # Bytes, not text: write_text() would turn "\n" into "\r\n" on + # Windows, so the restored file would no longer match the manifest. + original = dotted_plan.read_bytes() - dotted_plan.write_text(original + "my note\n", encoding="utf-8") + dotted_plan.write_bytes(original + b"my note\n") blocked = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) assert blocked.exit_code != 0 assert "speckit.plan.md" in blocked.output - assert dotted_plan.read_text(encoding="utf-8") == original + "my note\n" + assert dotted_plan.read_bytes() == original + b"my note\n" - dotted_plan.write_text(original, encoding="utf-8") + dotted_plan.write_bytes(original) result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) assert result.exit_code == 0, result.output assert sorted(prompts.glob("speckit.*.md")) == [] From 270f740ec8fbf24051677e4212aa56873e76190c Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 15:58:29 +0000 Subject: [PATCH 04/11] fix(integrations): remove dotted kiro extension prompts on upgrade Upgrade only unregistered enabled extension commands when the command directory changed. Kiro keeps .kiro/prompts but renamed its files, and extension prompts are tracked in the extension registry rather than the manifest, so upgrading a project with the git extension left the five speckit.git.*.md prompts beside the new speckit-git-*.md ones. Treat a same-directory rename of the core command files like a directory change, so the existing cleanup removes them before re-registration. Also document that headless Kiro dispatch passes --trust-all-tools. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- docs/reference/integrations.md | 2 +- .../integrations/_command_upgrade_layout.py | 16 ++++++++++++++++ src/specify_cli/integrations/command_upgrade.py | 9 ++++++--- .../integrations/test_command_upgrade.py | 12 ++++++++++-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 575c5fb051..c70a2571cc 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -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` | Installs prompts as `.kiro/prompts/speckit-.md` and invokes them as `/speckit-`, 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` | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Installs prompts as `.kiro/prompts/speckit-.md` and invokes them as `/speckit-`, 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-`. 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` | | diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index caf4ba52d1..49213bdd7d 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -62,6 +62,22 @@ 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..md`` to ``speckit-.md`` in the + same ``.kiro/prompts`` directory (#4797). Enabled extension commands are + tracked in the extension registry rather than 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)} + return bool(old - new) and bool(new - old) + + 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 {} diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index b4682b6825..84d6799a59 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -15,6 +15,7 @@ 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, @@ -317,13 +318,15 @@ def integration_upgrade( old_manifest, new_manifest, ) - if legacy_command_root_changed: + if legacy_command_root_changed or _command_file_names_changed( + integration, old_manifest, new_manifest + ): _unregister_enabled_extension_commands_for_agent( project_root, key, continuing=( - "The integration command directory changed, but legacy enabled " - "extension artifacts may need manual cleanup." + "The integration command directory or file names changed, but " + "legacy enabled extension artifacts may need manual cleanup." ), ) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 696412f858..bfd34fa3d5 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -299,18 +299,25 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): """Kiro installs used to write ``.kiro/prompts/speckit..md``, - which Kiro CLI cannot invoke (#4797). Upgrade stale-removes them and - installs ``speckit-.md``, but a user-modified one blocks it.""" + which Kiro CLI cannot invoke (#4797). Upgrade replaces them, including + enabled extension prompts, with ``speckit-.md``, but a + user-modified one blocks it.""" + from specify_cli.agents import CommandRegistrar from specify_cli.integrations.base import MarkdownIntegration from specify_cli.integrations.kiro_cli import KiroCliIntegration + CommandRegistrar._ensure_configs() with monkeypatch.context() as m: m.setattr( KiroCliIntegration, "command_filename", MarkdownIntegration.command_filename, ) + m.delitem(CommandRegistrar.AGENT_CONFIGS["kiro-cli"], "format_name") project = _init_project(tmp_path, "kiro-cli") + added = _run_in_project(project, ["extension", "add", "git"]) + assert added.exit_code == 0, added.output prompts = project / ".kiro" / "prompts" + assert (prompts / "speckit.git.commit.md").is_file() dotted_plan = prompts / "speckit.plan.md" # Bytes, not text: write_text() would turn "\n" into "\r\n" on # Windows, so the restored file would no longer match the manifest. @@ -327,6 +334,7 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): assert result.exit_code == 0, result.output assert sorted(prompts.glob("speckit.*.md")) == [] assert (prompts / "speckit-plan.md").is_file() + assert (prompts / "speckit-git-commit.md").is_file() def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): """Qoder upgrade retires old extension commands after skills exist.""" From 569a1bdb7fec874d4890027cddf08cc4751145d2 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 16:54:12 +0000 Subject: [PATCH 05/11] fix(integrations): only treat real renames as a Kiro filename migration _command_file_names_changed() now needs a removed command file and an added one with the same name up to "."/"-" separators, so a release that adds one command and drops another no longer unregisters extension commands. When the rename does happen on the active integration, preset commands are unregistered before re-registration too, so dotted preset prompts such as speckit.fakeext.cmd.md don't survive the upgrade. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- .../integrations/_command_upgrade_layout.py | 12 ++- .../integrations/command_upgrade.py | 18 ++++- .../integrations/test_command_upgrade.py | 73 ++++++++++++++++++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 49213bdd7d..90218eae17 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -66,16 +66,20 @@ 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..md`` to ``speckit-.md`` in the - same ``.kiro/prompts`` directory (#4797). Enabled extension commands are - tracked in the extension registry rather than the manifest, so - re-registering them alone would leave their old names beside the new ones. + 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)} - return bool(old - new) and bool(new - old) + removed = {PurePath(rel).name.replace(".", "-") for rel in old - new} + added = {PurePath(rel).name.replace(".", "-") for rel in new - old} + return bool(removed & added) def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool: diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 84d6799a59..49c33e997b 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -23,7 +23,7 @@ _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, _unregister_enabled_extension_commands_for_agent, _unregister_presets_for_agent, _update_init_options_for_integration, _write_integration_json @integration_app.command("upgrade") @@ -318,9 +318,10 @@ def integration_upgrade( old_manifest, new_manifest, ) - if legacy_command_root_changed or _command_file_names_changed( + command_file_names_changed = _command_file_names_changed( integration, old_manifest, new_manifest - ): + ) + if legacy_command_root_changed or command_file_names_changed: _unregister_enabled_extension_commands_for_agent( project_root, key, @@ -341,6 +342,17 @@ def integration_upgrade( force=True, continuing="The integration was upgraded, but installed extensions may need re-registration.", ) + if command_file_names_changed: + # Preset commands aren't in the manifest either, so drop them + # under their old file names before re-registering. + _unregister_presets_for_agent( + project_root, + key, + continuing=( + "The integration command file names changed, but old " + "preset command files may need manual cleanup." + ), + ) _register_presets_for_agent( project_root, key, diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index bfd34fa3d5..ad4eca667f 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -300,12 +300,43 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): """Kiro installs used to write ``.kiro/prompts/speckit..md``, which Kiro CLI cannot invoke (#4797). Upgrade replaces them, including - enabled extension prompts, with ``speckit-.md``, but a + enabled extension and preset prompts, with ``speckit-.md``, but a user-modified one blocks it.""" + import yaml + from specify_cli.agents import CommandRegistrar from specify_cli.integrations.base import MarkdownIntegration from specify_cli.integrations.kiro_cli import KiroCliIntegration + preset_src = tmp_path / "custom-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Custom command\n---\nCustom preset content\n", + encoding="utf-8", + ) + (preset_src / "preset.yml").write_text( + yaml.dump({ + "schema_version": "1.0", + "preset": { + "id": "custom-preset", + "name": "Custom Preset", + "version": "1.0.0", + "description": "Test preset with a custom command", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + }), + encoding="utf-8", + ) + CommandRegistrar._ensure_configs() with monkeypatch.context() as m: m.setattr( @@ -316,8 +347,13 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "kiro-cli") added = _run_in_project(project, ["extension", "add", "git"]) assert added.exit_code == 0, added.output + added = _run_in_project( + project, ["preset", "add", "--dev", str(preset_src)] + ) + assert added.exit_code == 0, added.output prompts = project / ".kiro" / "prompts" assert (prompts / "speckit.git.commit.md").is_file() + assert (prompts / "speckit.fakeext.cmd.md").is_file() dotted_plan = prompts / "speckit.plan.md" # Bytes, not text: write_text() would turn "\n" into "\r\n" on # Windows, so the restored file would no longer match the manifest. @@ -335,6 +371,41 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): assert sorted(prompts.glob("speckit.*.md")) == [] assert (prompts / "speckit-plan.md").is_file() assert (prompts / "speckit-git-commit.md").is_file() + assert "Custom preset content" in ( + prompts / "speckit-fakeext-cmd.md" + ).read_text(encoding="utf-8") + + @pytest.mark.parametrize( + ("old_files", "new_files", "expected"), + [ + (["speckit.plan.md", "speckit.tasks.md"], + ["speckit-plan.md", "speckit-tasks.md"], True), + (["speckit.plan.md", "speckit.old.md"], + ["speckit.plan.md", "speckit.new.md"], False), + (["speckit.plan.md"], ["speckit.plan.md", "speckit.new.md"], False), + ], + ) + def test_command_file_names_changed_needs_a_rename( + self, old_files, new_files, expected + ): + """Commands added and dropped in the same release are not a rename, + so upgrade must not unregister extension commands for them.""" + from types import SimpleNamespace + + from specify_cli.integrations._command_upgrade_layout import ( + _command_file_names_changed, + ) + + integration = SimpleNamespace(registrar_config={"dir": ".kiro/prompts"}) + + def manifest(names): + return SimpleNamespace( + files={f".kiro/prompts/{name}": "hash" for name in names} + ) + + assert _command_file_names_changed( + integration, manifest(old_files), manifest(new_files) + ) is expected def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): """Qoder upgrade retires old extension commands after skills exist.""" From 268a908bd30b2da3cd5770c35478b32d64c5c41d Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 17:19:47 +0000 Subject: [PATCH 06/11] fix(integrations): keep disabled preset prompts and SKILL.md paths out of the Kiro rename The Kiro prompt rename now only unregisters enabled presets before re-registering them. Disabled presets keep their files until removal, as `specify preset disable` promises; PresetManager.unregister_agent_artifacts gains the same enabled_only switch ExtensionManager already has. _command_file_names_changed() compares whole manifest paths instead of file names, so skill layouts (every file is SKILL.md) no longer read an added and a dropped command as a rename. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- .../integrations/_command_upgrade_layout.py | 5 +- src/specify_cli/integrations/_helpers.py | 3 +- .../integrations/command_upgrade.py | 6 +- src/specify_cli/presets/_manager_commands.py | 11 +- .../integrations/test_command_upgrade.py | 128 +++++++++++------- 5 files changed, 99 insertions(+), 54 deletions(-) diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 90218eae17..4e7fab6884 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -77,8 +77,9 @@ def _command_file_names_changed(integration, old_manifest, new_manifest) -> bool 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)} - removed = {PurePath(rel).name.replace(".", "-") for rel in old - new} - added = {PurePath(rel).name.replace(".", "-") for rel in new - old} + # 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) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 38e87294ec..1fbcf4f306 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -538,6 +538,7 @@ def _unregister_presets_for_agent( agent_key: str, *, continuing: str, + enabled_only: bool = False, ) -> None: """Best-effort removal of ``agent_key``'s preset command/skill artifacts. @@ -553,7 +554,7 @@ def _unregister_presets_for_agent( from ..presets import PresetManager preset_mgr = PresetManager(project_root) - preset_mgr.unregister_agent_artifacts(agent_key) + preset_mgr.unregister_agent_artifacts(agent_key, enabled_only=enabled_only) except Exception as preset_err: from .. import _print_cli_warning diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 49c33e997b..2fcaeddbbd 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -343,8 +343,9 @@ def integration_upgrade( continuing="The integration was upgraded, but installed extensions may need re-registration.", ) if command_file_names_changed: - # Preset commands aren't in the manifest either, so drop them - # under their old file names before re-registering. + # Preset commands aren't in the manifest either, so drop enabled + # ones under their old file names before re-registering. Disabled + # presets keep their files until removal. _unregister_presets_for_agent( project_root, key, @@ -352,6 +353,7 @@ def integration_upgrade( "The integration command file names changed, but old " "preset command files may need manual cleanup." ), + enabled_only=True, ) _register_presets_for_agent( project_root, diff --git a/src/specify_cli/presets/_manager_commands.py b/src/specify_cli/presets/_manager_commands.py index 4d665b45dc..5e182f57de 100644 --- a/src/specify_cli/presets/_manager_commands.py +++ b/src/specify_cli/presets/_manager_commands.py @@ -673,7 +673,9 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: pack_id, {"registered_skills": merged_skills} ) - def unregister_agent_artifacts(self, agent_name: str) -> None: + def unregister_agent_artifacts( + self, agent_name: str, *, enabled_only: bool = False + ) -> None: """Remove ``agent_name``'s tracked preset command/skill artifacts. Mirrors ``ExtensionManager.unregister_agent_artifacts()`` (#2948): @@ -689,6 +691,10 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: tracking, and preset packs themselves are left untouched, and no priority-stack reconciliation runs — this is agent-scoped cleanup only, not preset removal. + + Set ``enabled_only=True`` when a caller is about to re-register + enabled presets and must keep disabled presets' artifacts, which + stay in place until the preset is removed. """ if not agent_name: return @@ -705,6 +711,9 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: return for pack_id, metadata in list(self.registry.list().items()): + if enabled_only and not metadata.get("enabled", True): + continue + updates: Dict[str, Any] = {} raw_skills = metadata.get("registered_skills", []) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index ad4eca667f..882a106c0a 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -26,6 +26,61 @@ runner, # noqa: F401 ) +def _write_command_preset(tmp_path, preset_id): + """Write a dev preset that adds the custom ``speckit.fakeext.cmd`` command.""" + import yaml + + preset_src = tmp_path / preset_id + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Custom command\n---\nCustom preset content\n", + encoding="utf-8", + ) + (preset_src / "preset.yml").write_text( + yaml.dump({ + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": "Custom Preset", + "version": "1.0.0", + "description": "Test preset with a custom command", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + }), + encoding="utf-8", + ) + return preset_src + + +def _init_dotted_kiro_project(tmp_path, monkeypatch, *commands): + """Init a Kiro project and run ``commands`` with the old dotted prompt names.""" + from specify_cli.agents import CommandRegistrar + from specify_cli.integrations.base import MarkdownIntegration + from specify_cli.integrations.kiro_cli import KiroCliIntegration + + CommandRegistrar._ensure_configs() + with monkeypatch.context() as m: + m.setattr( + KiroCliIntegration, "command_filename", + MarkdownIntegration.command_filename, + ) + m.delitem(CommandRegistrar.AGENT_CONFIGS["kiro-cli"], "format_name") + project = _init_project(tmp_path, "kiro-cli") + for args in commands: + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + return project + + class TestIntegrationUpgradeDetailed: def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") @@ -302,55 +357,13 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): which Kiro CLI cannot invoke (#4797). Upgrade replaces them, including enabled extension and preset prompts, with ``speckit-.md``, but a user-modified one blocks it.""" - import yaml - - from specify_cli.agents import CommandRegistrar - from specify_cli.integrations.base import MarkdownIntegration - from specify_cli.integrations.kiro_cli import KiroCliIntegration - - preset_src = tmp_path / "custom-preset" - (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Custom command\n---\nCustom preset content\n", - encoding="utf-8", - ) - (preset_src / "preset.yml").write_text( - yaml.dump({ - "schema_version": "1.0", - "preset": { - "id": "custom-preset", - "name": "Custom Preset", - "version": "1.0.0", - "description": "Test preset with a custom command", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - } - ] - }, - }), - encoding="utf-8", + preset_src = _write_command_preset(tmp_path, "custom-preset") + project = _init_dotted_kiro_project( + tmp_path, + monkeypatch, + ["extension", "add", "git"], + ["preset", "add", "--dev", str(preset_src)], ) - - CommandRegistrar._ensure_configs() - with monkeypatch.context() as m: - m.setattr( - KiroCliIntegration, "command_filename", - MarkdownIntegration.command_filename, - ) - m.delitem(CommandRegistrar.AGENT_CONFIGS["kiro-cli"], "format_name") - project = _init_project(tmp_path, "kiro-cli") - added = _run_in_project(project, ["extension", "add", "git"]) - assert added.exit_code == 0, added.output - added = _run_in_project( - project, ["preset", "add", "--dev", str(preset_src)] - ) - assert added.exit_code == 0, added.output prompts = project / ".kiro" / "prompts" assert (prompts / "speckit.git.commit.md").is_file() assert (prompts / "speckit.fakeext.cmd.md").is_file() @@ -375,6 +388,23 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): prompts / "speckit-fakeext-cmd.md" ).read_text(encoding="utf-8") + def test_upgrade_keeps_disabled_preset_kiro_prompts(self, tmp_path, monkeypatch): + """A disabled preset's prompts stay until the preset is removed, so + the Kiro prompt rename must not delete them (#4797).""" + preset_src = _write_command_preset(tmp_path, "custom-preset") + project = _init_dotted_kiro_project( + tmp_path, + monkeypatch, + ["preset", "add", "--dev", str(preset_src)], + ["preset", "disable", "custom-preset"], + ) + prompt = project / ".kiro" / "prompts" / "speckit.fakeext.cmd.md" + original = prompt.read_bytes() + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert prompt.read_bytes() == original + @pytest.mark.parametrize( ("old_files", "new_files", "expected"), [ @@ -383,6 +413,8 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): (["speckit.plan.md", "speckit.old.md"], ["speckit.plan.md", "speckit.new.md"], False), (["speckit.plan.md"], ["speckit.plan.md", "speckit.new.md"], False), + (["speckit-plan/SKILL.md", "speckit-old/SKILL.md"], + ["speckit-plan/SKILL.md", "speckit-new/SKILL.md"], False), ], ) def test_command_file_names_changed_needs_a_rename( From cc915602826fb0843681da7a222c061d2c9e78b6 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 18:32:07 +0000 Subject: [PATCH 07/11] fix(integrations): retire dotted Kiro prompts only after their replacements exist The Kiro prompt rename unregistered enabled extension and preset commands before re-registering them. Re-registration is best-effort, so an extension or preset whose manifest couldn't be loaded lost its dotted prompt without getting a hyphenated one, and its registry entry with it. Upgrade now re-registers first, then removes each old speckit.*.md file only when the file under the new name exists: the replacement-before- retirement rule of _retire_legacy_flat_extension_commands(). The enabled_only switch 268a908b added to PresetManager.unregister_agent_artifacts() has no caller left, so it is reverted. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/integrations/_helpers.py | 68 ++++++++++++++++++- .../integrations/command_upgrade.py | 33 ++++----- src/specify_cli/presets/_manager_commands.py | 11 +-- .../integrations/test_command_upgrade.py | 44 +++++++++++- 4 files changed, 123 insertions(+), 33 deletions(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 1fbcf4f306..a85160a882 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -538,7 +538,6 @@ def _unregister_presets_for_agent( agent_key: str, *, continuing: str, - enabled_only: bool = False, ) -> None: """Best-effort removal of ``agent_key``'s preset command/skill artifacts. @@ -554,7 +553,7 @@ def _unregister_presets_for_agent( from ..presets import PresetManager preset_mgr = PresetManager(project_root) - preset_mgr.unregister_agent_artifacts(agent_key, enabled_only=enabled_only) + preset_mgr.unregister_agent_artifacts(agent_key) except Exception as preset_err: from .. import _print_cli_warning @@ -587,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..md`` prompts became + ``speckit-.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() + ): + 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) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 2fcaeddbbd..de7f643425 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -23,7 +23,7 @@ _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, _unregister_presets_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") @@ -318,16 +318,13 @@ def integration_upgrade( old_manifest, new_manifest, ) - command_file_names_changed = _command_file_names_changed( - integration, old_manifest, new_manifest - ) - if legacy_command_root_changed or command_file_names_changed: + if legacy_command_root_changed: _unregister_enabled_extension_commands_for_agent( project_root, key, continuing=( - "The integration command directory or file names changed, but " - "legacy enabled extension artifacts may need manual cleanup." + "The integration command directory changed, but legacy enabled " + "extension artifacts may need manual cleanup." ), ) @@ -342,24 +339,20 @@ def integration_upgrade( force=True, continuing="The integration was upgraded, but installed extensions may need re-registration.", ) - if command_file_names_changed: - # Preset commands aren't in the manifest either, so drop enabled - # ones under their old file names before re-registering. Disabled - # presets keep their files until removal. - _unregister_presets_for_agent( - project_root, - key, - continuing=( - "The integration command file names changed, but old " - "preset command files may need manual cleanup." - ), - enabled_only=True, - ) _register_presets_for_agent( project_root, 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, diff --git a/src/specify_cli/presets/_manager_commands.py b/src/specify_cli/presets/_manager_commands.py index 5e182f57de..4d665b45dc 100644 --- a/src/specify_cli/presets/_manager_commands.py +++ b/src/specify_cli/presets/_manager_commands.py @@ -673,9 +673,7 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: pack_id, {"registered_skills": merged_skills} ) - def unregister_agent_artifacts( - self, agent_name: str, *, enabled_only: bool = False - ) -> None: + def unregister_agent_artifacts(self, agent_name: str) -> None: """Remove ``agent_name``'s tracked preset command/skill artifacts. Mirrors ``ExtensionManager.unregister_agent_artifacts()`` (#2948): @@ -691,10 +689,6 @@ def unregister_agent_artifacts( tracking, and preset packs themselves are left untouched, and no priority-stack reconciliation runs — this is agent-scoped cleanup only, not preset removal. - - Set ``enabled_only=True`` when a caller is about to re-register - enabled presets and must keep disabled presets' artifacts, which - stay in place until the preset is removed. """ if not agent_name: return @@ -711,9 +705,6 @@ def unregister_agent_artifacts( return for pack_id, metadata in list(self.registry.list().items()): - if enabled_only and not metadata.get("enabled", True): - continue - updates: Dict[str, Any] = {} raw_skills = metadata.get("registered_skills", []) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 882a106c0a..fdbfd97637 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -405,6 +405,48 @@ def test_upgrade_keeps_disabled_preset_kiro_prompts(self, tmp_path, monkeypatch) assert result.exit_code == 0, result.output assert prompt.read_bytes() == original + def test_upgrade_keeps_dotted_kiro_prompts_when_reregistration_fails( + self, tmp_path, monkeypatch + ): + """A dotted extension or preset prompt is removed only after its + hyphenated replacement exists (#4797), as in + ``test_upgrade_layout_change_preserves_extension_artifacts_when_reregistration_fails``. + If re-registration can't rebuild it, the old prompt and its registry + entry survive the upgrade.""" + preset_src = _write_command_preset(tmp_path, "custom-preset") + project = _init_dotted_kiro_project( + tmp_path, + monkeypatch, + ["extension", "add", "git"], + ["preset", "add", "--dev", str(preset_src)], + ) + specify = project / ".specify" + (specify / "extensions" / "git" / "extension.yml").write_text( + "invalid: [", encoding="utf-8" + ) + (specify / "presets" / "custom-preset" / "preset.yml").write_text( + "invalid: [", encoding="utf-8" + ) + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + + prompts = project / ".kiro" / "prompts" + assert (prompts / "speckit-plan.md").is_file() + assert not (prompts / "speckit.plan.md").exists() + assert (prompts / "speckit.git.commit.md").is_file() + assert (prompts / "speckit.fakeext.cmd.md").is_file() + extensions = json.loads( + (specify / "extensions" / ".registry").read_text(encoding="utf-8") + ) + assert "kiro-cli" in extensions["extensions"]["git"]["registered_commands"] + presets = json.loads( + (specify / "presets" / ".registry").read_text(encoding="utf-8") + ) + assert "kiro-cli" in ( + presets["presets"]["custom-preset"]["registered_commands"] + ) + @pytest.mark.parametrize( ("old_files", "new_files", "expected"), [ @@ -421,7 +463,7 @@ def test_command_file_names_changed_needs_a_rename( self, old_files, new_files, expected ): """Commands added and dropped in the same release are not a rename, - so upgrade must not unregister extension commands for them.""" + so upgrade must not retire extension or preset command files for them.""" from types import SimpleNamespace from specify_cli.integrations._command_upgrade_layout import ( From f53b21f42c8d9ddf3576fcf940a0479a6426b7f1 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 20:16:10 +0000 Subject: [PATCH 08/11] fix(integrations): refuse the Kiro prompt rename while presets are installed A preset override shares its path with the command it overrides, and its rescaffold is best-effort. If the preset couldn't be re-registered during the rename, Phase 2 stale cleanup (core commands) or the extension's new file (extension commands) left only the other layer's prompt, and the override was gone. Upgrade now refuses the rename before changing files while presets have commands registered for the integration, like the Kilo command-root and command/skills layout guards, and says to remove the preset, upgrade and add it back. The rename is detected before setup by comparing the old manifest with the files setup will write (_planned_command_files). With presets out of the way, _retire_renamed_command_files only handles extension commands. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- .../integrations/_command_upgrade_layout.py | 27 +++-- src/specify_cli/integrations/_helpers.py | 76 +++++++------ .../integrations/command_upgrade.py | 54 +++++++++- .../integrations/test_command_upgrade.py | 100 ++++++++---------- 4 files changed, 152 insertions(+), 105 deletions(-) diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 4e7fab6884..9ca1234735 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -62,21 +62,34 @@ 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. +def _planned_command_files(integration) -> set[str]: + """Return the manifest keys ``setup()`` will write for core command templates.""" + commands_dir = (integration.registrar_config or {}).get("dir") + if not isinstance(commands_dir, str) or not commands_dir.strip(): + return set() + return { + (PurePath(commands_dir) / integration.command_filename(template.stem)).as_posix() + for template in integration.list_command_templates() + } + + +def _command_file_names_changed(integration, old_files, new_files) -> bool: + """Return True when core command files are renamed inside the command dir. Kiro CLI moved from ``speckit..md`` to ``speckit-.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 + same ``.kiro/prompts`` directory (#4797). *old_files* and *new_files* are + manifest keys; ``upgrade`` passes ``_planned_command_files()`` as the new + ones so it can decide before changing files. 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. 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)} + old = {rel for rel in old_files if _manifest_path_under(rel, commands_dir)} + new = {rel for rel in new_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} diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a85160a882..b8f477391d 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -592,63 +592,61 @@ def _retire_renamed_command_files( *, continuing: str, ) -> None: - """Remove enabled extension and preset command files under old names. + """Remove enabled extension command files left under their old names. ``upgrade`` calls this after re-registration when the core command files were renamed in place, as Kiro CLI's ``speckit..md`` prompts became - ``speckit-.md`` (#4797). Extension and preset commands are tracked in - their registries, not the integration manifest, so stale cleanup leaves + ``speckit-.md`` (#4797). Extension commands are tracked in the + extension registry, 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``). + ``ExtensionManager._retire_legacy_flat_extension_commands``). Preset + overrides can share a path with another layer, so ``upgrade`` refuses the + rename while presets have commands registered for the agent. Best-effort: never aborts the surrounding integration operation. """ - try: + + def retire(ext_mgr: Any, key: str) -> None: from ..agents import CommandRegistrar - from ..extensions import ExtensionManager - from ..presets import PresetManager registrar = CommandRegistrar() - agent_config = registrar.AGENT_CONFIGS.get(agent_key) + agent_config = registrar.AGENT_CONFIGS.get(key) if not agent_config: return - commands_dir = registrar._resolve_agent_dir(agent_key, agent_config, project_root) + commands_dir = registrar._resolve_agent_dir(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): + for metadata in ext_mgr.registry.list().values(): + if not isinstance(metadata, dict) or not metadata.get("enabled", True): + continue + registered = metadata.get("registered_commands") + names = registered.get(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 - registered = metadata.get("registered_commands") - names = registered.get(agent_key) if isinstance(registered, dict) else None - if not isinstance(names, list): + new_name = registrar._compute_output_name(key, name, agent_config) + old_file = commands_dir / f"{name}{suffix}" + try: + registrar._ensure_inside(old_file, commands_dir) + except ValueError: 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() - ): - old_file.unlink() - except Exception as exc: - from .. import _print_cli_warning + if ( + new_name != name + and old_file.is_file() + and (commands_dir / f"{new_name}{suffix}").is_file() + ): + old_file.unlink() - _print_cli_warning( - "clean up renamed command files for", - "integration", - agent_key, - exc, - continuing=continuing, - ) + _best_effort_extension_op( + project_root, + agent_key, + retire, + phase="clean up renamed extension command files for", + continuing=continuing, + ) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index de7f643425..1672e54401 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -21,6 +21,7 @@ _legacy_command_root_changed, _legacy_command_root_upgrade_pending, _manifest_tracks_skill_layout, + _planned_command_files, ) 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, _retire_renamed_command_files, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json @@ -189,6 +190,53 @@ def integration_upgrade( ) raise typer.Exit(1) + # Reject in-place command file renames (Kiro CLI's speckit..md -> + # speckit-.md, #4797) while preset command artifacts are tracked for + # the integration. A preset override shares its path with the core or + # extension command it overrides, and its rescaffold is best-effort: if + # the preset can't be re-registered, stale cleanup or the other layer's + # new file would replace the override. Refuse before any mutation, as for + # the layout changes above. + command_file_names_changed = _command_file_names_changed( + integration, old_manifest.files, _planned_command_files(integration) + ) + if command_file_names_changed: + try: + affected_presets = _installed_command_presets_affecting_agent( + project_root, + key, + ) + except _PresetRegistryUnreadableError as exc: + console.print( + f"[red]Error:[/red] Cannot rename '{key}' command files: the " + "preset registry could not be read to verify installed presets." + ) + console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") + console.print( + "A command file rename cannot reconcile preset command " + "artifacts while the preset registry state is unknown. Fix or " + "restore [cyan].specify/presets/.registry[/cyan] and retry." + ) + raise typer.Exit(1) + if affected_presets: + preset_list = ", ".join(sorted(affected_presets)) + console.print( + f"[red]Error:[/red] Cannot rename '{key}' command files while " + f"preset override(s) are installed: [bold]{preset_list}[/bold]." + ) + console.print( + "Preset command artifacts cannot yet be reconciled across a " + "command file rename, so the upgrade is refused before " + "changing files." + ) + console.print( + "Remove the preset(s), run the upgrade, then reinstall them:\n" + f" [cyan]specify preset remove [/cyan]\n" + f" [cyan]specify integration upgrade {key}[/cyan]\n" + f" [cyan]specify preset add [/cyan]" + ) + raise typer.Exit(1) + # Ensure shared infrastructure is up to date; --force overwrites existing files. infra_integration = integration infra_key = key @@ -344,13 +392,13 @@ 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): + if command_file_names_changed: _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." + "The integration was upgraded, but extension command files " + "under the old names may need manual cleanup." ), ) _resync_manifest_after_registration( diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index fdbfd97637..da0ed2244f 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -27,13 +27,13 @@ ) def _write_command_preset(tmp_path, preset_id): - """Write a dev preset that adds the custom ``speckit.fakeext.cmd`` command.""" + """Write a dev preset that overrides the core ``speckit.plan`` command.""" import yaml preset_src = tmp_path / preset_id (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Custom command\n---\nCustom preset content\n", + (preset_src / "commands" / "speckit.plan.md").write_text( + "---\ndescription: Overridden plan\n---\nOverridden plan content\n", encoding="utf-8", ) (preset_src / "preset.yml").write_text( @@ -41,17 +41,17 @@ def _write_command_preset(tmp_path, preset_id): "schema_version": "1.0", "preset": { "id": preset_id, - "name": "Custom Preset", + "name": "Command Preset", "version": "1.0.0", - "description": "Test preset with a custom command", + "description": "Test preset with a command override", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", } ] }, @@ -355,18 +355,13 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): """Kiro installs used to write ``.kiro/prompts/speckit..md``, which Kiro CLI cannot invoke (#4797). Upgrade replaces them, including - enabled extension and preset prompts, with ``speckit-.md``, but a + enabled extension prompts, with ``speckit-.md``, but a user-modified one blocks it.""" - preset_src = _write_command_preset(tmp_path, "custom-preset") project = _init_dotted_kiro_project( - tmp_path, - monkeypatch, - ["extension", "add", "git"], - ["preset", "add", "--dev", str(preset_src)], + tmp_path, monkeypatch, ["extension", "add", "git"] ) prompts = project / ".kiro" / "prompts" assert (prompts / "speckit.git.commit.md").is_file() - assert (prompts / "speckit.fakeext.cmd.md").is_file() dotted_plan = prompts / "speckit.plan.md" # Bytes, not text: write_text() would turn "\n" into "\r\n" on # Windows, so the restored file would no longer match the manifest. @@ -384,49 +379,50 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): assert sorted(prompts.glob("speckit.*.md")) == [] assert (prompts / "speckit-plan.md").is_file() assert (prompts / "speckit-git-commit.md").is_file() - assert "Custom preset content" in ( - prompts / "speckit-fakeext-cmd.md" - ).read_text(encoding="utf-8") - - def test_upgrade_keeps_disabled_preset_kiro_prompts(self, tmp_path, monkeypatch): - """A disabled preset's prompts stay until the preset is removed, so - the Kiro prompt rename must not delete them (#4797).""" - preset_src = _write_command_preset(tmp_path, "custom-preset") + + def test_upgrade_refuses_kiro_prompt_rename_while_presets_are_installed( + self, tmp_path, monkeypatch + ): + """A preset override shares its path with the command it overrides, + and its rescaffold is best-effort. If the preset can't be + re-registered, the rename would leave only the core prompt, so + upgrade refuses before changing files, even with ``--force`` (#4797), + as for the Kilo command-root and command/skills layout migrations.""" + preset_src = _write_command_preset(tmp_path, "cmd-preset") project = _init_dotted_kiro_project( - tmp_path, - monkeypatch, - ["preset", "add", "--dev", str(preset_src)], - ["preset", "disable", "custom-preset"], + tmp_path, monkeypatch, ["preset", "add", "--dev", str(preset_src)] ) - prompt = project / ".kiro" / "prompts" / "speckit.fakeext.cmd.md" - original = prompt.read_bytes() + prompts = project / ".kiro" / "prompts" + before = {path.name: path.read_bytes() for path in prompts.iterdir()} + assert b"Overridden plan content" in before["speckit.plan.md"] + # The preset can no longer be re-registered. + ( + project / ".specify" / "presets" / "cmd-preset" / "commands" + / "speckit.plan.md" + ).unlink() - result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) - assert result.exit_code == 0, result.output - assert prompt.read_bytes() == original + result = _run_in_project( + project, ["integration", "upgrade", "kiro-cli", "--force"] + ) + assert result.exit_code != 0 + assert "cmd-preset" in result.output + assert {path.name: path.read_bytes() for path in prompts.iterdir()} == before def test_upgrade_keeps_dotted_kiro_prompts_when_reregistration_fails( self, tmp_path, monkeypatch ): - """A dotted extension or preset prompt is removed only after its - hyphenated replacement exists (#4797), as in + """A dotted extension prompt is removed only after its hyphenated + replacement exists (#4797), as in ``test_upgrade_layout_change_preserves_extension_artifacts_when_reregistration_fails``. If re-registration can't rebuild it, the old prompt and its registry entry survive the upgrade.""" - preset_src = _write_command_preset(tmp_path, "custom-preset") project = _init_dotted_kiro_project( - tmp_path, - monkeypatch, - ["extension", "add", "git"], - ["preset", "add", "--dev", str(preset_src)], + tmp_path, monkeypatch, ["extension", "add", "git"] ) specify = project / ".specify" (specify / "extensions" / "git" / "extension.yml").write_text( "invalid: [", encoding="utf-8" ) - (specify / "presets" / "custom-preset" / "preset.yml").write_text( - "invalid: [", encoding="utf-8" - ) result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) assert result.exit_code == 0, result.output @@ -435,17 +431,10 @@ def test_upgrade_keeps_dotted_kiro_prompts_when_reregistration_fails( assert (prompts / "speckit-plan.md").is_file() assert not (prompts / "speckit.plan.md").exists() assert (prompts / "speckit.git.commit.md").is_file() - assert (prompts / "speckit.fakeext.cmd.md").is_file() - extensions = json.loads( + registry = json.loads( (specify / "extensions" / ".registry").read_text(encoding="utf-8") ) - assert "kiro-cli" in extensions["extensions"]["git"]["registered_commands"] - presets = json.loads( - (specify / "presets" / ".registry").read_text(encoding="utf-8") - ) - assert "kiro-cli" in ( - presets["presets"]["custom-preset"]["registered_commands"] - ) + assert "kiro-cli" in registry["extensions"]["git"]["registered_commands"] @pytest.mark.parametrize( ("old_files", "new_files", "expected"), @@ -463,7 +452,8 @@ def test_command_file_names_changed_needs_a_rename( self, old_files, new_files, expected ): """Commands added and dropped in the same release are not a rename, - so upgrade must not retire extension or preset command files for them.""" + so upgrade must neither refuse over presets nor retire extension + command files for them.""" from types import SimpleNamespace from specify_cli.integrations._command_upgrade_layout import ( @@ -472,13 +462,11 @@ def test_command_file_names_changed_needs_a_rename( integration = SimpleNamespace(registrar_config={"dir": ".kiro/prompts"}) - def manifest(names): - return SimpleNamespace( - files={f".kiro/prompts/{name}": "hash" for name in names} - ) + def files(names): + return {f".kiro/prompts/{name}" for name in names} assert _command_file_names_changed( - integration, manifest(old_files), manifest(new_files) + integration, files(old_files), files(new_files) ) is expected def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): From ec15335ed728b87fc93be82334d54833fccf75f8 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Thu, 1 Oct 2026 18:54:10 +0000 Subject: [PATCH 09/11] fix(integrations): stop treating the Kiro IDE launcher as Kiro CLI check_tool("kiro-cli") also accepted a bare `kiro`, so `specify check` and `specify init` reported Kiro CLI as installed while both workflow preflights, which look for `kiro-cli`, reported it missing. A bare `kiro` launches Kiro IDE by default. Kiro's command router sends it to the CLI only after `kiro-cli` installs the router. Kiro IDE 1.2.4 takes `kiro chat --no-interactive --trust-all-tools /speckit-plan`, warns about the unknown options and exits 0 without running anything, so a dispatch fallback to `kiro` would mark steps completed. Look for `kiro-cli` only, in the tool check and in the devcontainer. SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE still opts in to another binary. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- .devcontainer/post-create.sh | 11 ++--- docs/reference/integrations.md | 2 +- src/specify_cli/_utils.py | 6 +-- .../integrations/test_integration_kiro_cli.py | 49 +++++++++++++++++++ tests/specify_cli/test_check_tool.py | 6 +-- 5 files changed, 57 insertions(+), 17 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index a702d3eb8a..3f2d2080cf 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -75,17 +75,12 @@ run_command "echo \"$KIRO_INSTALLER_SHA256 $KIRO_INSTALLER_PATH\" | sha256sum - run_command "bash \"$KIRO_INSTALLER_PATH\"" -kiro_binary="" -if command -v kiro-cli >/dev/null 2>&1; then - kiro_binary="kiro-cli" -elif command -v kiro >/dev/null 2>&1; then - kiro_binary="kiro" -else - echo -e "\033[0;31m[ERROR] Kiro CLI installation did not create 'kiro-cli' or 'kiro' in PATH.\033[0m" >&2 +if ! command -v kiro-cli >/dev/null 2>&1; then + echo -e "\033[0;31m[ERROR] Kiro CLI installation did not create 'kiro-cli' in PATH.\033[0m" >&2 exit 1 fi -run_command "$kiro_binary --help > /dev/null" +run_command "kiro-cli --help > /dev/null" echo "āœ… Done" echo -e "\nšŸ¤– Installing Kimi Code CLI..." diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index c70a2571cc..d289f4049d 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -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` | Installs prompts as `.kiro/prompts/speckit-.md` and invokes them as `/speckit-`, 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` | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Installs prompts as `.kiro/prompts/speckit-.md` and invokes them as `/speckit-`, 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. Spec Kit looks for the `kiro-cli` executable only, because a bare `kiro` launches Kiro IDE by default. 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-`. 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` | | diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index 300ca4ff58..5c2f077a7a 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -170,11 +170,7 @@ def check_tool(tool: str, tracker=None) -> bool: return True # Per-integration executable resolution. - if tool == "kiro-cli": - # Kiro currently supports both executable names. Prefer kiro-cli and - # accept kiro as a compatibility fallback. - found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None - elif tool == "rovodev": + if tool == "rovodev": found = shutil.which("acli") is not None elif tool == "docker-agent": found = docker_agent_command() is not None diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index 8f715202d5..e7fffa4a8a 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -102,6 +102,55 @@ def test_command_step_dispatches_hyphenated_prompt(self, tmp_path): "/speckit-constitution", ] + @pytest.mark.parametrize("step_type", ["command", "prompt"]) + def test_steps_do_not_dispatch_the_kiro_ide_launcher( + self, tmp_path, monkeypatch, step_type + ): + """A bare ``kiro`` launches Kiro IDE unless Kiro's command router, + which ``kiro-cli`` itself installs, is set to the CLI. Kiro IDE 1.2.4 + takes ``kiro chat --no-interactive --trust-all-tools /speckit-plan``, + warns about the unknown options and exits 0 without running anything, + so ``specify check``/``init`` and both workflow preflights must report + Kiro CLI as missing. The executable override still opts in to ``kiro``. + """ + from unittest.mock import MagicMock, patch + + from specify_cli import check_tool + from specify_cli.workflows.base import StepContext, StepStatus + from specify_cli.workflows.step.command import CommandStep + from specify_cli.workflows.step.prompt import PromptStep + + if step_type == "command": + step, config = CommandStep(), {"id": "plan", "command": "speckit.plan"} + else: + step = PromptStep() + config = {"id": "plan", "type": "prompt", "prompt": "/speckit-plan"} + ctx = StepContext( + inputs={}, default_integration=self.KEY, project_root=str(tmp_path) + ) + done = MagicMock(returncode=0, stdout="", stderr="") + + def which(name): + return "/usr/bin/kiro" if name == "kiro" else None + + with patch("shutil.which", side_effect=which), \ + patch("subprocess.run", return_value=done) as mock_run: + assert check_tool(self.KEY) is False + assert step.execute(config, ctx).status == StepStatus.FAILED + mock_run.assert_not_called() + + monkeypatch.setenv("SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE", "kiro") + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert mock_run.call_args[0][0] == [ + "/usr/bin/kiro", + "chat", + "--no-interactive", + "--trust-all-tools", + "/speckit-plan", + ] + def test_post_process_adds_hook_note_and_rewrites_handoffs(self): i = get_integration(self.KEY) content = ( diff --git a/tests/specify_cli/test_check_tool.py b/tests/specify_cli/test_check_tool.py index 0d82e84564..a14490bf99 100644 --- a/tests/specify_cli/test_check_tool.py +++ b/tests/specify_cli/test_check_tool.py @@ -97,13 +97,13 @@ def test_missing_tool(self): with patch("shutil.which", return_value=None): assert check_tool("nonexistent-tool") is False - def test_kiro_fallback(self): - """kiro-cli detection should try both kiro-cli and kiro.""" + def test_kiro_ide_launcher_is_not_kiro_cli(self): + """A bare ``kiro`` launches Kiro IDE by default, so it is not Kiro CLI.""" def fake_which(name): return "/usr/bin/kiro" if name == "kiro" else None with patch("shutil.which", side_effect=fake_which): - assert check_tool("kiro-cli") is True + assert check_tool("kiro-cli") is False def test_rovodev_uses_acli_executable(self): """rovodev should resolve through the shared acli executable.""" From ed4200442a6b6d6016773000b7c5bbafd7d54d24 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Thu, 1 Oct 2026 18:54:10 +0000 Subject: [PATCH 10/11] test(workflows): pin the zip entry time in catalog release archives _archive() stamped workflow.yml with the current time, which zip stores at 2-second resolution. The tests hash one build for the catalog and serve another as the download, so a run that crossed an even second between the two failed the integrity check (windows-latest on Python 3.13 in #4338 and on 3.14 in #4798). Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- tests/specify_cli/workflows/test_catalog_versions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index f9828b3278..6bd7193fb9 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -49,7 +49,11 @@ def _archive(version: str, workflow_id: str = "history-wf", requires=None) -> by document["requires"] = requires output = io.BytesIO() with zipfile.ZipFile(output, "w") as archive: - archive.writestr("workflow.yml", yaml.safe_dump(document)) + # Pin the entry time: tests hash one build for the catalog and serve + # another as the download, and the current time (2 s resolution) can + # differ between the two. + info = zipfile.ZipInfo("workflow.yml", date_time=(1980, 1, 1, 0, 0, 0)) + archive.writestr(info, yaml.safe_dump(document)) return output.getvalue() From 234bbd23000140ce0708ac190dbd6ede10b5f996 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 2 Oct 2026 14:28:24 +0000 Subject: [PATCH 11/11] fix(integrations): retire dotted Kiro extension prompts on every registration Upgrading Kiro while another integration is active skips extension registration (#2948), and the new manifest no longer shows a rename, so the dotted extension prompts stayed for good once `use` or `switch` activated it. An extension disabled during the upgrade ended up the same way. Kiro now declares its dotted prompts as legacy flat command files, so ExtensionManager._retire_legacy_flat_extension_commands(), which already retires Qoder's old commands once their skills are written, removes a dotted prompt once registration writes its hyphenated replacement. It runs on every registration pass, uses the registrar's output path rather than assuming a SKILL.md, and skips a name that is its own replacement. This replaces the upgrade-only _retire_renamed_command_files(). Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 24 ++++-- .../integrations/_command_upgrade_layout.py | 9 +-- src/specify_cli/integrations/_helpers.py | 63 --------------- src/specify_cli/integrations/base.py | 6 +- .../integrations/command_upgrade.py | 16 +--- .../integrations/kiro_cli/__init__.py | 7 ++ .../integrations/test_command_upgrade.py | 76 ++++++++++++++++++- 7 files changed, 111 insertions(+), 90 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6242dc2c05..0f3cdc7a2b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3813,7 +3813,15 @@ def _retire_legacy_flat_extension_commands( agent_name: str, command_names: List[str], ) -> List[Path]: - """Remove old flat commands whose replacement skills were written.""" + """Remove old flat commands whose replacements were written. + + Qoder's ``.qoder/commands`` files became skills, and Kiro CLI's dotted + ``.kiro/prompts/speckit..md`` files became hyphenated prompts in + the same directory (#4797). This runs on every registration pass, so + a command whose old file outlived an upgrade (the integration was + inactive, or the extension disabled) is cleaned up when it is next + registered (#2948). + """ from ..agents import CommandRegistrar from ..integrations import get_integration @@ -3832,7 +3840,7 @@ def _retire_legacy_flat_extension_commands( registrar = CommandRegistrar(self.project_root) agent_config = registrar.AGENT_CONFIGS.get(agent_name) - if not agent_config or agent_config.get("extension") != "/SKILL.md": + if not agent_config: return [] def safe_project_dir(relative: str) -> Optional[Path]: @@ -3851,8 +3859,8 @@ def safe_project_dir(relative: str) -> Optional[Path]: return current legacy_root = safe_project_dir(legacy_dir) - skills_root = safe_project_dir(str(agent_config.get("dir", ""))) - if legacy_root is None or skills_root is None or not legacy_root.is_dir(): + output_root = safe_project_dir(str(agent_config.get("dir", ""))) + if legacy_root is None or output_root is None or not legacy_root.is_dir(): return [] removed: List[Path] = [] @@ -3864,14 +3872,18 @@ def safe_project_dir(relative: str) -> Optional[Path]: ): continue - skill_name = registrar._compute_output_name( + output_name = registrar._compute_output_name( agent_name, command_name, agent_config ) - replacement = skills_root / skill_name / "SKILL.md" + replacement = output_root / f"{output_name}{agent_config['extension']}" if replacement.is_symlink() or not replacement.is_file(): continue legacy_file = legacy_root / f"{command_name}{legacy_extension}" + # Kiro's old and new prompts share a directory: a name without + # dots (e.g. an alias ``speckit-git-c``) is its own replacement. + if registrar._same_lexical_path(legacy_file, replacement): + continue if legacy_file.is_symlink() or legacy_file.is_file(): legacy_file.unlink() removed.append(legacy_file) diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 9ca1234735..313c33cccb 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -79,11 +79,10 @@ def _command_file_names_changed(integration, old_files, new_files) -> bool: Kiro CLI moved from ``speckit..md`` to ``speckit-.md`` in the same ``.kiro/prompts`` directory (#4797). *old_files* and *new_files* are manifest keys; ``upgrade`` passes ``_planned_command_files()`` as the new - ones so it can decide before changing files. 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. Extension and preset - commands are tracked outside the manifest, so re-registering them alone - would leave their old names beside the new ones. + ones so it can refuse the rename while presets have commands registered + for the agent, before changing files. 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. """ commands_dir = (integration.registrar_config or {}).get("dir") if not isinstance(commands_dir, str) or not commands_dir.strip(): diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index b8f477391d..38e87294ec 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -586,69 +586,6 @@ def _unregister_enabled_extension_commands_for_agent( ) -def _retire_renamed_command_files( - project_root: Path, - agent_key: str, - *, - continuing: str, -) -> None: - """Remove enabled extension command files left under their old names. - - ``upgrade`` calls this after re-registration when the core command files - were renamed in place, as Kiro CLI's ``speckit..md`` prompts became - ``speckit-.md`` (#4797). Extension commands are tracked in the - extension registry, 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``). Preset - overrides can share a path with another layer, so ``upgrade`` refuses the - rename while presets have commands registered for the agent. - - Best-effort: never aborts the surrounding integration operation. - """ - - def retire(ext_mgr: Any, key: str) -> None: - from ..agents import CommandRegistrar - - registrar = CommandRegistrar() - agent_config = registrar.AGENT_CONFIGS.get(key) - if not agent_config: - return - commands_dir = registrar._resolve_agent_dir(key, agent_config, project_root) - suffix = agent_config["extension"] - for metadata in ext_mgr.registry.list().values(): - if not isinstance(metadata, dict) or not metadata.get("enabled", True): - continue - registered = metadata.get("registered_commands") - names = registered.get(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(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() - ): - old_file.unlink() - - _best_effort_extension_op( - project_root, - agent_key, - retire, - phase="clean up renamed extension command files for", - continuing=continuing, - ) - - # --------------------------------------------------------------------------- # CLI formatting helpers (re-exported from _commands.py) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e698b9e289..c8366a6f64 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -144,7 +144,11 @@ class IntegrationBase(ABC): """ legacy_flat_command_dir: str | None = None - """Previous flat command directory retired after skill replacements exist.""" + """Previous flat command directory retired after replacements exist. + + Its files are named ````. It + may be the current command directory when only the file names changed. + """ legacy_flat_command_extension: str | None = None """File extension used by commands in ``legacy_flat_command_dir``.""" diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 1672e54401..fbf1aa45e0 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -24,7 +24,7 @@ _planned_command_files, ) 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, _retire_renamed_command_files, _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, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json @integration_app.command("upgrade") @@ -197,10 +197,9 @@ def integration_upgrade( # the preset can't be re-registered, stale cleanup or the other layer's # new file would replace the override. Refuse before any mutation, as for # the layout changes above. - command_file_names_changed = _command_file_names_changed( + if _command_file_names_changed( integration, old_manifest.files, _planned_command_files(integration) - ) - if command_file_names_changed: + ): try: affected_presets = _installed_command_presets_affecting_agent( project_root, @@ -392,15 +391,6 @@ def integration_upgrade( key, continuing="The integration was upgraded, but installed presets may need re-registration.", ) - if command_file_names_changed: - _retire_renamed_command_files( - project_root, - key, - continuing=( - "The integration was upgraded, but extension command files " - "under the old names may need manual cleanup." - ), - ) _resync_manifest_after_registration( new_manifest, key, diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 0b724b9c70..c15aee9b6d 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -70,6 +70,13 @@ class KiroCliIntegration(MarkdownIntegration): "invoke_separator": "-", } invoke_separator = "-" + # Prompts used to be named after the dotted command + # (``.kiro/prompts/speckit..md``), which Kiro CLI cannot invoke. + # ``integration upgrade`` stale-removes the core ones through the + # manifest; ExtensionManager retires an extension's dotted prompt once + # registration writes its hyphenated replacement (#4797). + legacy_flat_command_dir = ".kiro/prompts" + legacy_flat_command_extension = ".md" def command_filename(self, template_name: str) -> str: return format_kiro_command_name(template_name) + ".md" diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index da0ed2244f..0e88bfcacd 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -436,6 +436,79 @@ def test_upgrade_keeps_dotted_kiro_prompts_when_reregistration_fails( ) assert "kiro-cli" in registry["extensions"]["git"]["registered_commands"] + @pytest.mark.parametrize("activate", ["use", "switch"]) + def test_activating_kiro_after_secondary_upgrade_retires_dotted_prompts( + self, tmp_path, monkeypatch, activate + ): + """Upgrading Kiro while another integration is active skips extension + registration (#2948), so its dotted extension prompts survive, and the + new manifest no longer shows a rename. ``use`` or ``switch`` registers + the hyphenated prompts and then retires the dotted ones (#4797).""" + project = _init_dotted_kiro_project( + tmp_path, + monkeypatch, + ["extension", "add", "git"], + ["integration", "install", "claude"], + ["integration", "use", "claude"], + ) + prompts = project / ".kiro" / "prompts" + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert (prompts / "speckit-plan.md").is_file() + assert (prompts / "speckit.git.commit.md").is_file() + + result = _run_in_project(project, ["integration", activate, "kiro-cli"]) + assert result.exit_code == 0, result.output + assert sorted(prompts.glob("speckit.*.md")) == [] + assert (prompts / "speckit-git-commit.md").is_file() + + def test_enabling_extension_after_kiro_rename_retires_its_dotted_prompts( + self, tmp_path, monkeypatch + ): + """A disabled extension keeps its dotted prompts through the rename. + Once it is enabled, the next registration pass replaces them, although + that upgrade no longer sees a rename (#4797).""" + project = _init_dotted_kiro_project( + tmp_path, + monkeypatch, + ["extension", "add", "git"], + ["extension", "disable", "git"], + ) + prompts = project / ".kiro" / "prompts" + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert (prompts / "speckit.git.commit.md").is_file() + + for args in ( + ["extension", "enable", "git"], + ["integration", "upgrade", "kiro-cli"], + ): + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + assert sorted(prompts.glob("speckit.*.md")) == [] + assert (prompts / "speckit-git-commit.md").is_file() + + def test_kiro_prompt_named_without_dots_is_not_retired(self, tmp_path): + """Aliases are free-form, and one without dots is already its Kiro + prompt name, so its old and new prompt are the same file.""" + import yaml + + project = _init_project(tmp_path, "kiro-cli") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, result.output + manifest_path = project / ".specify" / "extensions" / "git" / "extension.yml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + for command in manifest["provides"]["commands"]: + if command["name"] == "speckit.git.commit": + command["aliases"] = ["speckit-git-c"] + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert (project / ".kiro" / "prompts" / "speckit-git-c.md").is_file() + @pytest.mark.parametrize( ("old_files", "new_files", "expected"), [ @@ -452,8 +525,7 @@ def test_command_file_names_changed_needs_a_rename( self, old_files, new_files, expected ): """Commands added and dropped in the same release are not a rename, - so upgrade must neither refuse over presets nor retire extension - command files for them.""" + so upgrade must not refuse them while presets are installed.""" from types import SimpleNamespace from specify_cli.integrations._command_upgrade_layout import (