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 e4f2c4e57a..b2f760eaa3 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. 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/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 caf4ba52d1..313c33cccb 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -62,6 +62,39 @@ def _legacy_command_root_changed( return old_had_legacy and new_has_canonical +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). *old_files* and *new_files* are + manifest keys; ``upgrade`` passes ``_planned_command_files()`` as the new + 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(): + return False + 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} + return bool(removed & added) + + def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool: """Return True when the old manifest tracks command files under legacy_dir.""" config = integration.registrar_config or {} 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 b4682b6825..fbf1aa45e0 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -15,11 +15,13 @@ from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys from ._command_upgrade_layout import ( _PresetRegistryUnreadableError, + _command_file_names_changed, _installed_command_presets_affecting_agent, _installed_presets_affecting_agent, _legacy_command_root_changed, _legacy_command_root_upgrade_pending, _manifest_tracks_skill_layout, + _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, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json @@ -188,6 +190,52 @@ 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. + if _command_file_names_changed( + integration, old_manifest.files, _planned_command_files(integration) + ): + 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 diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 4c90d030a1..c15aee9b6d 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -1,6 +1,14 @@ """Kiro CLI integration.""" -from ..base import MarkdownIntegration +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from ..base import _HOOK_COMMAND_NOTE, MarkdownIntegration +from ..manifest import IntegrationManifest # Kiro CLI file-based prompts do NOT support any argument-substitution syntax, @@ -11,6 +19,31 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)" +def format_kiro_command_name(cmd_name: str) -> str: + """Convert a command name to the hyphenated form Kiro CLI can invoke. + + Kiro CLI runs ``/name`` from ``.kiro/prompts/name.md`` only when the name + has no dots: ``/speckit.plan`` is rejected as an unrecognized slash + command, while ``/speckit-plan`` runs the prompt (issue #4797). + + The function is idempotent: already-formatted names are returned unchanged. + + Examples: + >>> format_kiro_command_name("plan") + 'speckit-plan' + >>> format_kiro_command_name("speckit.plan") + 'speckit-plan' + >>> format_kiro_command_name("speckit.git.commit") + 'speckit-git-commit' + """ + cmd_name = cmd_name.replace(".", "-") + + if not cmd_name.startswith("speckit-"): + cmd_name = f"speckit-{cmd_name}" + + return cmd_name + + class KiroCliIntegration(MarkdownIntegration): key = "kiro-cli" # Kiro CLI keeps everything under a static, isolated agent root @@ -33,4 +66,136 @@ class KiroCliIntegration(MarkdownIntegration): "format": "markdown", "args": _KIRO_ARG_FALLBACK, "extension": ".md", + "format_name": format_kiro_command_name, + "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" + + 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, + 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..e7fffa4a8a 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,179 @@ 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", + ] + + @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 = ( + "---\nhandoffs:\n - label: Plan\n agent: speckit.plan\n---\n" + "When normalizing table names, replace dots with underscores.\n" + "- For each executable hook, output the following:\n" + ) + processed = i.post_process_command_content(content) + assert " agent: speckit-plan\n" in processed + assert "`/speckit-git-commit`" in processed + assert i.post_process_command_content(processed) == processed + + def test_build_exec_args_uses_headless_chat(self): + """``kiro-cli -p`` exits 2 (``unexpected argument '-p'``). + + Kiro CLI runs one prompt through ``chat --no-interactive``, and in + that mode it denies every tool call unless ``--trust-all-tools`` is + set, while still exiting 0. + """ + args = get_integration(self.KEY).build_exec_args( + "/speckit-plan", output_json=False + ) + + assert args == [ + "kiro-cli", + "chat", + "--no-interactive", + "--trust-all-tools", + "/speckit-plan", + ] + assert "-p" not in args + + def test_build_exec_args_forwards_model_and_stream_json(self): + """Kiro has no ``json`` output format; structured output is ``stream-json``.""" + args = get_integration(self.KEY).build_exec_args( + "/speckit-plan", model="claude-sonnet-4.5", output_json=True + ) + + assert args == [ + "kiro-cli", + "chat", + "--no-interactive", + "--trust-all-tools", + "--model", + "claude-sonnet-4.5", + "--output-format", + "stream-json", + "/speckit-plan", + ] + + def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch): + """Operator-injected flags reach ``chat`` ahead of the positional prompt.""" + monkeypatch.setenv( + "SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--agent reviewer" + ) + args = get_integration(self.KEY).build_exec_args( + "/speckit-plan", output_json=False + ) + + assert args == [ + "kiro-cli", + "chat", + "--no-interactive", + "--trust-all-tools", + "--agent", + "reviewer", + "/speckit-plan", + ] + def test_registrar_config(self): """Override base assertion: kiro-cli uses a prose fallback for args because Kiro CLI file-based prompts do not natively substitute @@ -124,6 +299,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.""" @@ -148,4 +370,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..0e88bfcacd 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 overrides the core ``speckit.plan`` command.""" + import yaml + + preset_src = tmp_path / preset_id + (preset_src / "commands").mkdir(parents=True) + (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( + yaml.dump({ + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": "Command Preset", + "version": "1.0.0", + "description": "Test preset with a command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.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") @@ -297,6 +352,195 @@ 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 replaces them, including + enabled extension prompts, with ``speckit-.md``, but a + user-modified one blocks it.""" + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + 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. + original = dotted_plan.read_bytes() + + 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_bytes() == original + b"my note\n" + + 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")) == [] + assert (prompts / "speckit-plan.md").is_file() + assert (prompts / "speckit-git-commit.md").is_file() + + 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)] + ) + 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", "--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 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.""" + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + specify = project / ".specify" + (specify / "extensions" / "git" / "extension.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() + registry = json.loads( + (specify / "extensions" / ".registry").read_text(encoding="utf-8") + ) + 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"), + [ + (["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), + (["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( + self, old_files, new_files, expected + ): + """Commands added and dropped in the same release are not a rename, + so upgrade must not refuse them while presets are installed.""" + from types import SimpleNamespace + + from specify_cli.integrations._command_upgrade_layout import ( + _command_file_names_changed, + ) + + integration = SimpleNamespace(registrar_config={"dir": ".kiro/prompts"}) + + def files(names): + return {f".kiro/prompts/{name}" for name in names} + + assert _command_file_names_changed( + integration, files(old_files), files(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.""" project = _init_project(tmp_path, "qodercli") 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.""" 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()