From 88c9c1328b75a9731ce64a195482dee4b3cfa95f Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 30 Sep 2026 09:13:28 +0000 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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 ( From 3bcee7e9f75867cca0fc6144e258439a9006c0d9 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Sat, 3 Oct 2026 17:45:18 +0000 Subject: [PATCH 12/30] fix(integrations): keep Kiro prompts tracked when a command source is missing register_commands skips a missing source and returns only the names it wrote. The command-mode registry update then replaced that agent's registered_commands entry with the short list, or dropped the entry when nothing was written. The prompt stayed on disk, and extension remove no longer deleted it. The same drop hit a hyphenated prompt an earlier pass had already written, and an alias from that source. Keep a previously registered name when this pass did not write it and the manifest still declares it. A name the manifest no longer declares is not kept, because removal deletes the formatted path and another extension may now own that file. Retirement is unchanged: only names written this pass, and only once that pass's replacement exists. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 45 +++- .../integrations/test_command_upgrade.py | 200 ++++++++++++++++++ 2 files changed, 239 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 0f3cdc7a2b..2178555c5d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3980,13 +3980,46 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool if not isinstance(registered_commands, dict): registered_commands = {} new_registered = copy.deepcopy(registered_commands) - if registered: - new_registered[agent_name] = registered + # register_commands skips a missing source and returns + # only the names it wrote; it does not raise. Replacing + # the agent's list with that return value (or dropping + # the entry when nothing was written) untracks a prompt + # that is still on disk, and extension removal then + # cannot delete it. Keep a previously registered name + # when this pass did not write it and the manifest + # still declares it. A name that was never registered + # is not added. A name the manifest no longer declares + # is not kept: removal deletes the formatted path, and + # another extension may now own that file. Retirement + # below still runs only for names written this pass, + # and only once that pass's replacement file exists + # (#4797, #2948). + declared_names: Set[str] = set() + for command in manifest.commands: + if not isinstance(command, dict): + continue + primary_name = command.get("name") + if isinstance(primary_name, str): + declared_names.add(primary_name) + aliases = command.get("aliases") or [] + if isinstance(aliases, list): + declared_names.update( + alias for alias in aliases + if isinstance(alias, str) + ) + previous = self._valid_name_list( + new_registered.get(agent_name) + ) + kept = [ + name for name in previous + if name in registered or name in declared_names + ] + merged = kept + [ + name for name in registered if name not in kept + ] + if merged: + new_registered[agent_name] = merged else: - # Registration returned empty list (e.g., corrupted - # manifest pointing at missing command files). Clear - # stale entry so later cleanup doesn't try to remove - # files that were never written. new_registered.pop(agent_name, None) if new_registered != registered_commands: updates["registered_commands"] = new_registered diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 0e88bfcacd..2cdd230306 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -436,6 +436,206 @@ def test_upgrade_keeps_dotted_kiro_prompts_when_reregistration_fails( ) assert "kiro-cli" in registry["extensions"]["git"]["registered_commands"] + def _kiro_git_commands(self, project): + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text( + encoding="utf-8" + ) + ) + return list( + registry["extensions"]["git"]["registered_commands"]["kiro-cli"] + ) + + def test_upgrade_keeps_tracking_when_one_kiro_command_source_is_missing( + self, tmp_path, monkeypatch + ): + """A valid manifest whose command source is missing must not drop + that command's dotted prompt from the registry. + + ``register_commands`` returns only the commands it wrote, so a + partial list used to replace ``registered_commands["kiro-cli"]`` + before retirement. The missing command's dotted prompt stayed on + disk and ``extension remove`` left it there (#4797). + """ + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + prompts = project / ".kiro" / "prompts" + before = self._kiro_git_commands(project) + assert "speckit.git.commit" in before + assert (prompts / "speckit.git.commit.md").is_file() + ( + project / ".specify" / "extensions" / "git" / "commands" + / "speckit.git.commit.md" + ).unlink() + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + + assert (prompts / "speckit.git.commit.md").is_file() + assert not (prompts / "speckit-git-commit.md").exists() + assert (prompts / "speckit-git-feature.md").is_file() + assert not (prompts / "speckit.git.feature.md").exists() + assert (prompts / "speckit-plan.md").is_file() + assert set(self._kiro_git_commands(project)) == set(before) + assert "speckit.git.commit" in self._kiro_git_commands(project) + + result = _run_in_project( + project, ["extension", "remove", "git", "--force"] + ) + assert result.exit_code == 0, result.output + assert not (prompts / "speckit.git.commit.md").exists() + assert not (prompts / "speckit-git-feature.md").exists() + assert (prompts / "speckit-plan.md").is_file() + + def test_upgrade_keeps_tracking_when_every_kiro_command_source_is_missing( + self, tmp_path, monkeypatch + ): + """An empty command registration must not drop dotted prompts that + were already registered. + + With every extension command source gone, registration returns + nothing and used to pop ``registered_commands["kiro-cli"]``. The + dotted prompts remained, untracked, so removing the extension left + them behind (#4797). + """ + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + prompts = project / ".kiro" / "prompts" + before = self._kiro_git_commands(project) + assert before + sources = list( + (project / ".specify" / "extensions" / "git" / "commands").glob( + "*.md" + ) + ) + assert sources + for source in sources: + source.unlink() + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + + assert (prompts / "speckit-plan.md").is_file() + assert not (prompts / "speckit.plan.md").exists() + for name in before: + assert (prompts / f"{name}.md").is_file(), name + assert not (prompts / f"{name.replace('.', '-')}.md").exists(), name + assert set(self._kiro_git_commands(project)) == set(before) + + result = _run_in_project( + project, ["extension", "remove", "git", "--force"] + ) + assert result.exit_code == 0, result.output + for name in before: + assert not (prompts / f"{name}.md").exists(), name + assert (prompts / "speckit-plan.md").is_file() + + def test_upgrade_keeps_tracking_when_a_rewritten_kiro_source_disappears( + self, tmp_path, monkeypatch + ): + """A hyphenated prompt already written stays tracked when a later + pass cannot write it, and so does an alias from the same source. + + The dotted file is already gone. The live files are + ``speckit-git-commit.md`` and the alias prompt. Dropping either + name would leave that file behind after extension removal (#4797). + """ + import yaml + + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + 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 + prompts = project / ".kiro" / "prompts" + assert (prompts / "speckit-git-commit.md").is_file() + assert (prompts / "speckit-git-c.md").is_file() + assert not (prompts / "speckit.git.commit.md").exists() + before = self._kiro_git_commands(project) + assert {"speckit.git.commit", "speckit-git-c"} <= set(before) + + ( + project / ".specify" / "extensions" / "git" / "commands" + / "speckit.git.commit.md" + ).unlink() + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + + assert (prompts / "speckit-git-commit.md").is_file() + assert (prompts / "speckit-git-c.md").is_file() + assert not (prompts / "speckit.git.commit.md").exists() + assert set(self._kiro_git_commands(project)) == set(before) + + result = _run_in_project( + project, ["extension", "remove", "git", "--force"] + ) + assert result.exit_code == 0, result.output + assert not (prompts / "speckit-git-commit.md").exists() + assert not (prompts / "speckit-git-c.md").exists() + assert (prompts / "speckit-plan.md").is_file() + + def test_upgrade_drops_a_kiro_command_the_manifest_no_longer_declares( + self, tmp_path, monkeypatch + ): + """A command the manifest no longer provides is dropped from the + registry. + + ``extension remove`` deletes the formatted prompt for every name + still tracked. Keeping ``speckit.git.commit`` after the extension + stops providing it would unlink ``speckit-git-commit.md`` even + when another extension now owns that path. + """ + import yaml + + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + 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 + before = self._kiro_git_commands(project) + assert {"speckit.git.commit", "speckit-git-c"} <= set(before) + + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["provides"]["commands"] = [ + command for command in manifest["provides"]["commands"] + if command.get("name") != "speckit.git.commit" + ] + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + ( + project / ".specify" / "extensions" / "git" / "commands" + / "speckit.git.commit.md" + ).unlink() + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + after = set(self._kiro_git_commands(project)) + assert "speckit.git.commit" not in after + assert "speckit-git-c" not in after + assert "speckit.git.feature" in after + prompts = project / ".kiro" / "prompts" + assert (prompts / "speckit-git-feature.md").is_file() + assert (prompts / "speckit-plan.md").is_file() + @pytest.mark.parametrize("activate", ["use", "switch"]) def test_activating_kiro_after_secondary_upgrade_retires_dotted_prompts( self, tmp_path, monkeypatch, activate From 62732e8cb31dbf272c8c056b82509e22dcce7e35 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Mon, 5 Oct 2026 14:05:05 +0000 Subject: [PATCH 13/30] fix(integrations): keep Kiro prompts that hyphenate to one file format_kiro_command_name replaces dots and is not injective. speckit.foo.bar-baz and speckit.foo-bar.baz both become speckit-foo-bar-baz, and so can another command's alias. Registration overwrote one prompt, retired both dotted files, and removing either extension deleted the shared replacement. Install rejects a new command or alias that hyphenates onto another command or a core prompt. A command's own alias may reuse its hyphenated name, because both render the same body. A pair that is already installed is left in place: registration warns, does not write either prompt, and does not retire either dotted file, and the extension's other commands still migrate. Enable refuses before it flips the flag. Removal writes the remaining owner's source back into the shared file when that source can be read, then deletes only the removed command's own file. A name the manifest drops is rewritten the same way, including when the other owner is disabled. Presets skip a colliding command, including the reconcile pass, while an exact core or extension override still writes. Skills follow the same rule. The whole upgrade still exits 0. Preserving the shared file was the alternative to refusing the migration, and the core rename does not depend on one colliding extension. A collision check that cannot read generic settings does not replace that enable error, and the filtered command list is passed only when a name was dropped, so one failing extension still does not abort the rest. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- docs/reference/integrations.md | 2 +- src/specify_cli/agents.py | 21 +- src/specify_cli/extensions/__init__.py | 909 +++++++++++++++++- src/specify_cli/extensions/command_enable.py | 29 +- .../integrations/kiro_cli/__init__.py | 8 + src/specify_cli/presets/_manager_commands.py | 91 +- .../integrations/test_command_upgrade.py | 309 ++++++ tests/test_extensions.py | 101 ++ 8 files changed, 1442 insertions(+), 28 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index b2f760eaa3..14282965bc 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. 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` | +| [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)). Extension commands that hyphenate to the same prompt (for example `speckit.foo.bar-baz` and `speckit.foo-bar.baz`) are rejected at install. If both are already installed, registration leaves each prompt in place instead of overwriting one and deleting the other. Removing one extension writes the remaining owner's prompt back into the shared file when that source can be read, then deletes only the removed extension's own file. If the source cannot be read, the shared file is left in place. | | [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/agents.py b/src/specify_cli/agents.py index 6016773e51..4276e4b88c 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1352,7 +1352,10 @@ def register_commands_for_non_skill_agents( return results def unregister_commands( - self, registered_commands: Dict[str, List[str]], project_root: Path + self, + registered_commands: Dict[str, List[str]], + project_root: Path, + preserved_output_names: Dict[str, set[str]] | None = None, ) -> None: """Remove previously registered command files from agent directories. @@ -1361,9 +1364,16 @@ def unregister_commands( commands left behind after an ``integration upgrade`` are cleaned up as well. + ``preserved_output_names`` maps an agent to formatted stems that + must stay. Extension removal passes stems still owned by a core + command or by another extension, so a shared hyphenated prompt is + not deleted with the first of its owners. The raw command name is + still removed when it differs from that stem. + Args: registered_commands: Dict mapping agent names to command name lists project_root: Path to project root + preserved_output_names: Agent -> formatted stems to leave in place """ self._ensure_configs() for agent_name, cmd_names in registered_commands.items(): @@ -1384,12 +1394,19 @@ def unregister_commands( if legacy_dir.exists() and legacy_dir != commands_dir: dirs_to_clean.append(legacy_dir) + preserved = { + os.path.normcase(name) + for name in (preserved_output_names or {}).get(agent_name, ()) + if isinstance(name, str) + } for cmd_name in cmd_names: output_name = self._compute_output_name( agent_name, cmd_name, agent_config ) - names_to_clean = [output_name] + names_to_clean = [] + if os.path.normcase(output_name) not in preserved: + names_to_clean.append(output_name) if output_name != cmd_name and self._is_safe_command_name(cmd_name): names_to_clean.append(cmd_name) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 2178555c5d..e9bff470c5 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1198,19 +1198,31 @@ def _get_installed_command_name_map( def _normalize_shadow_name(name: str) -> str: """Normalize a command/alias name to its on-disk output form. - Agent integrations (Cline, Forge, Junie) and the SKILL.md output-name - computation (``CommandRegistrar._compute_output_name``) all collapse - dots to hyphens and prefix a bare name with ``speckit-``, so - ``speckit.taskstoissues``, ``taskstoissues``, and + Agent integrations (Cline, Forge, Junie, Kiro CLI) and the SKILL.md + output-name computation (``CommandRegistrar._compute_output_name``) + all collapse dots to hyphens and prefix a bare name with + ``speckit-``, so ``speckit.taskstoissues``, ``taskstoissues``, and ``speckit-taskstoissues`` are distinct alias spellings that land on - the same on-disk command name. Normalize before comparing so all of - them are caught, not just the exact dotted spelling. + the same on-disk command name. The same fold merges command names + that differ as manifest strings: ``speckit.foo.bar-baz`` and + ``speckit.foo-bar.baz`` both become ``speckit-foo-bar-baz``. + Normalize before comparing so all of them are caught, not just the + exact dotted spelling. """ hyphenated = name.replace(".", "-") if not hyphenated.startswith("speckit-"): hyphenated = f"speckit-{hyphenated}" return hyphenated + @staticmethod + def _shadow_key(name: str) -> str: + """Comparison key for a normalized command path. + + ``os.path.normcase`` matches ``CommandRegistrar._same_lexical_path``, + so on Windows ``speckit-Foo`` and ``speckit-foo`` are one file. + """ + return os.path.normcase(ExtensionManager._normalize_shadow_name(name)) + def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: """Reject installs that would shadow core or installed extension commands. @@ -1221,22 +1233,78 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: on-disk form (see ``_normalize_shadow_name``) against core command names rather than relying on ``_get_installed_command_name_map``, which only knows about installed extensions. + + Exact equality is not enough. Hyphenation is not injective, so two + legal names can write one prompt. A command and its own aliases are + one body and may share a path. Two command entries, or a name owned + by another extension, may not. """ declared_names = self._collect_manifest_command_names(manifest) installed_names = self._get_installed_command_name_map( exclude_extension_id=manifest.id ) core_shadow_names = { - self._normalize_shadow_name(f"speckit.{name}") for name in CORE_COMMAND_NAMES + self._shadow_key(f"speckit.{name}") for name in CORE_COMMAND_NAMES } collisions = [] + entry_groups: Dict[str, List[tuple[str, str]]] = {} + for cmd in manifest.commands: + if not isinstance(cmd, dict): + continue + primary = cmd.get("name") + if not isinstance(primary, str) or not primary: + continue + raws = [primary] + aliases = cmd.get("aliases") or [] + if isinstance(aliases, list): + raws.extend(alias for alias in aliases if isinstance(alias, str) and alias) + for raw in raws: + entry_groups.setdefault(self._shadow_key(raw), []).append((primary, raw)) + for group in entry_groups.values(): + primaries = {primary for primary, _raw in group} + if len(primaries) < 2: + continue + rendered = ", ".join(sorted({raw for _primary, raw in group})) + display = self._normalize_shadow_name(group[0][1]) + collisions.append(f"{rendered} (all write '{display}')") + + installed_norm: Dict[str, tuple[str, str]] = {} + for ext_id in self.registry.keys(): + if ext_id == manifest.id: + continue + other = self.get_extension(ext_id) + if other is None: + continue + for cmd in other.commands: + if not isinstance(cmd, dict): + continue + raws = [] + primary = cmd.get("name") + if isinstance(primary, str) and primary: + raws.append(primary) + aliases = cmd.get("aliases") or [] + if isinstance(aliases, list): + raws.extend( + alias for alias in aliases if isinstance(alias, str) and alias + ) + for raw in raws: + installed_norm.setdefault(self._shadow_key(raw), (ext_id, raw)) + for name in sorted(declared_names): + key = self._shadow_key(name) if name in installed_names: collisions.append( f"{name} (already provided by extension '{installed_names[name]}')" ) - elif self._normalize_shadow_name(name) in core_shadow_names: + elif key in installed_norm and installed_norm[key][1] != name: + owner, raw = installed_norm[key] + display = self._normalize_shadow_name(name) + collisions.append( + f"{name} (writes '{display}', already provided by extension " + f"'{owner}' as '{raw}')" + ) + elif key in core_shadow_names: collisions.append(f"{name} (conflicts with core command)") if collisions: @@ -1245,6 +1313,586 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: + "\n- ".join(collisions) ) + def _command_output_name(self, agent_name: str, command_name: str) -> Optional[str]: + """Return the command filename stem ``agent_name`` would write. + + ``None`` when the agent is unknown or the name could escape its + command directory. The stem matches registration and removal. + """ + registrar = CommandRegistrar(self.project_root) + inner = registrar._registrar + inner._ensure_configs() + agent_config = inner.AGENT_CONFIGS.get(agent_name) + if not agent_config or not inner._is_safe_command_name(command_name): + return None + return inner._compute_output_name(agent_name, command_name, agent_config) + + @staticmethod + def _is_core_command_spelling(name: str) -> bool: + """True for the spellings that override one core command. + + ``plan``, ``speckit.plan``, and ``speckit-plan`` are one command. + A preset uses those to replace the core prompt. A different + manifest string that only hyphenates to the same file is not. + """ + for stem in CORE_COMMAND_NAMES: + if name in {stem, f"speckit.{stem}", f"speckit-{stem}"}: + return True + return False + + def _raw_command_file_exists(self, agent_name: str, raw: str) -> bool: + """True when this agent still has the unformatted command file. + + The hyphenated path is shared, so it does not prove this command + owns it. The dotted (or otherwise unformatted) filename does. + """ + wrapper = CommandRegistrar(self.project_root) + inner = wrapper._registrar + inner._ensure_configs() + agent_config = inner.AGENT_CONFIGS.get(agent_name) + if not agent_config or not inner._is_safe_command_name(raw): + return False + extension = agent_config.get("extension") + if not isinstance(extension, str): + return False + candidates: List[Path] = [] + try: + commands_dir = inner._resolve_agent_dir( + agent_name, agent_config, self.project_root + ) + candidates.append(commands_dir / f"{raw}{extension}") + except (OSError, ValueError): + pass + try: + from ..integrations import get_integration + + integration = get_integration(agent_name) + except (ImportError, ValueError, KeyError): + integration = None + if integration is not None: + legacy_dir = getattr(integration, "legacy_flat_command_dir", None) + legacy_ext = getattr(integration, "legacy_flat_command_extension", None) + if ( + isinstance(legacy_dir, str) + and legacy_dir + and isinstance(legacy_ext, str) + and legacy_ext + ): + candidates.append( + self.project_root / legacy_dir / f"{raw}{legacy_ext}" + ) + return any(path.is_file() or path.is_symlink() for path in candidates) + + def _iter_preset_command_names( + self, agent_name: Optional[str] = None + ) -> List[tuple[str, str, str, Dict[str, Any]]]: + """Preset id, primary, raw name, and the command template. + + Enabled presets contribute every command. A disabled preset + contributes a name only when ``agent_name`` still has it + registered or the unformatted command file is still on disk. + A missing or unreadable preset contributes nothing. ``raw`` is + the primary or one alias; aliases of one template share that + template so they stay one command. + """ + from ..presets._manifest import PresetManifest, PresetValidationError + from ..presets._registry import PresetRegistry + + found: List[tuple[str, str, str, Dict[str, Any]]] = [] + registry = PresetRegistry(self.project_root / ".specify" / "presets") + for preset_id, metadata in registry.list().items(): + if not isinstance(metadata, dict): + metadata = {} + enabled = bool(metadata.get("enabled", True)) + registered: Set[str] = set() + if agent_name: + recorded = metadata.get("registered_commands", {}) + if isinstance(recorded, dict): + registered = set( + self._valid_name_list(recorded.get(agent_name)) + ) + manifest_path = registry.packs_dir / preset_id / "preset.yml" + if not manifest_path.is_file(): + continue + try: + manifest = PresetManifest(manifest_path) + except PresetValidationError: + continue + for template in manifest.templates: + if not isinstance(template, dict) or template.get("type") != "command": + continue + primary = template.get("name") + if not isinstance(primary, str) or not primary: + continue + raws = [primary] + aliases = template.get("aliases") or [] + if isinstance(aliases, list): + raws.extend( + alias for alias in aliases + if isinstance(alias, str) and alias + ) + + def include(raw: str) -> bool: + if enabled: + return True + if agent_name is None: + return False + if raw in registered: + return True + return self._raw_command_file_exists(agent_name, raw) + + for raw in raws: + if include(raw): + found.append((preset_id, primary, raw, template)) + return found + + def _command_output_claims( + self, + agent_name: str, + *, + also_enabled: Optional[Set[str]] = None, + pending_preset: Optional[tuple[str, List[tuple[str, str, Dict[str, Any]]]]] = None, + ) -> Dict[str, List[tuple]]: + """Formatted path -> owners. + + Each item is ``(owner, entry, raw, output, command, source_dir)``. + ``entry`` is ``(owner_id, primary)`` so a command and its own + aliases are one owner. A disabled extension or preset is an owner + only when this agent already registered the name or the + unformatted file is still on disk. ``also_enabled`` treats those + ids as enabled, for a check that runs before the flag is flipped. + ``pending_preset`` is a preset that is being registered and may + not be listed yet. + """ + also = also_enabled or set() + claims: Dict[str, List[tuple]] = {} + + def claim( + owner: str, + entry: tuple[str, str], + raw: str, + command: Optional[Dict[str, Any]], + source_dir: Optional[Path], + ) -> None: + output = self._command_output_name(agent_name, raw) + if output is None: + return + claims.setdefault(os.path.normcase(output), []).append( + (owner, entry, raw, output, command, source_dir) + ) + + for core_name in CORE_COMMAND_NAMES: + raw = f"speckit.{core_name}" + claim("core", ("core", raw), raw, None, None) + + for ext_id, metadata in self.registry.list().items(): + if not isinstance(metadata, dict): + metadata = {} + enabled = bool(metadata.get("enabled", True)) or ext_id in also + recorded = metadata.get("registered_commands", {}) + registered = set( + self._valid_name_list(recorded.get(agent_name)) + if isinstance(recorded, dict) else [] + ) + manifest = self.get_extension(ext_id) + source_dir = self.extensions_dir / ext_id + if manifest is None: + for raw in registered: + claim(ext_id, (ext_id, raw), raw, None, None) + continue + + def owns(raw: str) -> bool: + if enabled: + return True + if raw in registered: + return True + return self._raw_command_file_exists(agent_name, raw) + + for cmd in manifest.commands: + if not isinstance(cmd, dict): + continue + primary = cmd.get("name") + if not isinstance(primary, str) or not primary: + continue + raws = [primary] + aliases = cmd.get("aliases") or [] + if isinstance(aliases, list): + raws.extend( + alias for alias in aliases + if isinstance(alias, str) and alias + ) + for raw in raws: + if owns(raw): + claim(ext_id, (ext_id, primary), raw, cmd, source_dir) + + preset_rows = list(self._iter_preset_command_names(agent_name)) + if pending_preset is not None: + pending_id, pending_rows = pending_preset + preset_dir = self.project_root / ".specify" / "presets" / pending_id + for primary, raw, template in pending_rows: + preset_rows.append((pending_id, primary, raw, template)) + # source dir is the template's home; stored on the template + # only when the caller set it. Fall back to the pack dir. + if isinstance(template, dict) and "_source_dir" not in template: + template = {**template, "_source_dir": preset_dir} + preset_rows[-1] = (pending_id, primary, raw, template) + + claimed_primaries_that_override = { + raw for group in claims.values() for _owner, _entry, raw, *_rest in group + } + grouped: Dict[tuple[str, str], List[tuple[str, Dict[str, Any]]]] = {} + for preset_id, primary, raw, template in preset_rows: + grouped.setdefault((preset_id, primary), []).append((raw, template)) + for (preset_id, primary), raws in grouped.items(): + # The whole command is that core or extension command's override. + # Its aliases render the same body and must stay writable. + if ( + self._is_core_command_spelling(primary) + or primary in claimed_primaries_that_override + ): + continue + preset_dir = self.project_root / ".specify" / "presets" / preset_id + for raw, template in raws: + source = template.get("_source_dir") if isinstance(template, dict) else None + if not isinstance(source, Path): + source = preset_dir + claim( + f"preset:{preset_id}", + (f"preset:{preset_id}", primary), + raw, + template, + source, + ) + return claims + + def _hyphenation_collision_blocks( + self, + agent_name: str, + *, + also_enabled: Optional[Set[str]] = None, + pending_preset: Optional[tuple[str, List[tuple[str, str, Dict[str, Any]]]]] = None, + ) -> tuple[Dict[str, Set[str]], Dict[str, Set[str]]]: + """Names that must not be written, by extension id and by preset id. + + A formatted path owned by two command entries, or by an extension + and a core command, is not written. Registration would overwrite + one body, then retirement would delete the other body's old file. + Names of a single command entry share a body and stay writable. + A preset whose primary is exactly an extension command, or a core + spelling, is that command's override and is not a second owner. + An alias of some other preset command is. A disabled extension is + not an owner unless this agent already registered the name or the + unformatted file is still on disk, so installing it for another + agent and disabling it does not block this one. An unreadable + manifest still counts: each name it already registered is treated + as its own command, which can only keep extra files rather than + merge two prompts. + """ + claims = self._command_output_claims( + agent_name, + also_enabled=also_enabled, + pending_preset=pending_preset, + ) + extension_blocks: Dict[str, Set[str]] = {} + preset_blocks: Dict[str, Set[str]] = {} + for group in claims.values(): + entries = {entry for _owner, entry, _raw, *_rest in group} + if len(entries) < 2: + continue + for owner, _entry, raw, *_rest in group: + if owner == "core": + continue + if owner.startswith("preset:"): + preset_blocks.setdefault( + owner.removeprefix("preset:"), set() + ).add(raw) + else: + extension_blocks.setdefault(owner, set()).add(raw) + return extension_blocks, preset_blocks + + @staticmethod + def _without_blocked_commands( + commands: List[Dict[str, Any]], blocked: Set[str] + ) -> List[Dict[str, Any]]: + """Drop command names whose formatted path belongs to someone else. + + A blocked primary is omitted for this pass, aliases included, so + none of that command's files are rewritten or retired. Declared + names stay tracked. An alias that collides on its own is dropped + and the primary is still written. + """ + if not blocked: + return commands + filtered: List[Dict[str, Any]] = [] + for cmd in commands: + if not isinstance(cmd, dict): + continue + primary = cmd.get("name") + if isinstance(primary, str) and primary in blocked: + continue + aliases = cmd.get("aliases") + if not isinstance(aliases, list): + filtered.append(cmd) + continue + kept = [ + alias for alias in aliases + if not (isinstance(alias, str) and alias in blocked) + ] + if len(kept) != len(aliases): + cmd = {**cmd, "aliases": kept} + filtered.append(cmd) + return filtered + + def _warn_hyphenation_collisions(self, extension_id: str, names: Set[str]) -> None: + """Tell the user which commands were left unchanged and why.""" + from .. import console + + shown = ", ".join(sorted(names)) + console.print( + f"[yellow]Warning:[/yellow] Extension '{extension_id}' has command " + f"names that hyphenate to one file ({shown}). Those commands " + "were left unchanged so an existing file is not overwritten " + "and its old file is not removed." + ) + console.print( + "[dim]Rename one of the commands that format to the same file, " + "then register again. Continuing with this extension's other " + "commands.[/dim]" + ) + + def _preserved_command_outputs( + self, + agent_name: str, + *, + removing_extension_ids: Set[str], + ) -> Set[str]: + """Formatted stems removal must leave on disk. + + Anything still claimed by core, by another extension, or by a + preset that is not an exact override stays. A path shared after + hyphenation is deleted only with its last owner (#4797). Exact + overrides are not claims, so removing the extension still removes + that file. + """ + claims = self._command_output_claims(agent_name) + preserved: Set[str] = set() + for group in claims.values(): + for owner, _entry, _raw, output, _command, _source in group: + if owner in removing_extension_ids: + continue + preserved.add(output) + return preserved + + def _outputs_preserved_when_forgetting( + self, agent_name: str, forgotten_raws: Set[str] + ) -> Set[str]: + """Stems that must stay when these raw names are unregistered. + + Used when a preset command is removed. A stem stays when some + other raw still formats to it. An exact core spelling, and a raw + that is exactly an extension command name, keep the previous + delete behavior. + """ + claims = self._command_output_claims(agent_name) + extension_raws = { + raw + for group in claims.values() + for owner, _entry, raw, *_rest in group + if owner != "core" and not str(owner).startswith("preset:") + } + forgotten = {raw for raw in forgotten_raws if isinstance(raw, str)} + preserved: Set[str] = set() + for group in claims.values(): + raws = {raw for _owner, _entry, raw, *_rest in group} + if not (raws - forgotten): + continue + forgotten_here = raws & forgotten + if forgotten_here and all( + self._is_core_command_spelling(raw) or raw in extension_raws + for raw in forgotten_here + ): + continue + for _owner, _entry, _raw, output, *_rest in group: + preserved.add(output) + return preserved + + def _pick_prompt_survivor(self, staying_items: List[tuple]) -> Optional[tuple]: + """Prefer an enabled extension with a readable source, then a preset.""" + + def readable(item: tuple) -> bool: + _owner, _entry, _raw, _output, command, source = item + if not isinstance(command, dict) or not isinstance(source, Path): + return False + file_name = command.get("file") + if not isinstance(file_name, str) or not file_name: + return False + path = source / file_name + return path.is_file() + + extensions = [ + item for item in staying_items + if not str(item[0]).startswith("preset:") + ] + enabled = [] + for item in extensions: + meta = self.registry.get(item[0]) or {} + if meta.get("enabled", True) and readable(item): + enabled.append(item) + pool = enabled or [item for item in extensions if readable(item)] + if pool: + return min(pool, key=lambda item: (str(item[0]), str(item[2]))) + presets = [] + for item in staying_items: + if not str(item[0]).startswith("preset:"): + continue + command = item[4] + strategy = command.get("strategy", "replace") if isinstance(command, dict) else "replace" + if strategy != "replace" or not readable(item): + continue + presets.append(item) + if presets: + return min(presets, key=lambda item: (str(item[0]), str(item[2]))) + return None + + def _rewrite_shared_prompts_from_survivors( + self, + agent_name: str, + removing_extension_ids: Set[str], + *, + only_raws: Optional[Set[str]] = None, + keeping_raws: Optional[Set[str]] = None, + ) -> None: + """Write a remaining owner's body into a shared prompt before removal. + + Keeping the file is not enough: the bytes may be the command that + is about to be deleted. Render the survivor's source into that + path first. When the source cannot be read, leave the bytes and + still refuse to delete the file. + + ``only_raws`` are names the manifest has already stopped declaring, + so they are no longer claims. They still occupy the formatted path. + ``keeping_raws`` are names the same extension still declares, so a + sibling that shares the path is the survivor rather than another + removal. + """ + from .. import console + + claims = self._command_output_claims(agent_name) + dropped_outputs: Set[str] = set() + if only_raws: + for raw in only_raws: + if not isinstance(raw, str): + continue + output = self._command_output_name(agent_name, raw) + if output is not None: + dropped_outputs.add(os.path.normcase(output)) + wrapper = CommandRegistrar(self.project_root) + + def _kept(item: tuple) -> bool: + if item[0] == "core": + return False + if keeping_raws is not None and item[2] in keeping_raws: + return True + return item[0] not in removing_extension_ids + + for group in claims.values(): + staying_items = [item for item in group if _kept(item)] + removing_items = [ + item for item in group + if item[0] in removing_extension_ids + and not _kept(item) + and (only_raws is None or item[2] in only_raws) + ] + group_output = group[0][3] + dropped_here = os.path.normcase(group_output) in dropped_outputs + if not staying_items or not (removing_items or dropped_here): + continue + output = group_output + survivor = self._pick_prompt_survivor(staying_items) + if survivor is None: + console.print( + f"[yellow]Warning:[/yellow] Shared prompt '{output}' still " + "belongs to another command, but that command's source " + "could not be read. Left the existing file in place." + ) + continue + owner, _entry, raw, _output, command, source = survivor + file_name = command.get("file") if isinstance(command, dict) else None + if not isinstance(file_name, str) or not isinstance(source, Path): + continue + synthetic = {"name": raw, "file": file_name, "aliases": []} + try: + if str(owner).startswith("preset:"): + from ..agents import CommandRegistrar as AgentRegistrar + + written = AgentRegistrar().register_commands( + agent_name, + [synthetic], + str(owner).removeprefix("preset:"), + source, + self.project_root, + ) + else: + manifest = self.get_extension(str(owner)) + if manifest is None: + written = [] + else: + written = wrapper.register_commands_for_agent( + agent_name, + manifest, + source, + self.project_root, + commands=[synthetic], + ) + except (OSError, ValueError, ExtensionError) as exc: + console.print( + f"[yellow]Warning:[/yellow] Could not restore shared prompt " + f"'{output}' from '{owner}': {exc}. Left the existing file " + "in place." + ) + continue + if raw not in written: + console.print( + f"[yellow]Warning:[/yellow] Shared prompt '{output}' still " + f"belongs to '{owner}', but its command source could not " + "be read. Left the existing file in place." + ) + + def _unlink_unclaimed_commands( + self, + agent_name: str, + extension_id: str, + names: List[str], + still_declared: Set[str], + registrar: "CommandRegistrar", + ) -> None: + """Delete prompts whose manifest owner is gone and nobody else claims. + + A shared hyphenated path is restored from the remaining owner, then + left in place. The dropped command's own dotted filename is still + removed. + """ + if not names: + return + self._rewrite_shared_prompts_from_survivors( + agent_name, + {extension_id}, + only_raws=set(names), + keeping_raws=still_declared, + ) + preserved = self._preserved_command_outputs( + agent_name, removing_extension_ids={extension_id} + ) + for raw in still_declared: + output = self._command_output_name(agent_name, raw) + if output is not None: + preserved.add(output) + registrar.unregister_commands( + {agent_name: list(names)}, + self.project_root, + preserved_output_names={agent_name: preserved}, + ) + @staticmethod def _load_extensionignore( source_dir: Path, @@ -1570,12 +2218,122 @@ def _register_commands_for_active_agent( only_agent=active_agent, ) + def _skill_name_owned_elsewhere( + self, + extension_id: str, + skill_name: str, + *, + ignore_extension_ids: Optional[Set[str]] = None, + ) -> bool: + """True when another extension's command hyphenates to this skill. + + A disabled extension counts only when it already registered the + skill. ``ignore_extension_ids`` is the set also being removed, so + agent cleanup can delete a skill once every owner is leaving. + """ + ignored = ignore_extension_ids or set() + for other_id, metadata in self.registry.list().items(): + if other_id == extension_id or other_id in ignored: + continue + if not isinstance(metadata, dict): + metadata = {} + enabled = bool(metadata.get("enabled", True)) + registered = metadata.get("registered_skills", []) + registered_names = set( + registered if isinstance(registered, list) else [] + ) + manifest = self.get_extension(other_id) + declared = False + if manifest is not None: + for cmd in manifest.commands: + if not isinstance(cmd, dict): + continue + primary = cmd.get("name") + if ( + isinstance(primary, str) + and self._skill_name_for_command(primary) == skill_name + ): + declared = True + break + elif skill_name in registered_names: + declared = True + if not declared: + continue + if enabled or skill_name in registered_names: + return True + return False + + def _rewrite_shared_skills_from_survivors( + self, + extension_id: str, + skill_names: List[str], + *, + ignore_extension_ids: Optional[Set[str]] = None, + ) -> None: + """Render a remaining extension's skill over a shared directory.""" + ignored = set(ignore_extension_ids or set()) + ignored.add(extension_id) + wanted = {name for name in skill_names if isinstance(name, str)} + if not wanted: + return + survivors: Dict[str, Set[str]] = {} + for other_id, metadata in self.registry.list().items(): + if other_id in ignored: + continue + if not isinstance(metadata, dict): + metadata = {} + enabled = bool(metadata.get("enabled", True)) + registered = metadata.get("registered_skills", []) + registered_names = set( + item for item in registered if isinstance(item, str) + ) if isinstance(registered, list) else set() + manifest = self.get_extension(other_id) + if manifest is None: + continue + names: Set[str] = set() + for cmd in manifest.commands: + if not isinstance(cmd, dict): + continue + primary = cmd.get("name") + if not isinstance(primary, str): + continue + skill_name = self._skill_name_for_command(primary) + if skill_name not in wanted: + continue + if not enabled and skill_name not in registered_names: + continue + source = cmd.get("file") + if not isinstance(source, str): + continue + if not (self.extensions_dir / other_id / source).is_file(): + continue + names.add(primary) + if names and (enabled or names): + survivors[other_id] = names + if not survivors: + return + enabled_ids = [ + ext_id for ext_id in survivors + if (self.registry.get(ext_id) or {}).get("enabled", True) + ] + chosen_id = sorted(enabled_ids or list(survivors))[0] + manifest = self.get_extension(chosen_id) + if manifest is None: + return + self._register_extension_skills( + manifest, + self.extensions_dir / chosen_id, + force=True, + only_names=survivors[chosen_id], + ) + def _register_extension_skills( self, manifest: ExtensionManifest, extension_dir: Path, link_outputs: bool = False, force: bool = False, + only_names: Optional[Set[str]] = None, ) -> List[str]: """Generate SKILL.md files for extension commands as agent skills. @@ -1666,6 +2424,16 @@ def _replacement(match: re.Match[str]) -> str: # Derive skill name from command name using the same hyphenated # convention as hook rendering and preset skill registration. skill_name = self._skill_name_for_command(cmd_name) + if only_names is not None and cmd_name not in only_names: + continue + # Another extension's command hyphenates to this same skill + # directory. Overwriting it would replace that command's body, + # and a later removal would delete the directory for both. + if only_names is None and self._skill_name_owned_elsewhere( + manifest.id, skill_name + ): + self._warn_hyphenation_collisions(manifest.id, {cmd_name}) + continue # Check if skill already exists before creating the directory skill_subdir = skills_dir / skill_name @@ -2208,6 +2976,8 @@ def _unregister_extension_skills( extension_id: str, skills_dir: Optional[Path] = None, generic_hashes: Optional[Dict[str, str]] = None, + *, + also_removing: Optional[Set[str]] = None, ) -> None: """Remove SKILL.md directories for extension skills. @@ -2235,6 +3005,15 @@ def _unregister_extension_skills( for path in (generic_hashes or {}) if isinstance(path, str) and path.endswith("/SKILL.md") } + self._rewrite_shared_skills_from_survivors( + extension_id, skill_names, ignore_extension_ids=also_removing + ) + skill_names = [ + name for name in skill_names + if not self._skill_name_owned_elsewhere( + extension_id, name, ignore_extension_ids=also_removing + ) + ] for skill_subdir in self._find_extension_skill_dirs( skill_names, extension_id, skills_dir=skills_dir ): @@ -3075,6 +3854,15 @@ def rollback_generic_registration() -> None: registered_commands = self._register_commands_for_active_agent( manifest, dest_dir, link_outputs=link_commands ) + # install and `extension add --force` write the new prompt + # but do not go through register_enabled_extensions_for_agent, + # so a dotted file left beside the new one would stay until + # a later upgrade. Retire only names this install wrote. + for registered_agent, registered_names in registered_commands.items(): + if registered_names: + self._retire_legacy_flat_extension_commands( + registered_agent, list(registered_names) + ) # Auto-register extension commands as agent skills when skills mode # was used during project initialisation (feature parity). @@ -3518,8 +4306,20 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if "generic" in safe_commands: safe_commands.pop("generic") if safe_commands: + for agent_name in safe_commands: + self._rewrite_shared_prompts_from_survivors( + agent_name, {extension_id} + ) + preserved_output_names = { + agent_name: self._preserved_command_outputs( + agent_name, removing_extension_ids={extension_id} + ) + for agent_name in safe_commands + } CommandRegistrar().unregister_commands( - safe_commands, self.project_root + safe_commands, + self.project_root, + preserved_output_names=preserved_output_names, ) if metadata: self._remove_generic_artifact_paths(extension_id, metadata) @@ -3714,6 +4514,18 @@ def unregister_agent_artifacts( agent_skills_dir = resolve_skills_dir(self.project_root, agent_name) + removing_extension_ids = { + ext_id + for ext_id, metadata in self.registry.list().items() + if not (enabled_only and not metadata.get("enabled", True)) + } + self._rewrite_shared_prompts_from_survivors( + agent_name, removing_extension_ids + ) + preserved_outputs = self._preserved_command_outputs( + agent_name, removing_extension_ids=removing_extension_ids + ) + for ext_id, metadata in self.registry.list().items(): if enabled_only and not metadata.get("enabled", True): continue @@ -3738,7 +4550,9 @@ def unregister_agent_artifacts( ) if command_names: registrar.unregister_commands( - {agent_name: command_names}, self.project_root + {agent_name: command_names}, + self.project_root, + preserved_output_names={agent_name: preserved_outputs}, ) new_registered = copy.deepcopy(registered_commands) @@ -3769,6 +4583,7 @@ def unregister_agent_artifacts( metadata.get("generic_artifact_hashes") if agent_name == "generic" else None ), + also_removing=removing_extension_ids, ) # Only reconcile registry state when this agent's directory @@ -3935,6 +4750,8 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool agent_skills_dir = _resolve_agent_skills_dir(self.project_root, agent_name) + blocked_by_ext, _preset_blocks = self._hyphenation_collision_blocks(agent_name) + for ext_id, metadata in self.registry.list().items(): if not metadata.get("enabled", True): continue @@ -3965,8 +4782,22 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool deferred_stale_commands: Optional[List[str]] = None if agent_config and not skills_mode_active: + blocked = blocked_by_ext.get(ext_id, set()) + # Pass ``commands`` only when this pass dropped a name. + # Callers and tests replace ``register_commands_for_agent`` + # and still use the original signature (#2950). + register_kwargs: Dict[str, Any] = {} + if blocked: + register_kwargs["commands"] = self._without_blocked_commands( + manifest.commands, blocked + ) + self._warn_hyphenation_collisions(ext_id, blocked) registered = registrar.register_commands_for_agent( - agent_name, manifest, ext_dir, self.project_root + agent_name, + manifest, + ext_dir, + self.project_root, + **register_kwargs, ) if agent_name == "generic": registered = self._complete_generic_refresh( @@ -4014,6 +4845,16 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool name for name in previous if name in registered or name in declared_names ] + dropped = [name for name in previous if name not in kept] + if dropped: + # A name the manifest no longer declares used to + # stay on disk forever: removal only deletes names + # still tracked. Delete it when this extension is + # the only owner. A shared hyphenated path stays, + # because the other command still uses that file. + self._unlink_unclaimed_commands( + agent_name, ext_id, dropped, declared_names, registrar + ) merged = kept + [ name for name in registered if name not in kept ] @@ -4226,7 +5067,14 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool extension_id=ext_id, ) registrar.unregister_commands( - {agent_name: fully_replaced}, self.project_root + {agent_name: fully_replaced}, + self.project_root, + preserved_output_names={ + agent_name: self._preserved_command_outputs( + agent_name, + removing_extension_ids={ext_id}, + ) + }, ) registered_commands = metadata.get( "registered_commands", {} @@ -4247,10 +5095,19 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool updates["registered_commands"] = new_registered if registered: - self._retire_legacy_flat_extension_commands( - agent_name, - registered, - ) + # Colliding names are not written above. Never retire + # them either: the replacement path is another + # command's file, and the old file is still the only + # copy of this command. + blocked = blocked_by_ext.get(ext_id, set()) + to_retire = [ + name for name in registered if name not in blocked + ] + if to_retire: + self._retire_legacy_flat_extension_commands( + agent_name, + to_retire, + ) if agent_name == "generic": hashes = self._generic_artifact_hashes( @@ -4436,14 +5293,19 @@ def register_commands_for_agent( extension_dir: Path, project_root: Path, link_outputs: bool = False, + commands: Optional[List[Dict[str, Any]]] = None, ) -> List[str]: - """Register extension commands for a specific agent.""" + """Register extension commands for a specific agent. + + ``commands`` replaces ``manifest.commands`` when the caller has + already dropped names that would share a formatted path. + """ if agent_name not in self.AGENT_CONFIGS: raise ExtensionError(f"Unsupported agent: {agent_name}") context_note = f"\n\n\n" return self._registrar.register_commands( agent_name, - manifest.commands, + manifest.commands if commands is None else commands, manifest.id, extension_dir, project_root, @@ -4478,10 +5340,17 @@ def register_commands_for_all_agents( ) def unregister_commands( - self, registered_commands: Dict[str, List[str]], project_root: Path + self, + registered_commands: Dict[str, List[str]], + project_root: Path, + preserved_output_names: Optional[Dict[str, Set[str]]] = None, ) -> None: """Remove previously registered command files from agent directories.""" - self._registrar.unregister_commands(registered_commands, project_root) + self._registrar.unregister_commands( + registered_commands, + project_root, + preserved_output_names=preserved_output_names, + ) def register_commands_for_claude( self, diff --git a/src/specify_cli/extensions/command_enable.py b/src/specify_cli/extensions/command_enable.py index 34bab2f279..70c38e93c6 100644 --- a/src/specify_cli/extensions/command_enable.py +++ b/src/specify_cli/extensions/command_enable.py @@ -42,11 +42,36 @@ def extension_enable( console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already enabled[/yellow]") raise typer.Exit(0) - manager.registry.update(extension_id, {"enabled": True}) - from .. import load_init_options init_options = load_init_options(project_root) + if not isinstance(init_options, dict): + init_options = {} + active_agent = init_options.get("ai") + if isinstance(active_agent, str) and active_agent: + try: + blocked_by_ext, _preset_blocks = manager._hyphenation_collision_blocks( + active_agent, also_enabled={extension_id} + ) + except (OSError, ValueError): + # Generic settings can be missing or unreadable. Registration + # below already reports that and puts the flag back. A collision + # check that cannot resolve the agent must not replace that error, + # and a command-less extension has nothing to collide. + blocked_by_ext = {} + blocked_names = blocked_by_ext.get(extension_id, set()) + if blocked_names: + shown = ", ".join(sorted(blocked_names)) + console.print( + f"[red]Error:[/red] Cannot enable " + f"'{_escape_markup(str(display_name))}': " + f"{_escape_markup(shown)} hyphenate to a prompt another " + "command already owns. Rename one of those commands, or " + "remove the other extension, then enable again." + ) + raise typer.Exit(1) + + manager.registry.update(extension_id, {"enabled": True}) if init_options.get("ai") == "generic": try: manifest = manager.get_extension(extension_id) diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index c15aee9b6d..a8873fe9fe 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -27,6 +27,14 @@ def format_kiro_command_name(cmd_name: str) -> str: command, while ``/speckit-plan`` runs the prompt (issue #4797). The function is idempotent: already-formatted names are returned unchanged. + It is not injective. ``speckit.foo.bar-baz`` and ``speckit.foo-bar.baz`` + both become ``speckit-foo-bar-baz``, and so does another command's alias + spelled ``speckit-foo-bar-baz``. Install rejects those names. Registration + does not write or retire either prompt. Removal writes the remaining + owner's body back into the shared file when that source can be read, + and deletes only the removed command's own file. If the source cannot + be read, the shared file stays. A command's own alias may reuse its + hyphenated name, because both render the same body. Examples: >>> format_kiro_command_name("plan") diff --git a/src/specify_cli/presets/_manager_commands.py b/src/specify_cli/presets/_manager_commands.py index 4d665b45dc..af60152037 100644 --- a/src/specify_cli/presets/_manager_commands.py +++ b/src/specify_cli/presets/_manager_commands.py @@ -224,6 +224,43 @@ def _register_commands( ): return {} + if active_agent is not None and commands_to_register: + from ..extensions import ExtensionManager + + pending_rows = [] + for command in commands_to_register: + primary = command.get("name") + if not isinstance(primary, str) or not primary: + continue + raws = [primary] + aliases = command.get("aliases") or [] + if isinstance(aliases, list): + raws.extend( + alias for alias in aliases + if isinstance(alias, str) and alias + ) + for raw in raws: + pending_rows.append((primary, raw, {**command, "_source_dir": preset_dir})) + manager = ExtensionManager(self.project_root) + _extension_blocks, preset_blocks = manager._hyphenation_collision_blocks( + active_agent, + pending_preset=(manifest.id, pending_rows), + ) + blocked = preset_blocks.get(manifest.id, set()) + if blocked: + from .._console import console + + shown = ", ".join(sorted(blocked)) + console.print( + f"[yellow]Warning:[/yellow] Preset '{manifest.id}' has " + f"command names that hyphenate to one prompt ({shown}). " + "Those commands were left unchanged so an existing prompt " + "is not overwritten." + ) + commands_to_register = ExtensionManager._without_blocked_commands( + commands_to_register, blocked + ) + return registrar.register_commands_for_all_agents( commands_to_register, manifest.id, @@ -828,7 +865,25 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non return registrar = CommandRegistrar() - registrar.unregister_commands(registered_commands, self.project_root) + preserved_output_names = None + try: + from ..extensions import ExtensionManager + + manager = ExtensionManager(self.project_root) + preserved_output_names = { + agent_name: manager._outputs_preserved_when_forgetting( + agent_name, + {name for name in names if isinstance(name, str)}, + ) + for agent_name, names in registered_commands.items() + } + except ImportError: + preserved_output_names = None + registrar.unregister_commands( + registered_commands, + self.project_root, + preserved_output_names=preserved_output_names, + ) def _merge_pack_registered_commands( self, pack_id: str, written: Optional[Dict[str, List[str]]] @@ -998,11 +1053,41 @@ def record_written(written: Dict[str, List[str]]) -> None: if extra_agents and isinstance(resolved_agent, str): extra_agents = set(extra_agents) - {resolved_agent} + # Same hyphenation guard as ``_register_commands``. Reconciliation + # runs after that pass and would otherwise write the command the + # guard just refused to overwrite. + blocked_names: Set[str] = set() + if isinstance(only_agent, str) and only_agent: + try: + from ..extensions import ExtensionManager + except ImportError: + ExtensionManager = None # type: ignore[assignment] + if ExtensionManager is not None: + _extension_blocks, preset_blocks = ExtensionManager( + self.project_root + )._hyphenation_collision_blocks(only_agent) + for names in preset_blocks.values(): + blocked_names.update(names) + + def without_blocked_aliases(template: Dict[str, Any]) -> Dict[str, Any]: + aliases = template.get("aliases") + if not isinstance(aliases, list) or not blocked_names: + return template + kept = [ + alias for alias in aliases + if not (isinstance(alias, str) and alias in blocked_names) + ] + if len(kept) == len(aliases): + return template + return {**template, "aliases": kept} + # Cache registry and manifests outside the loop to avoid # repeated filesystem reads for each command name. presets_by_priority = list(self.registry.list_by_priority()) for cmd_name in command_names: + if cmd_name in blocked_names: + continue layers = resolver.collect_all_layers(cmd_name, "command") if not layers: continue @@ -1027,7 +1112,7 @@ def record_written(written: Dict[str, List[str]]) -> None: for tmpl in manifest.templates: if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": written = self._register_for_non_skill_agents( - registrar, [tmpl], manifest.id, pack_dir, + registrar, [without_blocked_aliases(tmpl)], manifest.id, pack_dir, only_agent=only_agent, extra_agents=extra_agents, ) record_written(written) @@ -1144,7 +1229,7 @@ def record_written(written: Dict[str, List[str]]) -> None: composed_file.write_text(composed, encoding="utf-8") written = self._register_for_non_skill_agents( registrar, - [{**tmpl, "file": f".composed/{cmd_name}.md"}], + [without_blocked_aliases({**tmpl, "file": f".composed/{cmd_name}.md"})], manifest.id, pack_dir, only_agent=only_agent, extra_agents=extra_agents, ) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 2cdd230306..26510ebebf 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -635,6 +635,315 @@ def test_upgrade_drops_a_kiro_command_the_manifest_no_longer_declares( prompts = project / ".kiro" / "prompts" assert (prompts / "speckit-git-feature.md").is_file() assert (prompts / "speckit-plan.md").is_file() + # Sole owner: dropping the command deletes the prompt. Leaving it + # would orphan a file extension remove can no longer see. + assert not (prompts / "speckit-git-commit.md").exists() + assert not (prompts / "speckit-git-c.md").exists() + + def _plant_extension(self, project, ext_id, commands, *, enabled=True, registered=None): + """Write an extension that is already installed, skipping install checks.""" + import yaml + + from specify_cli.extensions import ExtensionManager + + ext_dir = project / ".specify" / "extensions" / ext_id + (ext_dir / "commands").mkdir(parents=True, exist_ok=True) + manifest_commands = [] + registered_names = [] + for command in commands: + filename = f"{command['name']}.md" + (ext_dir / "commands" / filename).write_text( + command["body"], encoding="utf-8" + ) + entry = {"name": command["name"], "file": f"commands/{filename}"} + if command.get("aliases"): + entry["aliases"] = list(command["aliases"]) + manifest_commands.append(entry) + registered_names.append(command["name"]) + registered_names.extend(command.get("aliases") or []) + (ext_dir / "extension.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": ext_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": manifest_commands}, + }), encoding="utf-8") + ExtensionManager(project).registry.add(ext_id, { + "version": "1.0.0", + "source": "local", + "enabled": enabled, + "priority": 10, + "registered_commands": { + "kiro-cli": list(registered) if registered is not None else registered_names + }, + "registered_skills": [], + }) + + def test_upgrade_preserves_already_installed_hyphen_collision( + self, tmp_path, monkeypatch + ): + """Two installed commands that hyphenate to one prompt stay put. + + Install rejects this pair. A project that already has both must + not overwrite one body and must not delete either dotted file. + """ + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + prompts = project / ".kiro" / "prompts" + foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" + other_body = "---\ndescription: Other\n---\n\nOTHER-BODY\n" + self._plant_extension(project, "foo", [ + {"name": "speckit.foo.bar-baz", "body": foo_body}, + {"name": "speckit.foo.other", "body": other_body}, + ]) + self._plant_extension(project, "foo-bar", [ + {"name": "speckit.foo-bar.baz", "body": bar_body}, + ]) + (prompts / "speckit.foo.bar-baz.md").write_text(foo_body, encoding="utf-8") + (prompts / "speckit.foo-bar.baz.md").write_text(bar_body, encoding="utf-8") + (prompts / "speckit.foo.other.md").write_text(other_body, encoding="utf-8") + sentinel = prompts / "speckit-foo-bar-baz.md" + sentinel.write_text("SENTINEL\n", encoding="utf-8") + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert "hyphenate to one file" in result.output + assert (prompts / "speckit.foo.bar-baz.md").read_text(encoding="utf-8") == foo_body + assert (prompts / "speckit.foo-bar.baz.md").read_text(encoding="utf-8") == bar_body + assert sentinel.read_text(encoding="utf-8") == "SENTINEL\n" + assert (prompts / "speckit-foo-other.md").is_file() + assert "OTHER-BODY" in (prompts / "speckit-foo-other.md").read_text(encoding="utf-8") + assert not (prompts / "speckit.foo.other.md").exists() + assert (prompts / "speckit-plan.md").is_file() + assert not (prompts / "speckit.plan.md").exists() + + result = _run_in_project(project, ["extension", "remove", "foo", "--force"]) + assert result.exit_code == 0, result.output + assert not (prompts / "speckit.foo.bar-baz.md").exists() + assert (prompts / "speckit.foo-bar.baz.md").read_text(encoding="utf-8") == bar_body + shared = sentinel.read_text(encoding="utf-8") + assert "BAR-BODY" in shared + assert "FOO-BODY" not in shared + assert "SENTINEL" not in shared + assert not (prompts / "speckit-foo-other.md").exists() + assert (prompts / "speckit-plan.md").is_file() + + result = _run_in_project(project, ["extension", "remove", "foo-bar", "--force"]) + assert result.exit_code == 0, result.output + assert not sentinel.exists() + assert not (prompts / "speckit.foo-bar.baz.md").exists() + assert (prompts / "speckit-plan.md").is_file() + + def test_disabled_extension_without_kiro_files_does_not_block_migration( + self, tmp_path, monkeypatch + ): + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + prompts = project / ".kiro" / "prompts" + foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" + self._plant_extension(project, "foo", [ + {"name": "speckit.foo.bar-baz", "body": foo_body}, + ]) + self._plant_extension( + project, + "foo-bar", + [{"name": "speckit.foo-bar.baz", "body": "---\ndescription: Bar\n---\n\nBAR-BODY\n"}], + enabled=False, + registered=[], + ) + (prompts / "speckit.foo.bar-baz.md").write_text(foo_body, encoding="utf-8") + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert "hyphenate to one file" not in result.output + assert not (prompts / "speckit.foo.bar-baz.md").exists() + written = (prompts / "speckit-foo-bar-baz.md").read_text(encoding="utf-8") + assert "FOO-BODY" in written + + def test_enable_refuses_extension_that_hyphenates_to_an_owned_prompt( + self, tmp_path, monkeypatch + ): + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + self._plant_extension(project, "foo", [ + {"name": "speckit.foo.bar-baz", "body": "---\ndescription: Foo\n---\n\nFOO-BODY\n"}, + ]) + self._plant_extension( + project, + "foo-bar", + [{"name": "speckit.foo-bar.baz", "body": "---\ndescription: Bar\n---\n\nBAR-BODY\n"}], + enabled=False, + ) + result = _run_in_project(project, ["extension", "enable", "foo-bar"]) + assert result.exit_code != 0 + assert "hyphenate to a prompt" in result.output + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + assert registry["extensions"]["foo-bar"]["enabled"] is False + + def test_upgrade_migrates_alias_that_hyphenates_to_its_own_command( + self, tmp_path, monkeypatch + ): + import yaml + + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + 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-commit"] + 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 + prompts = project / ".kiro" / "prompts" + assert (prompts / "speckit-git-commit.md").is_file() + assert not (prompts / "speckit.git.commit.md").exists() + + def test_force_reinstall_retires_dotted_prompts_written_before_hyphenation( + self, tmp_path, monkeypatch + ): + """``extension add --force`` writes the hyphenated prompt and retires + the dotted file left by an install that ran before the rename.""" + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + prompts = project / ".kiro" / "prompts" + assert (prompts / "speckit.git.commit.md").is_file() + + result = _run_in_project(project, ["extension", "add", "git", "--force"]) + assert result.exit_code == 0, result.output + assert (prompts / "speckit-git-commit.md").is_file() + assert not (prompts / "speckit.git.commit.md").exists() + # Reinstall retires the extension's own dotted file. It does not + # run integration setup, so the core prompt stays dotted until upgrade. + assert (prompts / "speckit.plan.md").is_file() + assert not (prompts / "speckit-plan.md").exists() + + def test_dropping_a_shared_command_rewrites_the_remaining_owners_body( + self, tmp_path, monkeypatch + ): + """A name the manifest drops must not leave its body in a shared file. + + The dropped command is already gone from the claim set, so removal + has to match the formatted path rather than the old manifest entry. + The other command stays disabled, so this pass does not register it. + Its source is still written back. A file only the dropped command + owned is deleted. + """ + import yaml + + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) + 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 + prompts = project / ".kiro" / "prompts" + shared = prompts / "speckit-git-commit.md" + assert shared.is_file() + assert (prompts / "speckit-git-c.md").is_file() + previous = shared.read_text(encoding="utf-8") + + other_body = "---\ndescription: Other\n---\n\nOTHER-BODY\n" + self._plant_extension( + project, + "other", + [{ + "name": "speckit.other.keep", + "body": other_body, + "aliases": ["speckit-git-commit"], + }], + enabled=False, + registered=["speckit-git-commit"], + ) + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["provides"]["commands"] = [ + command for command in manifest["provides"]["commands"] + if command.get("name") != "speckit.git.commit" + ] + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + ( + project / ".specify" / "extensions" / "git" / "commands" + / "speckit.git.commit.md" + ).unlink() + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + rewritten = shared.read_text(encoding="utf-8") + assert "OTHER-BODY" in rewritten + assert rewritten != previous + assert not (prompts / "speckit-other-keep.md").exists() + assert not (prompts / "speckit-git-c.md").exists() + assert not (prompts / "speckit.git.commit.md").exists() + assert "speckit.git.commit" not in self._kiro_git_commands(project) + assert (prompts / "speckit-plan.md").is_file() + + def test_preset_hyphen_collision_does_not_overwrite_extension_prompt( + self, tmp_path + ): + import yaml + + project = _init_project(tmp_path, "kiro-cli") + prompts = project / ".kiro" / "prompts" + self._plant_extension(project, "foo", [ + { + "name": "speckit.foo.bar-baz", + "body": "---\ndescription: Foo\n---\n\nFOO-BODY\n", + }, + ]) + shared = prompts / "speckit-foo-bar-baz.md" + shared.write_text("SENTINEL\n", encoding="utf-8") + preset_src = tmp_path / "collide-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.foo-bar.baz.md").write_text( + "---\ndescription: Preset\n---\n\nPRESET-BODY\n", + encoding="utf-8", + ) + (preset_src / "preset.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "preset": { + "id": "collide-preset", + "name": "Collide", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [{ + "type": "command", + "name": "speckit.foo-bar.baz", + "file": "commands/speckit.foo-bar.baz.md", + }]}, + }), encoding="utf-8") + + result = _run_in_project( + project, ["preset", "add", "--dev", str(preset_src)] + ) + assert result.exit_code == 0, result.output + assert "hyphenate to one prompt" in result.output + assert shared.read_text(encoding="utf-8") == "SENTINEL\n" + + def test_preset_core_override_still_writes_kiro_prompt(self, tmp_path): + preset_src = _write_command_preset(tmp_path, "cmd-preset") + project = _init_project(tmp_path, "kiro-cli") + result = _run_in_project( + project, ["preset", "add", "--dev", str(preset_src)] + ) + assert result.exit_code == 0, result.output + plan = (project / ".kiro" / "prompts" / "speckit-plan.md").read_text( + encoding="utf-8" + ) + assert "Overridden plan content" in plan @pytest.mark.parametrize("activate", ["use", "switch"]) def test_activating_kiro_after_secondary_upgrade_retires_dotted_prompts( diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b512742389..8f764a2122 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3213,6 +3213,107 @@ def test_install_rejects_equivalent_alias_shadowing_core_command( with pytest.raises(ValidationError, match="conflicts with core command"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + def _write_named_extension(self, root, ext_id, commands): + """Write an extension dir. ``commands`` is (name, aliases) pairs.""" + ext_dir = root / ext_id + (ext_dir / "commands").mkdir(parents=True) + manifest_commands = [] + for name, aliases in commands: + filename = f"{name}.md" + (ext_dir / "commands" / filename).write_text( + "---\ndescription: Test\n---\n\nBody\n", encoding="utf-8" + ) + entry = {"name": name, "file": f"commands/{filename}"} + if aliases: + entry["aliases"] = list(aliases) + manifest_commands.append(entry) + (ext_dir / "extension.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": ext_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": manifest_commands}, + })) + return ext_dir + + def test_install_rejects_hyphenated_cross_extension_collision( + self, temp_dir, project_dir + ): + """``speckit.foo.bar-baz`` and ``speckit.foo-bar.baz`` are one file.""" + first = self._write_named_extension( + temp_dir, "foo", [("speckit.foo.bar-baz", [])] + ) + second = self._write_named_extension( + temp_dir, "foo-bar", [("speckit.foo-bar.baz", [])] + ) + manager = ExtensionManager(project_dir) + manager.install_from_directory(first, "0.1.0", register_commands=False) + with pytest.raises(ValidationError, match="speckit-foo-bar-baz"): + manager.install_from_directory(second, "0.1.0", register_commands=False) + + def test_install_rejects_alias_that_hyphenates_to_another_command( + self, temp_dir, project_dir + ): + ext_dir = self._write_named_extension( + temp_dir, + "foo", + [ + ("speckit.foo.alpha", ["speckit-foo-bar-baz"]), + ("speckit.foo.bar-baz", []), + ], + ) + manager = ExtensionManager(project_dir) + with pytest.raises(ValidationError, match="all write 'speckit-foo-bar-baz'"): + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + def test_install_allows_alias_that_hyphenates_to_its_own_command( + self, temp_dir, project_dir + ): + ext_dir = self._write_named_extension( + temp_dir, "foo", [("speckit.foo.commit", ["speckit-foo-commit"])] + ) + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + assert manifest.id == "foo" + + def test_install_allows_distinct_commands_and_core_suffix( + self, temp_dir, project_dir + ): + """``speckit.foo.plan`` is not the core ``plan`` command.""" + ext_dir = self._write_named_extension( + temp_dir, + "foo", + [("speckit.foo.bar-baz", []), ("speckit.foo.qux", []), ("speckit.foo.plan", [])], + ) + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + assert {cmd["name"] for cmd in manifest.commands} == { + "speckit.foo.bar-baz", + "speckit.foo.qux", + "speckit.foo.plan", + } + + def test_force_reinstall_of_same_extension_is_not_a_self_collision( + self, temp_dir, project_dir + ): + ext_dir = self._write_named_extension( + temp_dir, "foo", [("speckit.foo.commit", ["speckit-foo-commit"])] + ) + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False, force=True + ) + assert manifest.id == "foo" + def test_remove_extension(self, extension_dir, project_dir): """Test removing an installed extension.""" manager = ExtensionManager(project_dir) From eab0910a130cc1152f0195f8aaff636eb4abeba8 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Tue, 6 Oct 2026 08:05:44 +0000 Subject: [PATCH 14/30] fix(integrations): skip Kiro prompts that share a file instead of rewriting 62732e8c kept extension commands that hyphenate to one prompt (speckit.foo.bar-baz and speckit.foo-bar.baz) by rewriting the shared file from whichever owner remained, across removal, enable, presets and skills. That rewriting is replaced by rules that leave such files alone. Install and update still reject a command or alias that writes the same file as a core command or another installed extension once dots become hyphens. For a pair installed before that check, or an alias such as plan from Spec Kit 1.0.7 and earlier, registration for Kiro CLI and Qoder skips the extension through the per-extension error path from #2950. It writes and retires nothing, the registry stays as it was, and the warning names the other writer. Once one extension of a pair is removed, the other migrates on the next pass. An alias such as speckit-plan has its prompt where the renamed core speckit.plan goes, so integration upgrade refuses the rename while one is registered, as it does for preset overrides. Extension removal keeps a core command's file only when the integration manifest tracks it: before the rename it deletes that alias's own prompt, so a dev-mode symlink is not left dangling. Presets and extension enable are back to main's code. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- docs/reference/extensions.md | 2 + docs/reference/integrations.md | 2 +- src/specify_cli/agents.py | 9 +- src/specify_cli/extensions/__init__.py | 1025 +++-------------- src/specify_cli/extensions/command_enable.py | 29 +- .../integrations/_command_upgrade_layout.py | 32 + .../integrations/command_upgrade.py | 24 +- .../integrations/kiro_cli/__init__.py | 12 +- src/specify_cli/presets/_manager_commands.py | 91 +- .../integrations/test_command_upgrade.py | 416 ++++--- 10 files changed, 504 insertions(+), 1138 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index e39ff9d697..f2685fea4d 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -32,6 +32,8 @@ specify extension add Installs an extension from the catalog, a URL, or a local directory. Extension commands are registered with the active AI coding agent integration. For `generic`, invocations use the configured `--commands-dir`: flat command files by default, or `speckit-/SKILL.md` with `--skills`. The deprecated core `speckit.taskstoissues` command remains available during migration and will be removed in a future minor release. It warns on invocation and continues its existing workflow without installing or enabling the extension automatically. For GitHub issue tracking, run `specify extension add github` and use the recommended `/speckit.github.taskstoissues` command (slash-hyphen integrations: `/speckit-github-taskstoissues`; Codex, ZCode, and Command Code skills: `$speckit-github-taskstoissues`; Kimi: `/skill:speckit-github-taskstoissues`). +Installation is refused when a command or alias would write the same file as a core command or another installed extension's command once dots become hyphens, as Cline, Forge, Junie, Kiro CLI and skills-based integrations name their files (for example `speckit.foo.bar-baz` and `speckit.foo-bar.baz`). An extension installed before this check that shares such a file is not registered for Kiro CLI or Qoder CLI, and keeps its existing files, until it is updated or removed, or the extension it shares the file with is removed. + If a generic integration refresh cannot produce every extension invocation (for example, because a command or skill is user-modified or its source is missing), it warns and restores that extension's prior registered artifacts. Other extensions can still refresh. An unqualified catalog install still selects the advertised current version. diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index e61cec3ab4..8d33bdf2bb 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. 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)). Extension commands that hyphenate to the same prompt (for example `speckit.foo.bar-baz` and `speckit.foo-bar.baz`) are rejected at install. If both are already installed, registration leaves each prompt in place instead of overwriting one and deleting the other. Removing one extension writes the remaining owner's prompt back into the shared file when that source can be read, then deletes only the removed extension's own file. If the source cannot be read, the shared file is left in place. | +| [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)). `specify integration upgrade kiro-cli` renames existing prompts, and refuses before changing files while presets have commands registered for Kiro CLI, or while an extension alias such as `speckit-plan` (accepted by Spec Kit 1.0.7 and earlier) already has its own prompt where a core prompt goes. An extension installed before Spec Kit rejected names that hyphenate to an existing prompt is not registered for Kiro CLI and keeps its existing prompts: two extensions with `speckit.foo.bar-baz` and `speckit.foo-bar.baz` stay that way until one is removed, and one with an alias such as `plan` until it is updated or removed. | | [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/agents.py b/src/specify_cli/agents.py index 4276e4b88c..5a140dab89 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1365,10 +1365,11 @@ def unregister_commands( cleaned up as well. ``preserved_output_names`` maps an agent to formatted stems that - must stay. Extension removal passes stems still owned by a core - command or by another extension, so a shared hyphenated prompt is - not deleted with the first of its owners. The raw command name is - still removed when it differs from that stem. + must stay. Extension removal passes the core command files the + integration installed for an agent that renamed its command files + (see ``ExtensionManager._core_command_files``), so an older alias + that now formats to one of them does not delete it. The raw command + name is still removed when it differs from that stem. Args: registered_commands: Dict mapping agent names to command name lists diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e9bff470c5..6ee79459ac 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1235,7 +1235,7 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: which only knows about installed extensions. Exact equality is not enough. Hyphenation is not injective, so two - legal names can write one prompt. A command and its own aliases are + legal names can write one file. A command and its own aliases are one body and may share a path. Two command entries, or a name owned by another extension, may not. """ @@ -1246,63 +1246,40 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: core_shadow_names = { self._shadow_key(f"speckit.{name}") for name in CORE_COMMAND_NAMES } + installed_shadow_names: Dict[str, str] = {} + for installed_name in installed_names: + installed_shadow_names.setdefault( + self._shadow_key(installed_name), installed_name + ) collisions = [] + # _collect_manifest_command_names() has validated every entry. entry_groups: Dict[str, List[tuple[str, str]]] = {} for cmd in manifest.commands: - if not isinstance(cmd, dict): - continue - primary = cmd.get("name") - if not isinstance(primary, str) or not primary: - continue - raws = [primary] - aliases = cmd.get("aliases") or [] - if isinstance(aliases, list): - raws.extend(alias for alias in aliases if isinstance(alias, str) and alias) - for raw in raws: - entry_groups.setdefault(self._shadow_key(raw), []).append((primary, raw)) + primary = cmd["name"] + for raw in [primary, *(cmd.get("aliases") or [])]: + entry_groups.setdefault(self._shadow_key(raw), []).append( + (primary, raw) + ) for group in entry_groups.values(): - primaries = {primary for primary, _raw in group} - if len(primaries) < 2: + if len({primary for primary, _raw in group}) < 2: continue rendered = ", ".join(sorted({raw for _primary, raw in group})) display = self._normalize_shadow_name(group[0][1]) collisions.append(f"{rendered} (all write '{display}')") - installed_norm: Dict[str, tuple[str, str]] = {} - for ext_id in self.registry.keys(): - if ext_id == manifest.id: - continue - other = self.get_extension(ext_id) - if other is None: - continue - for cmd in other.commands: - if not isinstance(cmd, dict): - continue - raws = [] - primary = cmd.get("name") - if isinstance(primary, str) and primary: - raws.append(primary) - aliases = cmd.get("aliases") or [] - if isinstance(aliases, list): - raws.extend( - alias for alias in aliases if isinstance(alias, str) and alias - ) - for raw in raws: - installed_norm.setdefault(self._shadow_key(raw), (ext_id, raw)) - for name in sorted(declared_names): key = self._shadow_key(name) if name in installed_names: collisions.append( f"{name} (already provided by extension '{installed_names[name]}')" ) - elif key in installed_norm and installed_norm[key][1] != name: - owner, raw = installed_norm[key] - display = self._normalize_shadow_name(name) + elif key in installed_shadow_names: + other = installed_shadow_names[key] collisions.append( - f"{name} (writes '{display}', already provided by extension " - f"'{owner}' as '{raw}')" + f"{name} (writes '{self._normalize_shadow_name(name)}', " + f"already provided by extension '{installed_names[other]}' " + f"as '{other}')" ) elif key in core_shadow_names: collisions.append(f"{name} (conflicts with core command)") @@ -1313,586 +1290,6 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: + "\n- ".join(collisions) ) - def _command_output_name(self, agent_name: str, command_name: str) -> Optional[str]: - """Return the command filename stem ``agent_name`` would write. - - ``None`` when the agent is unknown or the name could escape its - command directory. The stem matches registration and removal. - """ - registrar = CommandRegistrar(self.project_root) - inner = registrar._registrar - inner._ensure_configs() - agent_config = inner.AGENT_CONFIGS.get(agent_name) - if not agent_config or not inner._is_safe_command_name(command_name): - return None - return inner._compute_output_name(agent_name, command_name, agent_config) - - @staticmethod - def _is_core_command_spelling(name: str) -> bool: - """True for the spellings that override one core command. - - ``plan``, ``speckit.plan``, and ``speckit-plan`` are one command. - A preset uses those to replace the core prompt. A different - manifest string that only hyphenates to the same file is not. - """ - for stem in CORE_COMMAND_NAMES: - if name in {stem, f"speckit.{stem}", f"speckit-{stem}"}: - return True - return False - - def _raw_command_file_exists(self, agent_name: str, raw: str) -> bool: - """True when this agent still has the unformatted command file. - - The hyphenated path is shared, so it does not prove this command - owns it. The dotted (or otherwise unformatted) filename does. - """ - wrapper = CommandRegistrar(self.project_root) - inner = wrapper._registrar - inner._ensure_configs() - agent_config = inner.AGENT_CONFIGS.get(agent_name) - if not agent_config or not inner._is_safe_command_name(raw): - return False - extension = agent_config.get("extension") - if not isinstance(extension, str): - return False - candidates: List[Path] = [] - try: - commands_dir = inner._resolve_agent_dir( - agent_name, agent_config, self.project_root - ) - candidates.append(commands_dir / f"{raw}{extension}") - except (OSError, ValueError): - pass - try: - from ..integrations import get_integration - - integration = get_integration(agent_name) - except (ImportError, ValueError, KeyError): - integration = None - if integration is not None: - legacy_dir = getattr(integration, "legacy_flat_command_dir", None) - legacy_ext = getattr(integration, "legacy_flat_command_extension", None) - if ( - isinstance(legacy_dir, str) - and legacy_dir - and isinstance(legacy_ext, str) - and legacy_ext - ): - candidates.append( - self.project_root / legacy_dir / f"{raw}{legacy_ext}" - ) - return any(path.is_file() or path.is_symlink() for path in candidates) - - def _iter_preset_command_names( - self, agent_name: Optional[str] = None - ) -> List[tuple[str, str, str, Dict[str, Any]]]: - """Preset id, primary, raw name, and the command template. - - Enabled presets contribute every command. A disabled preset - contributes a name only when ``agent_name`` still has it - registered or the unformatted command file is still on disk. - A missing or unreadable preset contributes nothing. ``raw`` is - the primary or one alias; aliases of one template share that - template so they stay one command. - """ - from ..presets._manifest import PresetManifest, PresetValidationError - from ..presets._registry import PresetRegistry - - found: List[tuple[str, str, str, Dict[str, Any]]] = [] - registry = PresetRegistry(self.project_root / ".specify" / "presets") - for preset_id, metadata in registry.list().items(): - if not isinstance(metadata, dict): - metadata = {} - enabled = bool(metadata.get("enabled", True)) - registered: Set[str] = set() - if agent_name: - recorded = metadata.get("registered_commands", {}) - if isinstance(recorded, dict): - registered = set( - self._valid_name_list(recorded.get(agent_name)) - ) - manifest_path = registry.packs_dir / preset_id / "preset.yml" - if not manifest_path.is_file(): - continue - try: - manifest = PresetManifest(manifest_path) - except PresetValidationError: - continue - for template in manifest.templates: - if not isinstance(template, dict) or template.get("type") != "command": - continue - primary = template.get("name") - if not isinstance(primary, str) or not primary: - continue - raws = [primary] - aliases = template.get("aliases") or [] - if isinstance(aliases, list): - raws.extend( - alias for alias in aliases - if isinstance(alias, str) and alias - ) - - def include(raw: str) -> bool: - if enabled: - return True - if agent_name is None: - return False - if raw in registered: - return True - return self._raw_command_file_exists(agent_name, raw) - - for raw in raws: - if include(raw): - found.append((preset_id, primary, raw, template)) - return found - - def _command_output_claims( - self, - agent_name: str, - *, - also_enabled: Optional[Set[str]] = None, - pending_preset: Optional[tuple[str, List[tuple[str, str, Dict[str, Any]]]]] = None, - ) -> Dict[str, List[tuple]]: - """Formatted path -> owners. - - Each item is ``(owner, entry, raw, output, command, source_dir)``. - ``entry`` is ``(owner_id, primary)`` so a command and its own - aliases are one owner. A disabled extension or preset is an owner - only when this agent already registered the name or the - unformatted file is still on disk. ``also_enabled`` treats those - ids as enabled, for a check that runs before the flag is flipped. - ``pending_preset`` is a preset that is being registered and may - not be listed yet. - """ - also = also_enabled or set() - claims: Dict[str, List[tuple]] = {} - - def claim( - owner: str, - entry: tuple[str, str], - raw: str, - command: Optional[Dict[str, Any]], - source_dir: Optional[Path], - ) -> None: - output = self._command_output_name(agent_name, raw) - if output is None: - return - claims.setdefault(os.path.normcase(output), []).append( - (owner, entry, raw, output, command, source_dir) - ) - - for core_name in CORE_COMMAND_NAMES: - raw = f"speckit.{core_name}" - claim("core", ("core", raw), raw, None, None) - - for ext_id, metadata in self.registry.list().items(): - if not isinstance(metadata, dict): - metadata = {} - enabled = bool(metadata.get("enabled", True)) or ext_id in also - recorded = metadata.get("registered_commands", {}) - registered = set( - self._valid_name_list(recorded.get(agent_name)) - if isinstance(recorded, dict) else [] - ) - manifest = self.get_extension(ext_id) - source_dir = self.extensions_dir / ext_id - if manifest is None: - for raw in registered: - claim(ext_id, (ext_id, raw), raw, None, None) - continue - - def owns(raw: str) -> bool: - if enabled: - return True - if raw in registered: - return True - return self._raw_command_file_exists(agent_name, raw) - - for cmd in manifest.commands: - if not isinstance(cmd, dict): - continue - primary = cmd.get("name") - if not isinstance(primary, str) or not primary: - continue - raws = [primary] - aliases = cmd.get("aliases") or [] - if isinstance(aliases, list): - raws.extend( - alias for alias in aliases - if isinstance(alias, str) and alias - ) - for raw in raws: - if owns(raw): - claim(ext_id, (ext_id, primary), raw, cmd, source_dir) - - preset_rows = list(self._iter_preset_command_names(agent_name)) - if pending_preset is not None: - pending_id, pending_rows = pending_preset - preset_dir = self.project_root / ".specify" / "presets" / pending_id - for primary, raw, template in pending_rows: - preset_rows.append((pending_id, primary, raw, template)) - # source dir is the template's home; stored on the template - # only when the caller set it. Fall back to the pack dir. - if isinstance(template, dict) and "_source_dir" not in template: - template = {**template, "_source_dir": preset_dir} - preset_rows[-1] = (pending_id, primary, raw, template) - - claimed_primaries_that_override = { - raw for group in claims.values() for _owner, _entry, raw, *_rest in group - } - grouped: Dict[tuple[str, str], List[tuple[str, Dict[str, Any]]]] = {} - for preset_id, primary, raw, template in preset_rows: - grouped.setdefault((preset_id, primary), []).append((raw, template)) - for (preset_id, primary), raws in grouped.items(): - # The whole command is that core or extension command's override. - # Its aliases render the same body and must stay writable. - if ( - self._is_core_command_spelling(primary) - or primary in claimed_primaries_that_override - ): - continue - preset_dir = self.project_root / ".specify" / "presets" / preset_id - for raw, template in raws: - source = template.get("_source_dir") if isinstance(template, dict) else None - if not isinstance(source, Path): - source = preset_dir - claim( - f"preset:{preset_id}", - (f"preset:{preset_id}", primary), - raw, - template, - source, - ) - return claims - - def _hyphenation_collision_blocks( - self, - agent_name: str, - *, - also_enabled: Optional[Set[str]] = None, - pending_preset: Optional[tuple[str, List[tuple[str, str, Dict[str, Any]]]]] = None, - ) -> tuple[Dict[str, Set[str]], Dict[str, Set[str]]]: - """Names that must not be written, by extension id and by preset id. - - A formatted path owned by two command entries, or by an extension - and a core command, is not written. Registration would overwrite - one body, then retirement would delete the other body's old file. - Names of a single command entry share a body and stay writable. - A preset whose primary is exactly an extension command, or a core - spelling, is that command's override and is not a second owner. - An alias of some other preset command is. A disabled extension is - not an owner unless this agent already registered the name or the - unformatted file is still on disk, so installing it for another - agent and disabling it does not block this one. An unreadable - manifest still counts: each name it already registered is treated - as its own command, which can only keep extra files rather than - merge two prompts. - """ - claims = self._command_output_claims( - agent_name, - also_enabled=also_enabled, - pending_preset=pending_preset, - ) - extension_blocks: Dict[str, Set[str]] = {} - preset_blocks: Dict[str, Set[str]] = {} - for group in claims.values(): - entries = {entry for _owner, entry, _raw, *_rest in group} - if len(entries) < 2: - continue - for owner, _entry, raw, *_rest in group: - if owner == "core": - continue - if owner.startswith("preset:"): - preset_blocks.setdefault( - owner.removeprefix("preset:"), set() - ).add(raw) - else: - extension_blocks.setdefault(owner, set()).add(raw) - return extension_blocks, preset_blocks - - @staticmethod - def _without_blocked_commands( - commands: List[Dict[str, Any]], blocked: Set[str] - ) -> List[Dict[str, Any]]: - """Drop command names whose formatted path belongs to someone else. - - A blocked primary is omitted for this pass, aliases included, so - none of that command's files are rewritten or retired. Declared - names stay tracked. An alias that collides on its own is dropped - and the primary is still written. - """ - if not blocked: - return commands - filtered: List[Dict[str, Any]] = [] - for cmd in commands: - if not isinstance(cmd, dict): - continue - primary = cmd.get("name") - if isinstance(primary, str) and primary in blocked: - continue - aliases = cmd.get("aliases") - if not isinstance(aliases, list): - filtered.append(cmd) - continue - kept = [ - alias for alias in aliases - if not (isinstance(alias, str) and alias in blocked) - ] - if len(kept) != len(aliases): - cmd = {**cmd, "aliases": kept} - filtered.append(cmd) - return filtered - - def _warn_hyphenation_collisions(self, extension_id: str, names: Set[str]) -> None: - """Tell the user which commands were left unchanged and why.""" - from .. import console - - shown = ", ".join(sorted(names)) - console.print( - f"[yellow]Warning:[/yellow] Extension '{extension_id}' has command " - f"names that hyphenate to one file ({shown}). Those commands " - "were left unchanged so an existing file is not overwritten " - "and its old file is not removed." - ) - console.print( - "[dim]Rename one of the commands that format to the same file, " - "then register again. Continuing with this extension's other " - "commands.[/dim]" - ) - - def _preserved_command_outputs( - self, - agent_name: str, - *, - removing_extension_ids: Set[str], - ) -> Set[str]: - """Formatted stems removal must leave on disk. - - Anything still claimed by core, by another extension, or by a - preset that is not an exact override stays. A path shared after - hyphenation is deleted only with its last owner (#4797). Exact - overrides are not claims, so removing the extension still removes - that file. - """ - claims = self._command_output_claims(agent_name) - preserved: Set[str] = set() - for group in claims.values(): - for owner, _entry, _raw, output, _command, _source in group: - if owner in removing_extension_ids: - continue - preserved.add(output) - return preserved - - def _outputs_preserved_when_forgetting( - self, agent_name: str, forgotten_raws: Set[str] - ) -> Set[str]: - """Stems that must stay when these raw names are unregistered. - - Used when a preset command is removed. A stem stays when some - other raw still formats to it. An exact core spelling, and a raw - that is exactly an extension command name, keep the previous - delete behavior. - """ - claims = self._command_output_claims(agent_name) - extension_raws = { - raw - for group in claims.values() - for owner, _entry, raw, *_rest in group - if owner != "core" and not str(owner).startswith("preset:") - } - forgotten = {raw for raw in forgotten_raws if isinstance(raw, str)} - preserved: Set[str] = set() - for group in claims.values(): - raws = {raw for _owner, _entry, raw, *_rest in group} - if not (raws - forgotten): - continue - forgotten_here = raws & forgotten - if forgotten_here and all( - self._is_core_command_spelling(raw) or raw in extension_raws - for raw in forgotten_here - ): - continue - for _owner, _entry, _raw, output, *_rest in group: - preserved.add(output) - return preserved - - def _pick_prompt_survivor(self, staying_items: List[tuple]) -> Optional[tuple]: - """Prefer an enabled extension with a readable source, then a preset.""" - - def readable(item: tuple) -> bool: - _owner, _entry, _raw, _output, command, source = item - if not isinstance(command, dict) or not isinstance(source, Path): - return False - file_name = command.get("file") - if not isinstance(file_name, str) or not file_name: - return False - path = source / file_name - return path.is_file() - - extensions = [ - item for item in staying_items - if not str(item[0]).startswith("preset:") - ] - enabled = [] - for item in extensions: - meta = self.registry.get(item[0]) or {} - if meta.get("enabled", True) and readable(item): - enabled.append(item) - pool = enabled or [item for item in extensions if readable(item)] - if pool: - return min(pool, key=lambda item: (str(item[0]), str(item[2]))) - presets = [] - for item in staying_items: - if not str(item[0]).startswith("preset:"): - continue - command = item[4] - strategy = command.get("strategy", "replace") if isinstance(command, dict) else "replace" - if strategy != "replace" or not readable(item): - continue - presets.append(item) - if presets: - return min(presets, key=lambda item: (str(item[0]), str(item[2]))) - return None - - def _rewrite_shared_prompts_from_survivors( - self, - agent_name: str, - removing_extension_ids: Set[str], - *, - only_raws: Optional[Set[str]] = None, - keeping_raws: Optional[Set[str]] = None, - ) -> None: - """Write a remaining owner's body into a shared prompt before removal. - - Keeping the file is not enough: the bytes may be the command that - is about to be deleted. Render the survivor's source into that - path first. When the source cannot be read, leave the bytes and - still refuse to delete the file. - - ``only_raws`` are names the manifest has already stopped declaring, - so they are no longer claims. They still occupy the formatted path. - ``keeping_raws`` are names the same extension still declares, so a - sibling that shares the path is the survivor rather than another - removal. - """ - from .. import console - - claims = self._command_output_claims(agent_name) - dropped_outputs: Set[str] = set() - if only_raws: - for raw in only_raws: - if not isinstance(raw, str): - continue - output = self._command_output_name(agent_name, raw) - if output is not None: - dropped_outputs.add(os.path.normcase(output)) - wrapper = CommandRegistrar(self.project_root) - - def _kept(item: tuple) -> bool: - if item[0] == "core": - return False - if keeping_raws is not None and item[2] in keeping_raws: - return True - return item[0] not in removing_extension_ids - - for group in claims.values(): - staying_items = [item for item in group if _kept(item)] - removing_items = [ - item for item in group - if item[0] in removing_extension_ids - and not _kept(item) - and (only_raws is None or item[2] in only_raws) - ] - group_output = group[0][3] - dropped_here = os.path.normcase(group_output) in dropped_outputs - if not staying_items or not (removing_items or dropped_here): - continue - output = group_output - survivor = self._pick_prompt_survivor(staying_items) - if survivor is None: - console.print( - f"[yellow]Warning:[/yellow] Shared prompt '{output}' still " - "belongs to another command, but that command's source " - "could not be read. Left the existing file in place." - ) - continue - owner, _entry, raw, _output, command, source = survivor - file_name = command.get("file") if isinstance(command, dict) else None - if not isinstance(file_name, str) or not isinstance(source, Path): - continue - synthetic = {"name": raw, "file": file_name, "aliases": []} - try: - if str(owner).startswith("preset:"): - from ..agents import CommandRegistrar as AgentRegistrar - - written = AgentRegistrar().register_commands( - agent_name, - [synthetic], - str(owner).removeprefix("preset:"), - source, - self.project_root, - ) - else: - manifest = self.get_extension(str(owner)) - if manifest is None: - written = [] - else: - written = wrapper.register_commands_for_agent( - agent_name, - manifest, - source, - self.project_root, - commands=[synthetic], - ) - except (OSError, ValueError, ExtensionError) as exc: - console.print( - f"[yellow]Warning:[/yellow] Could not restore shared prompt " - f"'{output}' from '{owner}': {exc}. Left the existing file " - "in place." - ) - continue - if raw not in written: - console.print( - f"[yellow]Warning:[/yellow] Shared prompt '{output}' still " - f"belongs to '{owner}', but its command source could not " - "be read. Left the existing file in place." - ) - - def _unlink_unclaimed_commands( - self, - agent_name: str, - extension_id: str, - names: List[str], - still_declared: Set[str], - registrar: "CommandRegistrar", - ) -> None: - """Delete prompts whose manifest owner is gone and nobody else claims. - - A shared hyphenated path is restored from the remaining owner, then - left in place. The dropped command's own dotted filename is still - removed. - """ - if not names: - return - self._rewrite_shared_prompts_from_survivors( - agent_name, - {extension_id}, - only_raws=set(names), - keeping_raws=still_declared, - ) - preserved = self._preserved_command_outputs( - agent_name, removing_extension_ids={extension_id} - ) - for raw in still_declared: - output = self._command_output_name(agent_name, raw) - if output is not None: - preserved.add(output) - registrar.unregister_commands( - {agent_name: list(names)}, - self.project_root, - preserved_output_names={agent_name: preserved}, - ) - @staticmethod def _load_extensionignore( source_dir: Path, @@ -2218,122 +1615,12 @@ def _register_commands_for_active_agent( only_agent=active_agent, ) - def _skill_name_owned_elsewhere( - self, - extension_id: str, - skill_name: str, - *, - ignore_extension_ids: Optional[Set[str]] = None, - ) -> bool: - """True when another extension's command hyphenates to this skill. - - A disabled extension counts only when it already registered the - skill. ``ignore_extension_ids`` is the set also being removed, so - agent cleanup can delete a skill once every owner is leaving. - """ - ignored = ignore_extension_ids or set() - for other_id, metadata in self.registry.list().items(): - if other_id == extension_id or other_id in ignored: - continue - if not isinstance(metadata, dict): - metadata = {} - enabled = bool(metadata.get("enabled", True)) - registered = metadata.get("registered_skills", []) - registered_names = set( - registered if isinstance(registered, list) else [] - ) - manifest = self.get_extension(other_id) - declared = False - if manifest is not None: - for cmd in manifest.commands: - if not isinstance(cmd, dict): - continue - primary = cmd.get("name") - if ( - isinstance(primary, str) - and self._skill_name_for_command(primary) == skill_name - ): - declared = True - break - elif skill_name in registered_names: - declared = True - if not declared: - continue - if enabled or skill_name in registered_names: - return True - return False - - def _rewrite_shared_skills_from_survivors( - self, - extension_id: str, - skill_names: List[str], - *, - ignore_extension_ids: Optional[Set[str]] = None, - ) -> None: - """Render a remaining extension's skill over a shared directory.""" - ignored = set(ignore_extension_ids or set()) - ignored.add(extension_id) - wanted = {name for name in skill_names if isinstance(name, str)} - if not wanted: - return - survivors: Dict[str, Set[str]] = {} - for other_id, metadata in self.registry.list().items(): - if other_id in ignored: - continue - if not isinstance(metadata, dict): - metadata = {} - enabled = bool(metadata.get("enabled", True)) - registered = metadata.get("registered_skills", []) - registered_names = set( - item for item in registered if isinstance(item, str) - ) if isinstance(registered, list) else set() - manifest = self.get_extension(other_id) - if manifest is None: - continue - names: Set[str] = set() - for cmd in manifest.commands: - if not isinstance(cmd, dict): - continue - primary = cmd.get("name") - if not isinstance(primary, str): - continue - skill_name = self._skill_name_for_command(primary) - if skill_name not in wanted: - continue - if not enabled and skill_name not in registered_names: - continue - source = cmd.get("file") - if not isinstance(source, str): - continue - if not (self.extensions_dir / other_id / source).is_file(): - continue - names.add(primary) - if names and (enabled or names): - survivors[other_id] = names - if not survivors: - return - enabled_ids = [ - ext_id for ext_id in survivors - if (self.registry.get(ext_id) or {}).get("enabled", True) - ] - chosen_id = sorted(enabled_ids or list(survivors))[0] - manifest = self.get_extension(chosen_id) - if manifest is None: - return - self._register_extension_skills( - manifest, - self.extensions_dir / chosen_id, - force=True, - only_names=survivors[chosen_id], - ) - def _register_extension_skills( self, manifest: ExtensionManifest, extension_dir: Path, link_outputs: bool = False, force: bool = False, - only_names: Optional[Set[str]] = None, ) -> List[str]: """Generate SKILL.md files for extension commands as agent skills. @@ -2424,16 +1711,6 @@ def _replacement(match: re.Match[str]) -> str: # Derive skill name from command name using the same hyphenated # convention as hook rendering and preset skill registration. skill_name = self._skill_name_for_command(cmd_name) - if only_names is not None and cmd_name not in only_names: - continue - # Another extension's command hyphenates to this same skill - # directory. Overwriting it would replace that command's body, - # and a later removal would delete the directory for both. - if only_names is None and self._skill_name_owned_elsewhere( - manifest.id, skill_name - ): - self._warn_hyphenation_collisions(manifest.id, {cmd_name}) - continue # Check if skill already exists before creating the directory skill_subdir = skills_dir / skill_name @@ -2976,8 +2253,6 @@ def _unregister_extension_skills( extension_id: str, skills_dir: Optional[Path] = None, generic_hashes: Optional[Dict[str, str]] = None, - *, - also_removing: Optional[Set[str]] = None, ) -> None: """Remove SKILL.md directories for extension skills. @@ -3005,15 +2280,6 @@ def _unregister_extension_skills( for path in (generic_hashes or {}) if isinstance(path, str) and path.endswith("/SKILL.md") } - self._rewrite_shared_skills_from_survivors( - extension_id, skill_names, ignore_extension_ids=also_removing - ) - skill_names = [ - name for name in skill_names - if not self._skill_name_owned_elsewhere( - extension_id, name, ignore_extension_ids=also_removing - ) - ] for skill_subdir in self._find_extension_skill_dirs( skill_names, extension_id, skills_dir=skills_dir ): @@ -3854,15 +3120,6 @@ def rollback_generic_registration() -> None: registered_commands = self._register_commands_for_active_agent( manifest, dest_dir, link_outputs=link_commands ) - # install and `extension add --force` write the new prompt - # but do not go through register_enabled_extensions_for_agent, - # so a dotted file left beside the new one would stay until - # a later upgrade. Retire only names this install wrote. - for registered_agent, registered_names in registered_commands.items(): - if registered_names: - self._retire_legacy_flat_extension_commands( - registered_agent, list(registered_names) - ) # Auto-register extension commands as agent skills when skills mode # was used during project initialisation (feature parity). @@ -4306,20 +3563,19 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if "generic" in safe_commands: safe_commands.pop("generic") if safe_commands: - for agent_name in safe_commands: - self._rewrite_shared_prompts_from_survivors( - agent_name, {extension_id} - ) - preserved_output_names = { - agent_name: self._preserved_command_outputs( - agent_name, removing_extension_ids={extension_id} - ) - for agent_name in safe_commands - } + # An older alias can now format to a core command's file. + # Registration skips it (_shared_command_files), so once the + # integration has installed that file it is the core + # command's, not this extension's (#4797). CommandRegistrar().unregister_commands( safe_commands, self.project_root, - preserved_output_names=preserved_output_names, + preserved_output_names={ + agent_name: self._core_command_files( + agent_name, installed_only=True + ) + for agent_name in safe_commands + }, ) if metadata: self._remove_generic_artifact_paths(extension_id, metadata) @@ -4514,18 +3770,6 @@ def unregister_agent_artifacts( agent_skills_dir = resolve_skills_dir(self.project_root, agent_name) - removing_extension_ids = { - ext_id - for ext_id, metadata in self.registry.list().items() - if not (enabled_only and not metadata.get("enabled", True)) - } - self._rewrite_shared_prompts_from_survivors( - agent_name, removing_extension_ids - ) - preserved_outputs = self._preserved_command_outputs( - agent_name, removing_extension_ids=removing_extension_ids - ) - for ext_id, metadata in self.registry.list().items(): if enabled_only and not metadata.get("enabled", True): continue @@ -4550,9 +3794,7 @@ def unregister_agent_artifacts( ) if command_names: registrar.unregister_commands( - {agent_name: command_names}, - self.project_root, - preserved_output_names={agent_name: preserved_outputs}, + {agent_name: command_names}, self.project_root ) new_registered = copy.deepcopy(registered_commands) @@ -4583,7 +3825,6 @@ def unregister_agent_artifacts( metadata.get("generic_artifact_hashes") if agent_name == "generic" else None ), - also_removing=removing_extension_ids, ) # Only reconcile registry state when this agent's directory @@ -4705,6 +3946,131 @@ def safe_project_dir(relative: str) -> Optional[Path]: return removed + def _core_command_files( + self, agent_name: str, *, installed_only: bool = False + ) -> Set[str]: + """Return core command file stems for an agent that retires legacy files. + + Covers agents whose integration declares legacy flat command files + (Qoder, Kiro CLI): moving to the formatted names can land an older + alias on a core command's file (#4797). Other agents return an empty + set. + + With ``installed_only``, only stems whose file the agent's integration + manifest tracks are returned. Until ``integration upgrade`` renames + Kiro CLI's prompts, ``speckit-plan.md`` can be an older extension + alias rather than the core prompt. If the manifest is missing nothing + is installed; if it can't be read, every stem is returned. + """ + from ..agents import CommandRegistrar + from ..integrations import get_integration + + integration = get_integration(agent_name) + legacy_dir = getattr(integration, "legacy_flat_command_dir", None) + legacy_extension = getattr( + integration, "legacy_flat_command_extension", None + ) + if ( + not isinstance(legacy_dir, str) + or not legacy_dir + or not isinstance(legacy_extension, str) + or not legacy_extension + ): + return set() + + registrar = CommandRegistrar(self.project_root) + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if not agent_config: + return set() + stems = { + registrar._compute_output_name( + agent_name, f"speckit.{name}", agent_config + ) + for name in CORE_COMMAND_NAMES + } + if not installed_only: + return stems + + from ..integrations.manifest import IntegrationManifest + + try: + installed = IntegrationManifest.load(agent_name, self.project_root).files + except FileNotFoundError: + return set() + except (OSError, ValueError): + return stems + return { + stem + for stem in stems + if (Path(agent_config["dir"]) / f"{stem}{agent_config['extension']}") + .as_posix() in installed + } + + def _shared_command_files(self, agent_name: str) -> Dict[str, Dict[str, str]]: + """Return extension command names that would write another command's file. + + Retiring legacy flat files moves each command to its formatted name, + and that format is not injective: ``speckit.foo.bar-baz`` and + ``speckit.foo-bar.baz`` both become ``speckit-foo-bar-baz``, and an + older alias can land on a core command's file (#4797). Install + rejects such names. This finds the ones installed before that check, + for the agents covered by ``_core_command_files``, as extension id -> + {command name: ", also written by "}. + Registration skips those extensions, so neither body overwrites the + other and no old file is retired. + + An enabled extension claims every name its manifest declares. A + disabled one, or one whose manifest cannot be read, claims the names + already registered for the agent, because its files stay on disk. A + command and its own aliases are one owner. + """ + core_files = self._core_command_files(agent_name) + if not core_files: + return {} + + from ..agents import CommandRegistrar + + registrar = CommandRegistrar(self.project_root) + agent_config = registrar.AGENT_CONFIGS[agent_name] + owners: Dict[str, Set[tuple[str, str]]] = { + os.path.normcase(stem): {("", stem)} for stem in core_files + } + claims: List[tuple[str, str, str, str]] = [] + for ext_id, metadata in self.registry.list().items(): + if not isinstance(metadata, dict): + continue + manifest = self.get_extension(ext_id) + primary_of: Dict[str, str] = {} + for command in manifest.commands if manifest is not None else []: + for name in [command["name"], *(command.get("aliases") or [])]: + primary_of[name] = command["name"] + if manifest is not None and metadata.get("enabled", True): + names = list(primary_of) + else: + recorded = metadata.get("registered_commands") + names = self._valid_name_list( + recorded.get(agent_name) if isinstance(recorded, dict) else None + ) + for name in names: + stem = registrar._compute_output_name(agent_name, name, agent_config) + owner = (ext_id, primary_of.get(name, stem)) + owners.setdefault(os.path.normcase(stem), set()).add(owner) + claims.append((*owner, name, stem)) + + shared: Dict[str, Dict[str, str]] = {} + for ext_id, primary, name, stem in claims: + writers = sorted({ + "a core command" if not other_id + else "another command in this extension" if other_id == ext_id + else f"extension '{other_id}'" + for other_id, _ in owners[os.path.normcase(stem)] - {(ext_id, primary)} + }) + if writers: + shared.setdefault(ext_id, {})[name] = ( + f"{stem}, also written by {' and '.join(writers)}" + ) + return shared + def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None: """Register installed, enabled extensions for ``agent_name``. @@ -4750,7 +4116,12 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool agent_skills_dir = _resolve_agent_skills_dir(self.project_root, agent_name) - blocked_by_ext, _preset_blocks = self._hyphenation_collision_blocks(agent_name) + # Registration below changes no claim this reads (#4797). + shared_files = ( + self._shared_command_files(agent_name) + if agent_config and not skills_mode_active + else {} + ) for ext_id, metadata in self.registry.list().items(): if not metadata.get("enabled", True): @@ -4782,22 +4153,25 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool deferred_stale_commands: Optional[List[str]] = None if agent_config and not skills_mode_active: - blocked = blocked_by_ext.get(ext_id, set()) - # Pass ``commands`` only when this pass dropped a name. - # Callers and tests replace ``register_commands_for_agent`` - # and still use the original signature (#2950). - register_kwargs: Dict[str, Any] = {} - if blocked: - register_kwargs["commands"] = self._without_blocked_commands( - manifest.commands, blocked + shared = shared_files.get(ext_id) + if shared: + # Writing would overwrite another command's file, + # and retirement would then delete this command's + # old one (#4797). Leave the extension as it is, as + # for any extension that fails to register. + listing = "; ".join( + f"{name} ({files})" + for name, files in sorted(shared.items()) + ) + raise ExtensionError( + f"{listing}: this extension was not registered " + f"for {agent_name}, so none of its files were " + "written or removed. Update or remove an " + "extension so that only one command writes " + "each file." ) - self._warn_hyphenation_collisions(ext_id, blocked) registered = registrar.register_commands_for_agent( - agent_name, - manifest, - ext_dir, - self.project_root, - **register_kwargs, + agent_name, manifest, ext_dir, self.project_root ) if agent_name == "generic": registered = self._complete_generic_refresh( @@ -4845,16 +4219,6 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool name for name in previous if name in registered or name in declared_names ] - dropped = [name for name in previous if name not in kept] - if dropped: - # A name the manifest no longer declares used to - # stay on disk forever: removal only deletes names - # still tracked. Delete it when this extension is - # the only owner. A shared hyphenated path stays, - # because the other command still uses that file. - self._unlink_unclaimed_commands( - agent_name, ext_id, dropped, declared_names, registrar - ) merged = kept + [ name for name in registered if name not in kept ] @@ -5067,14 +4431,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool extension_id=ext_id, ) registrar.unregister_commands( - {agent_name: fully_replaced}, - self.project_root, - preserved_output_names={ - agent_name: self._preserved_command_outputs( - agent_name, - removing_extension_ids={ext_id}, - ) - }, + {agent_name: fully_replaced}, self.project_root ) registered_commands = metadata.get( "registered_commands", {} @@ -5095,19 +4452,10 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool updates["registered_commands"] = new_registered if registered: - # Colliding names are not written above. Never retire - # them either: the replacement path is another - # command's file, and the old file is still the only - # copy of this command. - blocked = blocked_by_ext.get(ext_id, set()) - to_retire = [ - name for name in registered if name not in blocked - ] - if to_retire: - self._retire_legacy_flat_extension_commands( - agent_name, - to_retire, - ) + self._retire_legacy_flat_extension_commands( + agent_name, + registered, + ) if agent_name == "generic": hashes = self._generic_artifact_hashes( @@ -5293,19 +4641,14 @@ def register_commands_for_agent( extension_dir: Path, project_root: Path, link_outputs: bool = False, - commands: Optional[List[Dict[str, Any]]] = None, ) -> List[str]: - """Register extension commands for a specific agent. - - ``commands`` replaces ``manifest.commands`` when the caller has - already dropped names that would share a formatted path. - """ + """Register extension commands for a specific agent.""" if agent_name not in self.AGENT_CONFIGS: raise ExtensionError(f"Unsupported agent: {agent_name}") context_note = f"\n\n\n" return self._registrar.register_commands( agent_name, - manifest.commands if commands is None else commands, + manifest.commands, manifest.id, extension_dir, project_root, diff --git a/src/specify_cli/extensions/command_enable.py b/src/specify_cli/extensions/command_enable.py index 70c38e93c6..34bab2f279 100644 --- a/src/specify_cli/extensions/command_enable.py +++ b/src/specify_cli/extensions/command_enable.py @@ -42,36 +42,11 @@ def extension_enable( console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already enabled[/yellow]") raise typer.Exit(0) + manager.registry.update(extension_id, {"enabled": True}) + from .. import load_init_options init_options = load_init_options(project_root) - if not isinstance(init_options, dict): - init_options = {} - active_agent = init_options.get("ai") - if isinstance(active_agent, str) and active_agent: - try: - blocked_by_ext, _preset_blocks = manager._hyphenation_collision_blocks( - active_agent, also_enabled={extension_id} - ) - except (OSError, ValueError): - # Generic settings can be missing or unreadable. Registration - # below already reports that and puts the flag back. A collision - # check that cannot resolve the agent must not replace that error, - # and a command-less extension has nothing to collide. - blocked_by_ext = {} - blocked_names = blocked_by_ext.get(extension_id, set()) - if blocked_names: - shown = ", ".join(sorted(blocked_names)) - console.print( - f"[red]Error:[/red] Cannot enable " - f"'{_escape_markup(str(display_name))}': " - f"{_escape_markup(shown)} hyphenate to a prompt another " - "command already owns. Rename one of those commands, or " - "remove the other extension, then enable again." - ) - raise typer.Exit(1) - - manager.registry.update(extension_id, {"enabled": True}) if init_options.get("ai") == "generic": try: manifest = manager.get_extension(extension_id) diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 313c33cccb..8140e40a8c 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from pathlib import Path, PurePath def _manifest_tracks_skill_layout(manifest) -> bool: @@ -95,6 +96,37 @@ def _command_file_names_changed(integration, old_files, new_files) -> bool: return bool(removed & added) +def _extension_commands_at(project_root, integration, rel_paths) -> list[str]: + """Return ``" ()"`` for registered commands at *rel_paths*. + + *rel_paths* are manifest keys. A command is matched by the file + registration wrote before Kiro CLI's rename (#4797), named after the + command itself, so an older alias ``speckit-plan`` matches + ``.kiro/prompts/speckit-plan.md``. Disabled extensions count: their + files stay on disk. + """ + from ..extensions import ExtensionManager + + config = integration.registrar_config or {} + commands_dir, suffix = config.get("dir"), config.get("extension") + if not isinstance(commands_dir, str) or not isinstance(suffix, str): + return [] + targets = {os.path.normcase(rel) for rel in rel_paths} + manager = ExtensionManager(Path(project_root)) + found = [] + for ext_id, metadata in manager.registry.list().items(): + recorded = ( + metadata.get("registered_commands") if isinstance(metadata, dict) else None + ) + if not isinstance(recorded, dict): + continue + for name in manager._valid_name_list(recorded.get(integration.key)): + rel = (PurePath(commands_dir) / f"{name}{suffix}").as_posix() + if os.path.normcase(rel) in targets: + found.append(f"{ext_id} ({name})") + return sorted(found) + + 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 fbf1aa45e0..960d281212 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -16,6 +16,7 @@ from ._command_upgrade_layout import ( _PresetRegistryUnreadableError, _command_file_names_changed, + _extension_commands_at, _installed_command_presets_affecting_agent, _installed_presets_affecting_agent, _legacy_command_root_changed, @@ -197,8 +198,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. + planned_command_files = _planned_command_files(integration) if _command_file_names_changed( - integration, old_manifest.files, _planned_command_files(integration) + integration, old_manifest.files, planned_command_files ): try: affected_presets = _installed_command_presets_affecting_agent( @@ -235,6 +237,26 @@ def integration_upgrade( f" [cyan]specify preset add [/cyan]" ) raise typer.Exit(1) + # Extension commands were written under their own names, and Spec + # Kit 1.0.7 and earlier accepted an alias such as ``speckit-plan``, + # whose file is where the renamed core command goes. Writing the core + # file would replace the extension's (or write through its dev-mode + # symlink into the extension directory). + taken = _extension_commands_at( + project_root, integration, planned_command_files + ) + if taken: + console.print( + f"[red]Error:[/red] Cannot rename '{key}' command files while " + "extension commands use the new file name of a core command: " + f"[bold]{', '.join(taken)}[/bold]." + ) + console.print( + "The upgrade would write a core command over the extension's " + "file, so it is refused before changing files. Update or " + "remove the extension(s), then run the upgrade again." + ) + raise typer.Exit(1) # Ensure shared infrastructure is up to date; --force overwrites existing files. infra_integration = integration diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index a8873fe9fe..0f0eb510d9 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -27,14 +27,10 @@ def format_kiro_command_name(cmd_name: str) -> str: command, while ``/speckit-plan`` runs the prompt (issue #4797). The function is idempotent: already-formatted names are returned unchanged. - It is not injective. ``speckit.foo.bar-baz`` and ``speckit.foo-bar.baz`` - both become ``speckit-foo-bar-baz``, and so does another command's alias - spelled ``speckit-foo-bar-baz``. Install rejects those names. Registration - does not write or retire either prompt. Removal writes the remaining - owner's body back into the shared file when that source can be read, - and deletes only the removed command's own file. If the source cannot - be read, the shared file stays. A command's own alias may reuse its - hyphenated name, because both render the same body. + It is not injective: ``speckit.foo.bar-baz`` and ``speckit.foo-bar.baz`` + both become ``speckit-foo-bar-baz``. Extension install rejects such + names, and registration leaves extensions installed before that check + as they are (``ExtensionManager._shared_command_files``). Examples: >>> format_kiro_command_name("plan") diff --git a/src/specify_cli/presets/_manager_commands.py b/src/specify_cli/presets/_manager_commands.py index af60152037..4d665b45dc 100644 --- a/src/specify_cli/presets/_manager_commands.py +++ b/src/specify_cli/presets/_manager_commands.py @@ -224,43 +224,6 @@ def _register_commands( ): return {} - if active_agent is not None and commands_to_register: - from ..extensions import ExtensionManager - - pending_rows = [] - for command in commands_to_register: - primary = command.get("name") - if not isinstance(primary, str) or not primary: - continue - raws = [primary] - aliases = command.get("aliases") or [] - if isinstance(aliases, list): - raws.extend( - alias for alias in aliases - if isinstance(alias, str) and alias - ) - for raw in raws: - pending_rows.append((primary, raw, {**command, "_source_dir": preset_dir})) - manager = ExtensionManager(self.project_root) - _extension_blocks, preset_blocks = manager._hyphenation_collision_blocks( - active_agent, - pending_preset=(manifest.id, pending_rows), - ) - blocked = preset_blocks.get(manifest.id, set()) - if blocked: - from .._console import console - - shown = ", ".join(sorted(blocked)) - console.print( - f"[yellow]Warning:[/yellow] Preset '{manifest.id}' has " - f"command names that hyphenate to one prompt ({shown}). " - "Those commands were left unchanged so an existing prompt " - "is not overwritten." - ) - commands_to_register = ExtensionManager._without_blocked_commands( - commands_to_register, blocked - ) - return registrar.register_commands_for_all_agents( commands_to_register, manifest.id, @@ -865,25 +828,7 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non return registrar = CommandRegistrar() - preserved_output_names = None - try: - from ..extensions import ExtensionManager - - manager = ExtensionManager(self.project_root) - preserved_output_names = { - agent_name: manager._outputs_preserved_when_forgetting( - agent_name, - {name for name in names if isinstance(name, str)}, - ) - for agent_name, names in registered_commands.items() - } - except ImportError: - preserved_output_names = None - registrar.unregister_commands( - registered_commands, - self.project_root, - preserved_output_names=preserved_output_names, - ) + registrar.unregister_commands(registered_commands, self.project_root) def _merge_pack_registered_commands( self, pack_id: str, written: Optional[Dict[str, List[str]]] @@ -1053,41 +998,11 @@ def record_written(written: Dict[str, List[str]]) -> None: if extra_agents and isinstance(resolved_agent, str): extra_agents = set(extra_agents) - {resolved_agent} - # Same hyphenation guard as ``_register_commands``. Reconciliation - # runs after that pass and would otherwise write the command the - # guard just refused to overwrite. - blocked_names: Set[str] = set() - if isinstance(only_agent, str) and only_agent: - try: - from ..extensions import ExtensionManager - except ImportError: - ExtensionManager = None # type: ignore[assignment] - if ExtensionManager is not None: - _extension_blocks, preset_blocks = ExtensionManager( - self.project_root - )._hyphenation_collision_blocks(only_agent) - for names in preset_blocks.values(): - blocked_names.update(names) - - def without_blocked_aliases(template: Dict[str, Any]) -> Dict[str, Any]: - aliases = template.get("aliases") - if not isinstance(aliases, list) or not blocked_names: - return template - kept = [ - alias for alias in aliases - if not (isinstance(alias, str) and alias in blocked_names) - ] - if len(kept) == len(aliases): - return template - return {**template, "aliases": kept} - # Cache registry and manifests outside the loop to avoid # repeated filesystem reads for each command name. presets_by_priority = list(self.registry.list_by_priority()) for cmd_name in command_names: - if cmd_name in blocked_names: - continue layers = resolver.collect_all_layers(cmd_name, "command") if not layers: continue @@ -1112,7 +1027,7 @@ def without_blocked_aliases(template: Dict[str, Any]) -> Dict[str, Any]: for tmpl in manifest.templates: if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": written = self._register_for_non_skill_agents( - registrar, [without_blocked_aliases(tmpl)], manifest.id, pack_dir, + registrar, [tmpl], manifest.id, pack_dir, only_agent=only_agent, extra_agents=extra_agents, ) record_written(written) @@ -1229,7 +1144,7 @@ def without_blocked_aliases(template: Dict[str, Any]) -> Dict[str, Any]: composed_file.write_text(composed, encoding="utf-8") written = self._register_for_non_skill_agents( registrar, - [without_blocked_aliases({**tmpl, "file": f".composed/{cmd_name}.md"})], + [{**tmpl, "file": f".composed/{cmd_name}.md"}], manifest.id, pack_dir, only_agent=only_agent, extra_agents=extra_agents, ) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 26510ebebf..d31b3cef92 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -635,12 +635,17 @@ def test_upgrade_drops_a_kiro_command_the_manifest_no_longer_declares( prompts = project / ".kiro" / "prompts" assert (prompts / "speckit-git-feature.md").is_file() assert (prompts / "speckit-plan.md").is_file() - # Sole owner: dropping the command deletes the prompt. Leaving it - # would orphan a file extension remove can no longer see. - assert not (prompts / "speckit-git-commit.md").exists() - assert not (prompts / "speckit-git-c.md").exists() - def _plant_extension(self, project, ext_id, commands, *, enabled=True, registered=None): + def _plant_extension( + self, + project, + ext_id, + commands, + *, + enabled=True, + registered=None, + agent="kiro-cli", + ): """Write an extension that is already installed, skipping install checks.""" import yaml @@ -678,20 +683,22 @@ def _plant_extension(self, project, ext_id, commands, *, enabled=True, registere "enabled": enabled, "priority": 10, "registered_commands": { - "kiro-cli": list(registered) if registered is not None else registered_names + agent: list(registered) if registered is not None else registered_names }, "registered_skills": [], }) - def test_upgrade_preserves_already_installed_hyphen_collision( + def test_upgrade_leaves_extensions_that_hyphenate_to_one_prompt_unregistered( self, tmp_path, monkeypatch ): - """Two installed commands that hyphenate to one prompt stay put. - - Install rejects this pair. A project that already has both must - not overwrite one body and must not delete either dotted file. - """ - project = _init_dotted_kiro_project(tmp_path, monkeypatch) + """``speckit.foo.bar-baz`` and ``speckit.foo-bar.baz`` both become + ``speckit-foo-bar-baz.md`` (#4797). Install rejects that pair, but a + project can already have both. Upgrade must not write one body over + the other or retire either dotted prompt, so both extensions stay as + they were until one is removed. Everything else still moves.""" + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "git"] + ) prompts = project / ".kiro" / "prompts" foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" @@ -703,86 +710,267 @@ def test_upgrade_preserves_already_installed_hyphen_collision( self._plant_extension(project, "foo-bar", [ {"name": "speckit.foo-bar.baz", "body": bar_body}, ]) - (prompts / "speckit.foo.bar-baz.md").write_text(foo_body, encoding="utf-8") - (prompts / "speckit.foo-bar.baz.md").write_text(bar_body, encoding="utf-8") - (prompts / "speckit.foo.other.md").write_text(other_body, encoding="utf-8") - sentinel = prompts / "speckit-foo-bar-baz.md" - sentinel.write_text("SENTINEL\n", encoding="utf-8") + planted = { + "speckit.foo.bar-baz.md": foo_body, + "speckit.foo.other.md": other_body, + "speckit.foo-bar.baz.md": bar_body, + } + for name, body in planted.items(): + (prompts / name).write_bytes(body.encode("utf-8")) + registry_path = project / ".specify" / "extensions" / ".registry" + before = json.loads(registry_path.read_text(encoding="utf-8"))["extensions"] result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) assert result.exit_code == 0, result.output - assert "hyphenate to one file" in result.output - assert (prompts / "speckit.foo.bar-baz.md").read_text(encoding="utf-8") == foo_body - assert (prompts / "speckit.foo-bar.baz.md").read_text(encoding="utf-8") == bar_body - assert sentinel.read_text(encoding="utf-8") == "SENTINEL\n" - assert (prompts / "speckit-foo-other.md").is_file() - assert "OTHER-BODY" in (prompts / "speckit-foo-other.md").read_text(encoding="utf-8") - assert not (prompts / "speckit.foo.other.md").exists() + output = " ".join(result.output.split()) + assert ( + "speckit.foo.bar-baz (speckit-foo-bar-baz, also written by " + "extension 'foo-bar')" + ) in output + assert ( + "speckit.foo-bar.baz (speckit-foo-bar-baz, also written by " + "extension 'foo')" + ) in output + for name, body in planted.items(): + assert (prompts / name).read_bytes() == body.encode("utf-8"), name + assert not (prompts / "speckit-foo-bar-baz.md").exists() + assert not (prompts / "speckit-foo-other.md").exists() + after = json.loads(registry_path.read_text(encoding="utf-8"))["extensions"] + for ext_id in ("foo", "foo-bar"): + assert ( + after[ext_id]["registered_commands"] + == before[ext_id]["registered_commands"] + ) assert (prompts / "speckit-plan.md").is_file() - assert not (prompts / "speckit.plan.md").exists() + assert (prompts / "speckit-git-commit.md").is_file() + assert not (prompts / "speckit.git.commit.md").exists() - result = _run_in_project(project, ["extension", "remove", "foo", "--force"]) + result = _run_in_project( + project, ["extension", "remove", "foo-bar", "--force"] + ) assert result.exit_code == 0, result.output - assert not (prompts / "speckit.foo.bar-baz.md").exists() - assert (prompts / "speckit.foo-bar.baz.md").read_text(encoding="utf-8") == bar_body - shared = sentinel.read_text(encoding="utf-8") - assert "BAR-BODY" in shared - assert "FOO-BODY" not in shared - assert "SENTINEL" not in shared - assert not (prompts / "speckit-foo-other.md").exists() - assert (prompts / "speckit-plan.md").is_file() + assert not (prompts / "speckit.foo-bar.baz.md").exists() + assert (prompts / "speckit.foo.bar-baz.md").read_bytes() == foo_body.encode( + "utf-8" + ) - result = _run_in_project(project, ["extension", "remove", "foo-bar", "--force"]) + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert "FOO-BODY" in (prompts / "speckit-foo-bar-baz.md").read_text( + encoding="utf-8" + ) + assert "OTHER-BODY" in (prompts / "speckit-foo-other.md").read_text( + encoding="utf-8" + ) + assert sorted(prompts.glob("speckit.*.md")) == [] + + @pytest.mark.parametrize("state", ["disabled", "unreadable"]) + def test_inactive_extension_keeps_its_claim_on_a_shared_kiro_prompt( + self, tmp_path, monkeypatch, state + ): + """A disabled extension, or one whose manifest can't be read, is not + registered, but its tracked dotted prompt is still on disk. Moving the + other extension onto ``speckit-foo-bar-baz.md`` would let removing the + first one delete that file, so both stay as they were (#4797).""" + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + prompts = project / ".kiro" / "prompts" + foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" + self._plant_extension(project, "foo", [ + {"name": "speckit.foo.bar-baz", "body": foo_body}, + ]) + self._plant_extension( + project, + "foo-bar", + [{"name": "speckit.foo-bar.baz", "body": bar_body}], + enabled=state != "disabled", + ) + if state == "unreadable": + ( + project / ".specify" / "extensions" / "foo-bar" / "extension.yml" + ).write_text("invalid: [", encoding="utf-8") + (prompts / "speckit.foo.bar-baz.md").write_bytes(foo_body.encode("utf-8")) + (prompts / "speckit.foo-bar.baz.md").write_bytes(bar_body.encode("utf-8")) + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert (prompts / "speckit.foo.bar-baz.md").read_bytes() == foo_body.encode( + "utf-8" + ) + assert not (prompts / "speckit-foo-bar-baz.md").exists() + + result = _run_in_project( + project, ["extension", "remove", "foo-bar", "--force"] + ) assert result.exit_code == 0, result.output - assert not sentinel.exists() assert not (prompts / "speckit.foo-bar.baz.md").exists() - assert (prompts / "speckit-plan.md").is_file() - def test_disabled_extension_without_kiro_files_does_not_block_migration( + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert "FOO-BODY" in (prompts / "speckit-foo-bar-baz.md").read_text( + encoding="utf-8" + ) + assert not (prompts / "speckit.foo.bar-baz.md").exists() + + def test_enabling_a_colliding_extension_keeps_the_migrated_prompt( self, tmp_path, monkeypatch ): + """A disabled extension with no Kiro prompts does not block the other + one's rename. Enabling it only flips its flag, and the next + registration pass leaves both as they are instead of writing its body + over the migrated prompt (#4797). Removing the migrated extension + deletes its own prompt, and the other one moves on the next pass.""" project = _init_dotted_kiro_project(tmp_path, monkeypatch) prompts = project / ".kiro" / "prompts" foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" self._plant_extension(project, "foo", [ {"name": "speckit.foo.bar-baz", "body": foo_body}, ]) self._plant_extension( project, "foo-bar", - [{"name": "speckit.foo-bar.baz", "body": "---\ndescription: Bar\n---\n\nBAR-BODY\n"}], + [{"name": "speckit.foo-bar.baz", "body": bar_body}], enabled=False, registered=[], ) - (prompts / "speckit.foo.bar-baz.md").write_text(foo_body, encoding="utf-8") + (prompts / "speckit.foo.bar-baz.md").write_bytes(foo_body.encode("utf-8")) + migrated = prompts / "speckit-foo-bar-baz.md" result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) assert result.exit_code == 0, result.output - assert "hyphenate to one file" not in result.output + assert "FOO-BODY" in migrated.read_text(encoding="utf-8") assert not (prompts / "speckit.foo.bar-baz.md").exists() - written = (prompts / "speckit-foo-bar-baz.md").read_text(encoding="utf-8") - assert "FOO-BODY" in written - def test_enable_refuses_extension_that_hyphenates_to_an_owned_prompt( + for args in ( + ["extension", "enable", "foo-bar"], + ["integration", "upgrade", "kiro-cli"], + ): + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + assert ( + "speckit.foo-bar.baz (speckit-foo-bar-baz, also written by " + "extension 'foo')" + ) in " ".join(result.output.split()) + assert "FOO-BODY" in migrated.read_text(encoding="utf-8") + + result = _run_in_project(project, ["extension", "remove", "foo", "--force"]) + assert result.exit_code == 0, result.output + assert not migrated.exists() + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert "BAR-BODY" in migrated.read_text(encoding="utf-8") + + def test_alias_that_hyphenates_onto_a_core_prompt_never_replaces_it( self, tmp_path, monkeypatch ): + """Spec Kit 1.0.7 and earlier accepted an alias like ``plan``, which + Kiro CLI now names ``speckit-plan.md``, the core prompt's file. The + extension is left unregistered, and removing it keeps the core + prompt while deleting the extension's own files (#4797).""" project = _init_dotted_kiro_project(tmp_path, monkeypatch) - self._plant_extension(project, "foo", [ - {"name": "speckit.foo.bar-baz", "body": "---\ndescription: Foo\n---\n\nFOO-BODY\n"}, + prompts = project / ".kiro" / "prompts" + body = "---\ndescription: Old\n---\n\nOLD-BODY\n" + self._plant_extension(project, "old", [ + {"name": "speckit.old.plan", "body": body, "aliases": ["plan"]}, ]) + for name in ("speckit.old.plan", "plan"): + (prompts / f"{name}.md").write_bytes(body.encode("utf-8")) + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert ( + "plan (speckit-plan, also written by a core command)" + ) in " ".join(result.output.split()) + core_plan = prompts / "speckit-plan.md" + assert "OLD-BODY" not in core_plan.read_text(encoding="utf-8") + core_bytes = core_plan.read_bytes() + + result = _run_in_project(project, ["extension", "remove", "old", "--force"]) + assert result.exit_code == 0, result.output + assert core_plan.read_bytes() == core_bytes + assert not (prompts / "speckit.old.plan.md").exists() + assert not (prompts / "plan.md").exists() + + def test_upgrade_refuses_while_an_extension_prompt_has_a_core_prompt_name( + self, tmp_path, monkeypatch + ): + """An older alias ``speckit-plan`` has its prompt where the renamed + core ``speckit.plan`` goes. Upgrade refuses before changing files. + Until the rename, that file is the extension's, so removing the + extension deletes it, and the upgrade then writes the core prompt + (#4797).""" + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + prompts = project / ".kiro" / "prompts" + body = "---\ndescription: Old\n---\n\nOLD-BODY\n" + self._plant_extension(project, "old", [ + {"name": "speckit.old.plan", "body": body, "aliases": ["speckit-plan"]}, + ]) + for name in ("speckit.old.plan", "speckit-plan"): + (prompts / f"{name}.md").write_bytes(body.encode("utf-8")) + manifest = project / ".specify" / "integrations" / "kiro-cli.manifest.json" + before = {path.name: path.read_bytes() for path in prompts.iterdir()} + manifest_before = manifest.read_bytes() + + for args in ( + ["integration", "upgrade", "kiro-cli"], + ["integration", "upgrade", "kiro-cli", "--force"], + ): + result = _run_in_project(project, args) + assert result.exit_code == 1, result.output + assert "old (speckit-plan)" in " ".join(result.output.split()) + assert { + path.name: path.read_bytes() for path in prompts.iterdir() + } == before + assert manifest.read_bytes() == manifest_before + + result = _run_in_project(project, ["extension", "remove", "old", "--force"]) + assert result.exit_code == 0, result.output + assert not (prompts / "speckit-plan.md").exists() + assert not (prompts / "speckit.old.plan.md").exists() + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert "OLD-BODY" not in (prompts / "speckit-plan.md").read_text( + encoding="utf-8" + ) + assert not (prompts / "speckit.plan.md").exists() + + def test_qoder_upgrade_leaves_extensions_that_share_a_skill_unregistered( + self, tmp_path + ): + """Qoder's flat commands become skills through the same retirement + step (#4205), so the same pair would merge into one skill (#4797).""" + project = _init_project(tmp_path, "qodercli") + commands = project / ".qoder" / "commands" + commands.mkdir(parents=True, exist_ok=True) + foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" + self._plant_extension( + project, + "foo", + [{"name": "speckit.foo.bar-baz", "body": foo_body}], + agent="qodercli", + ) self._plant_extension( project, "foo-bar", - [{"name": "speckit.foo-bar.baz", "body": "---\ndescription: Bar\n---\n\nBAR-BODY\n"}], - enabled=False, + [{"name": "speckit.foo-bar.baz", "body": bar_body}], + agent="qodercli", ) - result = _run_in_project(project, ["extension", "enable", "foo-bar"]) - assert result.exit_code != 0 - assert "hyphenate to a prompt" in result.output - registry = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + (commands / "speckit.foo.bar-baz.md").write_bytes(foo_body.encode("utf-8")) + (commands / "speckit.foo-bar.baz.md").write_bytes(bar_body.encode("utf-8")) + + result = _run_in_project(project, ["integration", "upgrade", "qodercli"]) + assert result.exit_code == 0, result.output + assert (commands / "speckit.foo.bar-baz.md").read_bytes() == foo_body.encode( + "utf-8" + ) + assert (commands / "speckit.foo-bar.baz.md").read_bytes() == bar_body.encode( + "utf-8" ) - assert registry["extensions"]["foo-bar"]["enabled"] is False + assert not (project / ".qoder" / "skills" / "speckit-foo-bar-baz").exists() def test_upgrade_migrates_alias_that_hyphenates_to_its_own_command( self, tmp_path, monkeypatch @@ -805,11 +993,11 @@ def test_upgrade_migrates_alias_that_hyphenates_to_its_own_command( assert (prompts / "speckit-git-commit.md").is_file() assert not (prompts / "speckit.git.commit.md").exists() - def test_force_reinstall_retires_dotted_prompts_written_before_hyphenation( + def test_force_reinstall_replaces_dotted_prompts_written_before_hyphenation( self, tmp_path, monkeypatch ): - """``extension add --force`` writes the hyphenated prompt and retires - the dotted file left by an install that ran before the rename.""" + """``extension add --force`` removes the extension first, which + deletes its dotted prompts, then writes the hyphenated ones.""" project = _init_dotted_kiro_project( tmp_path, monkeypatch, ["extension", "add", "git"] ) @@ -820,119 +1008,11 @@ def test_force_reinstall_retires_dotted_prompts_written_before_hyphenation( assert result.exit_code == 0, result.output assert (prompts / "speckit-git-commit.md").is_file() assert not (prompts / "speckit.git.commit.md").exists() - # Reinstall retires the extension's own dotted file. It does not - # run integration setup, so the core prompt stays dotted until upgrade. + # Reinstall does not run integration setup, so the core prompt stays + # dotted until upgrade. assert (prompts / "speckit.plan.md").is_file() assert not (prompts / "speckit-plan.md").exists() - def test_dropping_a_shared_command_rewrites_the_remaining_owners_body( - self, tmp_path, monkeypatch - ): - """A name the manifest drops must not leave its body in a shared file. - - The dropped command is already gone from the claim set, so removal - has to match the formatted path rather than the old manifest entry. - The other command stays disabled, so this pass does not register it. - Its source is still written back. A file only the dropped command - owned is deleted. - """ - import yaml - - project = _init_dotted_kiro_project( - tmp_path, monkeypatch, ["extension", "add", "git"] - ) - 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 - prompts = project / ".kiro" / "prompts" - shared = prompts / "speckit-git-commit.md" - assert shared.is_file() - assert (prompts / "speckit-git-c.md").is_file() - previous = shared.read_text(encoding="utf-8") - - other_body = "---\ndescription: Other\n---\n\nOTHER-BODY\n" - self._plant_extension( - project, - "other", - [{ - "name": "speckit.other.keep", - "body": other_body, - "aliases": ["speckit-git-commit"], - }], - enabled=False, - registered=["speckit-git-commit"], - ) - manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) - manifest["provides"]["commands"] = [ - command for command in manifest["provides"]["commands"] - if command.get("name") != "speckit.git.commit" - ] - manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") - ( - project / ".specify" / "extensions" / "git" / "commands" - / "speckit.git.commit.md" - ).unlink() - - result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) - assert result.exit_code == 0, result.output - rewritten = shared.read_text(encoding="utf-8") - assert "OTHER-BODY" in rewritten - assert rewritten != previous - assert not (prompts / "speckit-other-keep.md").exists() - assert not (prompts / "speckit-git-c.md").exists() - assert not (prompts / "speckit.git.commit.md").exists() - assert "speckit.git.commit" not in self._kiro_git_commands(project) - assert (prompts / "speckit-plan.md").is_file() - - def test_preset_hyphen_collision_does_not_overwrite_extension_prompt( - self, tmp_path - ): - import yaml - - project = _init_project(tmp_path, "kiro-cli") - prompts = project / ".kiro" / "prompts" - self._plant_extension(project, "foo", [ - { - "name": "speckit.foo.bar-baz", - "body": "---\ndescription: Foo\n---\n\nFOO-BODY\n", - }, - ]) - shared = prompts / "speckit-foo-bar-baz.md" - shared.write_text("SENTINEL\n", encoding="utf-8") - preset_src = tmp_path / "collide-preset" - (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.foo-bar.baz.md").write_text( - "---\ndescription: Preset\n---\n\nPRESET-BODY\n", - encoding="utf-8", - ) - (preset_src / "preset.yml").write_text(yaml.safe_dump({ - "schema_version": "1.0", - "preset": { - "id": "collide-preset", - "name": "Collide", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{ - "type": "command", - "name": "speckit.foo-bar.baz", - "file": "commands/speckit.foo-bar.baz.md", - }]}, - }), encoding="utf-8") - - result = _run_in_project( - project, ["preset", "add", "--dev", str(preset_src)] - ) - assert result.exit_code == 0, result.output - assert "hyphenate to one prompt" in result.output - assert shared.read_text(encoding="utf-8") == "SENTINEL\n" - def test_preset_core_override_still_writes_kiro_prompt(self, tmp_path): preset_src = _write_command_preset(tmp_path, "cmd-preset") project = _init_project(tmp_path, "kiro-cli") From e7d15bf5fb9d7ff0be784f2faaa143368cffaa95 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Tue, 6 Oct 2026 13:00:10 +0000 Subject: [PATCH 15/30] fix(extensions): count unreadable owners at install, remove skipped Qoder commands The install check built its name map from readable manifests only, so an installed extension whose extension.yml can't be read did not count, and a colliding install could overwrite its files. Fall back to the command names the registry tracks for it. Registration leaves a skipped extension's old flat commands in place. For Qoder they sit in .qoder/commands, outside the registrar's directory, so extension removal left them behind. Removal now deletes the tracked old flat files too (never a core command's own), reusing the registration-time retirement step without its replacement check. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 39 ++++++++++++++++++- .../integrations/test_command_upgrade.py | 19 ++++++++- tests/test_extensions.py | 25 ++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6ee79459ac..e55a57542c 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1168,7 +1168,12 @@ def _get_installed_command_name_map( self, exclude_extension_id: Optional[str] = None, ) -> Dict[str, str]: - """Return registered command and alias names for installed extensions.""" + """Return registered command and alias names for installed extensions. + + An extension whose manifest can't be read contributes the names the + registry tracks for it, because its command files are still on disk + (#4797). + """ installed_names: Dict[str, str] = {} for ext_id in self.registry.keys(): @@ -1177,6 +1182,16 @@ def _get_installed_command_name_map( manifest = self.get_extension(ext_id) if manifest is None: + metadata = self.registry.get(ext_id) + recorded = ( + metadata.get("registered_commands") + if isinstance(metadata, dict) + else None + ) + if isinstance(recorded, dict): + for names in recorded.values(): + for name in self._valid_name_list(names): + installed_names.setdefault(name, ext_id) continue for cmd in manifest.commands: @@ -3577,6 +3592,15 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: for agent_name in safe_commands }, ) + # Registration leaves an extension's old flat files in place + # when it skips the extension, and they live outside the + # registrar's directory for Qoder (#4797). + for agent_name, command_names in safe_commands.items(): + self._retire_legacy_flat_extension_commands( + agent_name, + self._valid_name_list(command_names), + require_replacement=False, + ) if metadata: self._remove_generic_artifact_paths(extension_id, metadata) @@ -3868,6 +3892,8 @@ def _retire_legacy_flat_extension_commands( self, agent_name: str, command_names: List[str], + *, + require_replacement: bool = True, ) -> List[Path]: """Remove old flat commands whose replacements were written. @@ -3877,6 +3903,11 @@ def _retire_legacy_flat_extension_commands( 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). + + Extension removal passes ``require_replacement=False``: the old files + go with the extension, including those registration left in place + because a command shares a file (``_shared_command_files``). A core + command's own old file is never removed. """ from ..agents import CommandRegistrar from ..integrations import get_integration @@ -3919,11 +3950,13 @@ def safe_project_dir(relative: str) -> Optional[Path]: if legacy_root is None or output_root is None or not legacy_root.is_dir(): return [] + core_names = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} removed: List[Path] = [] for command_name in command_names: if ( not isinstance(command_name, str) or not command_name + or command_name in core_names or not registrar._is_safe_command_name(command_name) ): continue @@ -3932,7 +3965,9 @@ def safe_project_dir(relative: str) -> Optional[Path]: agent_name, command_name, agent_config ) replacement = output_root / f"{output_name}{agent_config['extension']}" - if replacement.is_symlink() or not replacement.is_file(): + if require_replacement and ( + replacement.is_symlink() or not replacement.is_file() + ): continue legacy_file = legacy_root / f"{command_name}{legacy_extension}" diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index d31b3cef92..e6190376fd 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -970,7 +970,24 @@ def test_qoder_upgrade_leaves_extensions_that_share_a_skill_unregistered( assert (commands / "speckit.foo-bar.baz.md").read_bytes() == bar_body.encode( "utf-8" ) - assert not (project / ".qoder" / "skills" / "speckit-foo-bar-baz").exists() + skill = project / ".qoder" / "skills" / "speckit-foo-bar-baz" / "SKILL.md" + assert not skill.parent.exists() + + # Removal deletes the skipped extension's old flat command too, and + # the remaining extension then migrates. + result = _run_in_project( + project, ["extension", "remove", "foo-bar", "--force"] + ) + assert result.exit_code == 0, result.output + assert not (commands / "speckit.foo-bar.baz.md").exists() + assert (commands / "speckit.foo.bar-baz.md").read_bytes() == foo_body.encode( + "utf-8" + ) + + result = _run_in_project(project, ["integration", "upgrade", "qodercli"]) + assert result.exit_code == 0, result.output + assert "FOO-BODY" in skill.read_text(encoding="utf-8") + assert not (commands / "speckit.foo.bar-baz.md").exists() def test_upgrade_migrates_alias_that_hyphenates_to_its_own_command( self, tmp_path, monkeypatch diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 8f764a2122..3a389a3e3a 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3255,6 +3255,31 @@ def test_install_rejects_hyphenated_cross_extension_collision( with pytest.raises(ValidationError, match="speckit-foo-bar-baz"): manager.install_from_directory(second, "0.1.0", register_commands=False) + def test_install_rejects_collision_with_extension_whose_manifest_is_unreadable( + self, temp_dir, project_dir + ): + """An installed extension whose manifest can't be read still has its + tracked command files on disk, so its registry names count.""" + first = self._write_named_extension( + temp_dir, "foo-bar", [("speckit.foo-bar.baz", [])] + ) + second = self._write_named_extension( + temp_dir, "foo", [("speckit.foo.bar-baz", [])] + ) + manager = ExtensionManager(project_dir) + manager.install_from_directory(first, "0.1.0", register_commands=False) + manager.registry.update( + "foo-bar", + {"registered_commands": {"kiro-cli": ["speckit.foo-bar.baz"]}}, + ) + (manager.extensions_dir / "foo-bar" / "extension.yml").write_text( + "invalid: [", encoding="utf-8" + ) + assert manager.get_extension("foo-bar") is None + + with pytest.raises(ValidationError, match="extension 'foo-bar'"): + manager.install_from_directory(second, "0.1.0", register_commands=False) + def test_install_rejects_alias_that_hyphenates_to_another_command( self, temp_dir, project_dir ): From ec595b6f9fabbf55f5ddba4b1184f0d145def64e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:52:37 -0500 Subject: [PATCH 16/30] fix(extensions): preserve shared migrated commands during removal Verify ownership before deleting colliding Kiro prompts or Qoder skills. Protect formatted and raw aliases, preserve ambiguous files with warnings, and continue retiring the removed extension's legacy commands. Add 36 regression cases covering both owners and invalid ownership markers. Validation: full pytest run completed with 9766 passed, 19 skipped, and one pre-existing preset CLI help-text assertion failure reproduced on untouched fdb8d752. CI-pinned Ruff passed across src and tests. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/extensions.md | 5 + src/specify_cli/agents.py | 14 +-- src/specify_cli/extensions/__init__.py | 96 ++++++++++++++++--- .../integrations/test_command_upgrade.py | 75 +++++++++++++++ 4 files changed, 171 insertions(+), 19 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index f2685fea4d..e3e09b21c3 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -58,6 +58,11 @@ specify extension remove Removes an installed extension. Configuration files are backed up by default; use `--keep-config` to leave them in place or `--force` to skip the confirmation. +For older Kiro CLI or Qoder CLI extensions whose names collide after hyphenation, +removal deletes the extension's legacy commands but preserves a shared migrated +prompt or skill unless its source marker identifies the removed extension as +the owner. Installed core commands are also preserved. + ## List Installed Extensions ```bash diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 5a140dab89..f1d794b95e 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1365,11 +1365,9 @@ def unregister_commands( cleaned up as well. ``preserved_output_names`` maps an agent to formatted stems that - must stay. Extension removal passes the core command files the - integration installed for an agent that renamed its command files - (see ``ExtensionManager._core_command_files``), so an older alias - that now formats to one of them does not delete it. The raw command - name is still removed when it differs from that stem. + must stay. Extension removal protects installed core commands and + shared migrated files not owned by the extension being removed. + Protection applies to both formatted and raw command names. Args: registered_commands: Dict mapping agent names to command name lists @@ -1408,7 +1406,11 @@ def unregister_commands( names_to_clean = [] if os.path.normcase(output_name) not in preserved: names_to_clean.append(output_name) - if output_name != cmd_name and self._is_safe_command_name(cmd_name): + if ( + output_name != cmd_name + and os.path.normcase(cmd_name) not in preserved + and self._is_safe_command_name(cmd_name) + ): names_to_clean.append(cmd_name) for target_dir in dirs_to_clean: diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e55a57542c..1c5b90f43a 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3578,19 +3578,16 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if "generic" in safe_commands: safe_commands.pop("generic") if safe_commands: - # An older alias can now format to a core command's file. - # Registration skips it (_shared_command_files), so once the - # integration has installed that file it is the core - # command's, not this extension's (#4797). + preserved = { + agent_name: self._preserved_command_files( + extension_id, agent_name + ) + for agent_name in safe_commands + } CommandRegistrar().unregister_commands( safe_commands, self.project_root, - preserved_output_names={ - agent_name: self._core_command_files( - agent_name, installed_only=True - ) - for agent_name in safe_commands - }, + preserved_output_names=preserved, ) # Registration leaves an extension's old flat files in place # when it skips the extension, and they live outside the @@ -3600,6 +3597,7 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: agent_name, self._valid_name_list(command_names), require_replacement=False, + preserved_output_names=preserved[agent_name], ) if metadata: self._remove_generic_artifact_paths(extension_id, metadata) @@ -3894,6 +3892,7 @@ def _retire_legacy_flat_extension_commands( command_names: List[str], *, require_replacement: bool = True, + preserved_output_names: Optional[Set[str]] = None, ) -> List[Path]: """Remove old flat commands whose replacements were written. @@ -3907,7 +3906,8 @@ def _retire_legacy_flat_extension_commands( Extension removal passes ``require_replacement=False``: the old files go with the extension, including those registration left in place because a command shares a file (``_shared_command_files``). A core - command's own old file is never removed. + command's own old file and files preserved by ownership checks are + never removed. """ from ..agents import CommandRegistrar from ..integrations import get_integration @@ -3951,12 +3951,19 @@ def safe_project_dir(relative: str) -> Optional[Path]: return [] core_names = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} + preserved = ( + {os.path.normcase(name) for name in (preserved_output_names or set())} + if registrar._same_lexical_path(legacy_root, output_root) + and legacy_extension == agent_config["extension"] + else set() + ) removed: List[Path] = [] for command_name in command_names: if ( not isinstance(command_name, str) or not command_name or command_name in core_names + or os.path.normcase(command_name) in preserved or not registrar._is_safe_command_name(command_name) ): continue @@ -3981,6 +3988,64 @@ def safe_project_dir(relative: str) -> Optional[Path]: return removed + def _preserved_command_files( + self, extension_id: str, agent_name: str + ) -> Set[str]: + """Keep shared migrated outputs unless their marker proves ownership.""" + from .. import _print_cli_warning + from ..agents import CommandRegistrar + from ..shared_infra import _validate_safe_shared_directory + + preserved = self._core_command_files(agent_name, installed_only=True) + shared = self._shared_command_files( + agent_name, include_core=False + ).get(extension_id, {}) + if not shared: + return preserved + + registrar = CommandRegistrar(self.project_root) + config = registrar.AGENT_CONFIGS[agent_name] + for name in shared: + stem = registrar._compute_output_name(agent_name, name, config) + if stem in preserved: + continue + path = self.project_root / config["dir"] / f"{stem}{config['extension']}" + if not path.exists() and not path.is_symlink(): + continue + try: + _validate_safe_shared_directory(self.project_root, path.parent) + registrar._ensure_inside(path.resolve(), self.project_root.resolve()) + content = path.read_text(encoding="utf-8") + if config["extension"] == "/SKILL.md": + frontmatter, _ = registrar.parse_frontmatter(content) + metadata = frontmatter.get("metadata") + source = metadata.get("source") if isinstance(metadata, dict) else None + owned = isinstance(source, str) and ( + source == f"extension:{extension_id}" + or source.startswith(f"{extension_id}:") + ) + else: + owned = ( + f"" in content.splitlines() + ) + except (OSError, ValueError) as exc: + preserved.add(stem) + _print_cli_warning( + "verify ownership of", "shared command file", str(path), exc, + continuing="Preserving the shared file.", + ) + continue + if not owned: + preserved.add(stem) + _print_cli_warning( + "verify ownership of", "shared command file", str(path), + ExtensionError( + f"Not marked as owned by extension '{extension_id}'" + ), + continuing="Preserving the shared file.", + ) + return preserved + def _core_command_files( self, agent_name: str, *, installed_only: bool = False ) -> Set[str]: @@ -4041,7 +4106,9 @@ def _core_command_files( .as_posix() in installed } - def _shared_command_files(self, agent_name: str) -> Dict[str, Dict[str, str]]: + def _shared_command_files( + self, agent_name: str, *, include_core: bool = True + ) -> Dict[str, Dict[str, str]]: """Return extension command names that would write another command's file. Retiring legacy flat files moves each command to its formatted name, @@ -4058,6 +4125,9 @@ def _shared_command_files(self, agent_name: str) -> Dict[str, Dict[str, str]]: disabled one, or one whose manifest cannot be read, claims the names already registered for the agent, because its files stay on disk. A command and its own aliases are one owner. + + Removal excludes planned core files: only the integration manifest + can establish that a core command has actually been installed. """ core_files = self._core_command_files(agent_name) if not core_files: @@ -4069,7 +4139,7 @@ def _shared_command_files(self, agent_name: str) -> Dict[str, Dict[str, str]]: agent_config = registrar.AGENT_CONFIGS[agent_name] owners: Dict[str, Set[tuple[str, str]]] = { os.path.normcase(stem): {("", stem)} for stem in core_files - } + } if include_core else {} claims: List[tuple[str, str, str, str]] = [] for ext_id, metadata in self.registry.list().items(): if not isinstance(metadata, dict): diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index e6190376fd..e1f593678e 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -893,6 +893,81 @@ def test_alias_that_hyphenates_onto_a_core_prompt_never_replaces_it( assert not (prompts / "speckit.old.plan.md").exists() assert not (prompts / "plan.md").exists() + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("state", ["enabled", "disabled", "unreadable"]) + @pytest.mark.parametrize("removed", ["foo", "foo-bar"]) + @pytest.mark.parametrize("marker", ["generated", "missing", "invalid-utf8"]) + def test_removing_colliding_extension_preserves_other_owners_migrated_file( + self, tmp_path, agent, state, removed, marker + ): + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, agent) + self._plant_extension( + project, + "foo", + [{"name": "speckit.foo.bar-baz", "body": "FOO-BODY\n"}], + agent=agent, + ) + manager = ExtensionManager(project) + manager.register_enabled_extensions_for_agent(agent) + if agent == "kiro-cli": + migrated = project / ".kiro/prompts/speckit-foo-bar-baz.md" + legacy = project / ".kiro/prompts/speckit.foo-bar.baz.md" + else: + migrated = project / ".qoder/skills/speckit-foo-bar-baz/SKILL.md" + legacy = project / ".qoder/commands/speckit.foo-bar.baz.md" + assert b"FOO-BODY" in migrated.read_bytes() + if marker == "missing": + migrated.write_text("UNMARKED-BODY\n", encoding="utf-8") + elif marker == "invalid-utf8": + migrated.write_bytes(b"\xff") + before = migrated.read_bytes() + + self._plant_extension( + project, + "foo-bar", + [{ + "name": "speckit.foo-bar.baz", + "body": "BAR-BODY\n", + "aliases": ["speckit-foo-bar-baz"], + }], + enabled=state != "disabled", + agent=agent, + ) + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("BAR-BODY\n", encoding="utf-8") + legacy_alias = legacy.parent / "speckit-foo-bar-baz.md" + if agent == "qodercli": + legacy_alias.write_text("BAR-ALIAS\n", encoding="utf-8") + if state == "unreadable": + ( + project / ".specify/extensions/foo-bar/extension.yml" + ).write_text("invalid: [", encoding="utf-8") + + manager = ExtensionManager(project) + manager.register_enabled_extensions_for_agent(agent) + assert migrated.read_bytes() == before + assert legacy.read_text(encoding="utf-8") == "BAR-BODY\n" + + result = _run_in_project( + project, ["extension", "remove", removed, "--force"] + ) + assert result.exit_code == 0, result.output + if removed == "foo-bar" or marker != "generated": + assert migrated.read_bytes() == before + assert "Preserving the shared file" in result.output + else: + assert not migrated.exists() + if removed == "foo-bar": + assert not legacy.exists() + if agent == "qodercli": + assert not legacy_alias.exists() + else: + assert legacy.read_text(encoding="utf-8") == "BAR-BODY\n" + if agent == "qodercli": + assert legacy_alias.read_text(encoding="utf-8") == "BAR-ALIAS\n" + def test_upgrade_refuses_while_an_extension_prompt_has_a_core_prompt_name( self, tmp_path, monkeypatch ): From 18aca1998ec2db5ccef2af8e1e67347652e43fab Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 7 Oct 2026 22:52:27 +0000 Subject: [PATCH 17/30] fix(integrations): refuse the Kiro rename when prompt ownership is unknown Upgrade checked the extension registry for an alias such as speckit-plan at a renamed core prompt's path, but skipped a registry it couldn't parse and entries whose registered_commands wasn't a list of names. The rename then wrote the core prompt over the alias's file, even without --force. Read the registry the way the preset guard does and refuse before changing files when it or an entry is malformed. A file at a new core name that nothing tracks, such as a prompt written by hand, is replaced only with --force, and a symlink there never is. Install and the shared-file checks used an enabled extension's manifest names only. A name the registry still tracks after the manifest stopped declaring it keeps its prompt on disk, and removing that extension deletes it. So another extension could install a command writing the same file, and removing the first one deleted the new owner's prompt. Registered names now count at install, and in the claims that registration and removal's ownership check read. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- docs/reference/extensions.md | 2 +- docs/reference/integrations.md | 2 +- src/specify_cli/extensions/__init__.py | 60 +++--- .../integrations/_command_upgrade_layout.py | 54 +++++- .../integrations/command_upgrade.py | 49 ++++- .../integrations/test_command_upgrade.py | 176 ++++++++++++++++++ tests/test_extensions.py | 23 +++ 7 files changed, 330 insertions(+), 36 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index e3e09b21c3..e42fdd717c 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -32,7 +32,7 @@ specify extension add Installs an extension from the catalog, a URL, or a local directory. Extension commands are registered with the active AI coding agent integration. For `generic`, invocations use the configured `--commands-dir`: flat command files by default, or `speckit-/SKILL.md` with `--skills`. The deprecated core `speckit.taskstoissues` command remains available during migration and will be removed in a future minor release. It warns on invocation and continues its existing workflow without installing or enabling the extension automatically. For GitHub issue tracking, run `specify extension add github` and use the recommended `/speckit.github.taskstoissues` command (slash-hyphen integrations: `/speckit-github-taskstoissues`; Codex, ZCode, and Command Code skills: `$speckit-github-taskstoissues`; Kimi: `/skill:speckit-github-taskstoissues`). -Installation is refused when a command or alias would write the same file as a core command or another installed extension's command once dots become hyphens, as Cline, Forge, Junie, Kiro CLI and skills-based integrations name their files (for example `speckit.foo.bar-baz` and `speckit.foo-bar.baz`). An extension installed before this check that shares such a file is not registered for Kiro CLI or Qoder CLI, and keeps its existing files, until it is updated or removed, or the extension it shares the file with is removed. +Installation is refused when a command or alias would write the same file as a core command or another installed extension's command once dots become hyphens, as Cline, Forge, Junie, Kiro CLI and skills-based integrations name their files (for example `speckit.foo.bar-baz` and `speckit.foo-bar.baz`). An installed extension's names include those still registered for it, because their files stay on disk after its manifest stops declaring them. An extension installed before this check that shares such a file is not registered for Kiro CLI or Qoder CLI, and keeps its existing files, until it is updated or removed, or the extension it shares the file with is removed. If a generic integration refresh cannot produce every extension invocation (for example, because a command or skill is user-modified or its source is missing), it warns and restores that extension's prior registered artifacts. Other extensions can still refresh. diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 8d33bdf2bb..f21aa6eb61 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. 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)). `specify integration upgrade kiro-cli` renames existing prompts, and refuses before changing files while presets have commands registered for Kiro CLI, or while an extension alias such as `speckit-plan` (accepted by Spec Kit 1.0.7 and earlier) already has its own prompt where a core prompt goes. An extension installed before Spec Kit rejected names that hyphenate to an existing prompt is not registered for Kiro CLI and keeps its existing prompts: two extensions with `speckit.foo.bar-baz` and `speckit.foo-bar.baz` stay that way until one is removed, and one with an alias such as `plan` until it is updated or removed. | +| [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)). `specify integration upgrade kiro-cli` renames existing prompts, and refuses before changing files while presets have commands registered for Kiro CLI, while an extension alias such as `speckit-plan` (accepted by Spec Kit 1.0.7 and earlier) already has its own prompt where a core prompt goes, or while the extension registry can't be read to check that. Another file already at a new prompt name, such as a prompt you wrote yourself, is replaced only with `--force`, and a symbolic link there never is. An extension installed before Spec Kit rejected names that hyphenate to an existing prompt is not registered for Kiro CLI and keeps its existing prompts: two extensions with `speckit.foo.bar-baz` and `speckit.foo-bar.baz` stay that way until one is removed, and one with an alias such as `plan` until it is updated or removed. | | [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/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 1c5b90f43a..5c2e478f79 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1170,9 +1170,10 @@ def _get_installed_command_name_map( ) -> Dict[str, str]: """Return registered command and alias names for installed extensions. - An extension whose manifest can't be read contributes the names the - registry tracks for it, because its command files are still on disk - (#4797). + Besides its manifest's names, an extension contributes the names the + registry tracks for it. Their command files stay on disk while its + manifest can't be read, or no longer declares them, until it is + registered again or removed, and removal deletes them (#4797). """ installed_names: Dict[str, str] = {} @@ -1180,18 +1181,19 @@ def _get_installed_command_name_map( if ext_id == exclude_extension_id: continue + metadata = self.registry.get(ext_id) + recorded = ( + metadata.get("registered_commands") + if isinstance(metadata, dict) + else None + ) + if isinstance(recorded, dict): + for names in recorded.values(): + for name in self._valid_name_list(names): + installed_names.setdefault(name, ext_id) + manifest = self.get_extension(ext_id) if manifest is None: - metadata = self.registry.get(ext_id) - recorded = ( - metadata.get("registered_commands") - if isinstance(metadata, dict) - else None - ) - if isinstance(recorded, dict): - for names in recorded.values(): - for name in self._valid_name_list(names): - installed_names.setdefault(name, ext_id) continue for cmd in manifest.commands: @@ -4121,10 +4123,11 @@ def _shared_command_files( Registration skips those extensions, so neither body overwrites the other and no old file is retired. - An enabled extension claims every name its manifest declares. A - disabled one, or one whose manifest cannot be read, claims the names - already registered for the agent, because its files stay on disk. A - command and its own aliases are one owner. + Every extension claims the names already registered for the agent, + because their files stay on disk, including names its manifest no + longer declares. An enabled extension with a readable manifest also + claims every name it declares. A command, its own aliases, and a + registered name that writes the same file are one owner. Removal excludes planned core files: only the integration manifest can establish that a core command has actually been installed. @@ -4149,16 +4152,25 @@ def _shared_command_files( for command in manifest.commands if manifest is not None else []: for name in [command["name"], *(command.get("aliases") or [])]: primary_of[name] = command["name"] + recorded = metadata.get("registered_commands") + names = self._valid_name_list( + recorded.get(agent_name) if isinstance(recorded, dict) else None + ) if manifest is not None and metadata.get("enabled", True): - names = list(primary_of) - else: - recorded = metadata.get("registered_commands") - names = self._valid_name_list( - recorded.get(agent_name) if isinstance(recorded, dict) else None - ) + names = list(primary_of) + [n for n in names if n not in primary_of] + primary_of_stem = { + os.path.normcase( + registrar._compute_output_name(agent_name, name, agent_config) + ): primary + for name, primary in primary_of.items() + } for name in names: stem = registrar._compute_output_name(agent_name, name, agent_config) - owner = (ext_id, primary_of.get(name, stem)) + owner = ( + ext_id, + primary_of.get(name) + or primary_of_stem.get(os.path.normcase(stem), stem), + ) owners.setdefault(os.path.normcase(stem), set()).add(owner) claims.append((*owner, name, stem)) diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 8140e40a8c..66541aad14 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -96,6 +96,14 @@ def _command_file_names_changed(integration, old_files, new_files) -> bool: return bool(removed & added) +class _ExtensionRegistryUnreadableError(Exception): + """Raised when the extension registry can't show who owns a command file. + + The counterpart of :class:`_PresetRegistryUnreadableError` for + ``_extension_commands_at``. + """ + + def _extension_commands_at(project_root, integration, rel_paths) -> list[str]: """Return ``" ()"`` for registered commands at *rel_paths*. @@ -104,23 +112,55 @@ def _extension_commands_at(project_root, integration, rel_paths) -> list[str]: command itself, so an older alias ``speckit-plan`` matches ``.kiro/prompts/speckit-plan.md``. Disabled extensions count: their files stay on disk. + + Fails **closed** like ``_installed_presets_affecting_agent``: an absent + registry returns an empty list, but a registry that exists and can't be + read or parsed, or an entry whose ``registered_commands`` for the + integration isn't a list of names, raises + :class:`_ExtensionRegistryUnreadableError`. Skipping it would let the + rename write a core file over an extension's. """ - from ..extensions import ExtensionManager + from ..extensions import ExtensionRegistry config = integration.registrar_config or {} commands_dir, suffix = config.get("dir"), config.get("extension") if not isinstance(commands_dir, str) or not isinstance(suffix, str): return [] + registry_path = ( + Path(project_root) / ".specify" / "extensions" / ExtensionRegistry.REGISTRY_FILE + ) + if not os.path.lexists(registry_path): + return [] + try: + data = json.loads(registry_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise _ExtensionRegistryUnreadableError(str(exc)) from exc + extensions = data.get("extensions", {}) if isinstance(data, dict) else None + if not isinstance(extensions, dict): + raise _ExtensionRegistryUnreadableError( + "extension registry structure is malformed" + ) + targets = {os.path.normcase(rel) for rel in rel_paths} - manager = ExtensionManager(Path(project_root)) found = [] - for ext_id, metadata in manager.registry.list().items(): + for ext_id, metadata in extensions.items(): recorded = ( - metadata.get("registered_commands") if isinstance(metadata, dict) else None + metadata.get("registered_commands", {}) + if isinstance(metadata, dict) + else None + ) + names = ( + recorded.get(integration.key, []) + if isinstance(recorded, dict) + else None ) - if not isinstance(recorded, dict): - continue - for name in manager._valid_name_list(recorded.get(integration.key)): + if not isinstance(names, list) or not all( + isinstance(name, str) for name in names + ): + raise _ExtensionRegistryUnreadableError( + f"extension '{ext_id}' registered_commands is malformed" + ) + for name in names: rel = (PurePath(commands_dir) / f"{name}{suffix}").as_posix() if os.path.normcase(rel) in targets: found.append(f"{ext_id} ({name})") diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 960d281212..40f27090a2 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -14,6 +14,7 @@ ) from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys from ._command_upgrade_layout import ( + _ExtensionRegistryUnreadableError, _PresetRegistryUnreadableError, _command_file_names_changed, _extension_commands_at, @@ -242,9 +243,23 @@ def integration_upgrade( # whose file is where the renamed core command goes. Writing the core # file would replace the extension's (or write through its dev-mode # symlink into the extension directory). - taken = _extension_commands_at( - project_root, integration, planned_command_files - ) + try: + taken = _extension_commands_at( + project_root, integration, planned_command_files + ) + except _ExtensionRegistryUnreadableError as exc: + console.print( + f"[red]Error:[/red] Cannot rename '{key}' command files: the " + "extension registry could not be read to verify which " + "extension commands use the new file names." + ) + console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") + console.print( + "The upgrade could write a core command over an extension's " + "file, so it is refused before changing files. Fix or restore " + "[cyan].specify/extensions/.registry[/cyan] and retry." + ) + raise typer.Exit(1) if taken: console.print( f"[red]Error:[/red] Cannot rename '{key}' command files while " @@ -257,6 +272,34 @@ def integration_upgrade( "remove the extension(s), then run the upgrade again." ) raise typer.Exit(1) + # Any other file already at a new core name is tracked by nothing, + # e.g. a prompt the user wrote because Kiro ignored the dotted names, + # so ownership can't be verified. Replace it only with --force, and + # never write through a symlink to wherever it points. + occupied = sorted( + rel for rel in planned_command_files + if rel not in old_manifest.files + and os.path.lexists(project_root / rel) + ) + linked = [rel for rel in occupied if (project_root / rel).is_symlink()] + if linked or (occupied and not force): + console.print( + f"[yellow]⚠[/yellow] {len(occupied)} file(s) not installed by " + f"'{key}' already use the new command file names:" + ) + for rel in occupied: + console.print(f" {rel}") + if linked: + console.print( + "\nSymbolic links are not overwritten, even with " + "[cyan]--force[/cyan]. Move them away and retry." + ) + else: + console.print( + "\nUse [cyan]--force[/cyan] to overwrite them, or move " + "them away and retry." + ) + raise typer.Exit(1) # Ensure shared infrastructure is up to date; --force overwrites existing files. infra_integration = integration diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index e1f593678e..cc0e7ffdae 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -1012,6 +1012,182 @@ def test_upgrade_refuses_while_an_extension_prompt_has_a_core_prompt_name( ) assert not (prompts / "speckit.plan.md").exists() + @pytest.mark.parametrize( + "corruption", + ["registry", "entry", "registered_commands", "agent_entry", "agent_item"], + ) + def test_upgrade_refuses_kiro_prompt_rename_while_extension_registry_is_unreadable( + self, tmp_path, monkeypatch, corruption + ): + """Without a readable registry entry, upgrade can't tell that the + alias ``speckit-plan`` owns the renamed core prompt's file, so it + refuses before changing files, even with ``--force`` (#4797).""" + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + prompts = project / ".kiro" / "prompts" + body = "---\ndescription: Old\n---\n\nOLD-BODY\n" + self._plant_extension(project, "old", [ + {"name": "speckit.old.plan", "body": body, "aliases": ["speckit-plan"]}, + ]) + for name in ("speckit.old.plan", "speckit-plan"): + (prompts / f"{name}.md").write_bytes(body.encode("utf-8")) + registry = project / ".specify" / "extensions" / ".registry" + data = json.loads(registry.read_text(encoding="utf-8")) + entry = data["extensions"]["old"] + if corruption == "registry": + registry.write_text("{not json", encoding="utf-8") + else: + if corruption == "entry": + data["extensions"]["old"] = "old" + elif corruption == "registered_commands": + entry["registered_commands"] = "kiro-cli" + elif corruption == "agent_entry": + entry["registered_commands"] = {"kiro-cli": "speckit-plan"} + else: + entry["registered_commands"] = {"kiro-cli": [1, 2]} + registry.write_text(json.dumps(data), encoding="utf-8") + manifest = project / ".specify" / "integrations" / "kiro-cli.manifest.json" + before = {path.name: path.read_bytes() for path in prompts.iterdir()} + manifest_before = manifest.read_bytes() + + for args in ( + ["integration", "upgrade", "kiro-cli"], + ["integration", "upgrade", "kiro-cli", "--force"], + ): + result = _run_in_project(project, args) + assert result.exit_code == 1, result.output + assert "extension registry could not be read" in " ".join( + result.output.split() + ) + assert { + path.name: path.read_bytes() for path in prompts.iterdir() + } == before + assert manifest.read_bytes() == manifest_before + + @pytest.mark.parametrize("kind", ["file", "symlink"]) + def test_upgrade_replaces_a_kiro_prompt_it_did_not_install_only_with_force( + self, tmp_path, monkeypatch, kind + ): + """No extension tracks this ``speckit-plan.md``; a user may have + written it because Kiro ignored the dotted prompts. Upgrade replaces + it only with ``--force``, and never writes through a symlink (#4797).""" + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + prompts = project / ".kiro" / "prompts" + mine = b"---\ndescription: Mine\n---\n\nMY-PLAN\n" + if kind == "symlink": + target = tmp_path / "my-plan.md" + target.write_bytes(mine) + try: + (prompts / "speckit-plan.md").symlink_to(target) + except OSError: + pytest.skip("symlinks are unavailable") + else: + (prompts / "speckit-plan.md").write_bytes(mine) + before = {path.name: path.read_bytes() for path in prompts.iterdir()} + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 1, result.output + assert ".kiro/prompts/speckit-plan.md" in result.output + assert {path.name: path.read_bytes() for path in prompts.iterdir()} == before + + result = _run_in_project( + project, ["integration", "upgrade", "kiro-cli", "--force"] + ) + if kind == "symlink": + assert result.exit_code == 1, result.output + assert "Symbolic links are not overwritten" in " ".join( + result.output.split() + ) + assert { + path.name: path.read_bytes() for path in prompts.iterdir() + } == before + else: + assert result.exit_code == 0, result.output + assert "MY-PLAN" not in (prompts / "speckit-plan.md").read_text( + encoding="utf-8" + ) + assert not (prompts / "speckit.plan.md").exists() + + def test_install_refuses_a_kiro_prompt_name_another_extension_still_tracks( + self, tmp_path + ): + """foo's manifest stops declaring ``speckit.foo.bar-baz``, but its + prompt stays registered and on disk. ``speckit.foo-bar.baz`` would + write the same file, and removing foo would then delete it, so the + install is refused (#4797).""" + import yaml + + def write_source(ext_id, name, body): + source = tmp_path / f"src-{ext_id}" + (source / "commands").mkdir(parents=True) + (source / "commands" / "cmd.md").write_text(body, encoding="utf-8") + (source / "extension.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": ext_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [{"name": name, "file": "commands/cmd.md"}] + }, + }), encoding="utf-8") + return source + + project = _init_project(tmp_path, "kiro-cli") + prompts = project / ".kiro" / "prompts" + foo = write_source("foo", "speckit.foo.bar-baz", "FOO-BODY\n") + result = _run_in_project(project, ["extension", "add", "--dev", str(foo)]) + assert result.exit_code == 0, result.output + manifest_path = project / ".specify" / "extensions" / "foo" / "extension.yml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["provides"]["commands"][0]["name"] = "speckit.foo.qux" + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + + bar = write_source("foo-bar", "speckit.foo-bar.baz", "BAR-BODY\n") + result = _run_in_project(project, ["extension", "add", "--dev", str(bar)]) + assert result.exit_code != 0 + assert ( + "speckit.foo-bar.baz (writes 'speckit-foo-bar-baz', already provided " + "by extension 'foo' as 'speckit.foo.bar-baz')" + ) in " ".join(result.output.split()) + assert "FOO-BODY" in (prompts / "speckit-foo-bar-baz.md").read_text( + encoding="utf-8" + ) + + def test_removal_keeps_a_kiro_prompt_another_extension_wrote_at_a_stale_name( + self, tmp_path + ): + """foo's manifest no longer declares ``speckit.foo.bar-baz``, but the + registry still does. When foo-bar's ``speckit.foo-bar.baz`` already + wrote ``speckit-foo-bar-baz.md``, removing foo checks the file's owner + and keeps it (#4797).""" + import yaml + + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, "kiro-cli") + self._plant_extension(project, "foo", [ + {"name": "speckit.foo.bar-baz", "body": "FOO-BODY\n"}, + ]) + ExtensionManager(project).register_enabled_extensions_for_agent("kiro-cli") + shared = project / ".kiro" / "prompts" / "speckit-foo-bar-baz.md" + assert "FOO-BODY" in shared.read_text(encoding="utf-8") + manifest_path = project / ".specify" / "extensions" / "foo" / "extension.yml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["provides"]["commands"][0]["name"] = "speckit.foo.qux" + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + self._plant_extension(project, "foo-bar", [ + {"name": "speckit.foo-bar.baz", "body": "BAR-BODY\n"}, + ]) + shared.write_bytes(b"\nBAR-BODY\n") + + result = _run_in_project(project, ["extension", "remove", "foo", "--force"]) + assert result.exit_code == 0, result.output + assert "Preserving the shared file" in result.output + assert shared.read_bytes() == b"\nBAR-BODY\n" + def test_qoder_upgrade_leaves_extensions_that_share_a_skill_unregistered( self, tmp_path ): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 3a389a3e3a..271329c6d2 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3280,6 +3280,29 @@ def test_install_rejects_collision_with_extension_whose_manifest_is_unreadable( with pytest.raises(ValidationError, match="extension 'foo-bar'"): manager.install_from_directory(second, "0.1.0", register_commands=False) + def test_install_rejects_collision_with_a_name_only_the_registry_still_tracks( + self, temp_dir, project_dir + ): + """A registered name the installed manifest no longer declares still + has its command file on disk, and removing that extension deletes it, + so it counts too.""" + first = self._write_named_extension( + temp_dir, "foo-bar", [("speckit.foo-bar.qux", [])] + ) + second = self._write_named_extension( + temp_dir, "foo", [("speckit.foo.bar-baz", [])] + ) + manager = ExtensionManager(project_dir) + manager.install_from_directory(first, "0.1.0", register_commands=False) + manager.registry.update( + "foo-bar", + {"registered_commands": {"kiro-cli": ["speckit.foo-bar.baz"]}}, + ) + assert manager.get_extension("foo-bar") is not None + + with pytest.raises(ValidationError, match="extension 'foo-bar'"): + manager.install_from_directory(second, "0.1.0", register_commands=False) + def test_install_rejects_alias_that_hyphenates_to_another_command( self, temp_dir, project_dir ): From 5a22b1bf9ccb4b8f97e5fa251dca86caa40b2005 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 7 Oct 2026 23:03:35 +0000 Subject: [PATCH 18/30] fix(extensions): check the ownership marker before removal deletes a file Removal and integration cleanup still deleted files they couldn't show were the extension's: - A core prompt at an old alias's new name (plan -> speckit-plan.md) was kept only while the integration manifest tracked it. With the manifest missing or no longer listing it, extension remove deleted it, and integration switch deleted it right after uninstall had kept it as modified, because unregister_agent_artifacts passed no preserved names. - Removal deleted every registered name's old Qoder command in .qoder/commands, including a file another owner or the user wrote. - The shared-file check accepted any line equal to the extension's marker, so a prompt whose body quoted that line counted as the extension's. Ownership is now read from the first marker, where registration writes it (or SKILL.md metadata.source), in one helper. A core file the manifest doesn't track, and an old flat command on removal, are deleted only when that marker names the extension; integration cleanup passes the same preserved set as removal. Test fixtures that planted these files now carry the marker registration writes. Install and update also refuse while the registry can't be read, or while an installed extension has neither a readable manifest nor readable registered commands, since its names can't be checked. Refs #4797 Assisted-by: Codex CLI (model: gpt-6-astra, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- docs/reference/extensions.md | 10 +- src/specify_cli/extensions/__init__.py | 96 +++++++++++---- .../integrations/test_command_upgrade.py | 113 ++++++++++++++++-- tests/test_extensions.py | 55 +++++++++ 4 files changed, 239 insertions(+), 35 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index e42fdd717c..e8c51431b3 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -32,7 +32,7 @@ specify extension add Installs an extension from the catalog, a URL, or a local directory. Extension commands are registered with the active AI coding agent integration. For `generic`, invocations use the configured `--commands-dir`: flat command files by default, or `speckit-/SKILL.md` with `--skills`. The deprecated core `speckit.taskstoissues` command remains available during migration and will be removed in a future minor release. It warns on invocation and continues its existing workflow without installing or enabling the extension automatically. For GitHub issue tracking, run `specify extension add github` and use the recommended `/speckit.github.taskstoissues` command (slash-hyphen integrations: `/speckit-github-taskstoissues`; Codex, ZCode, and Command Code skills: `$speckit-github-taskstoissues`; Kimi: `/skill:speckit-github-taskstoissues`). -Installation is refused when a command or alias would write the same file as a core command or another installed extension's command once dots become hyphens, as Cline, Forge, Junie, Kiro CLI and skills-based integrations name their files (for example `speckit.foo.bar-baz` and `speckit.foo-bar.baz`). An installed extension's names include those still registered for it, because their files stay on disk after its manifest stops declaring them. An extension installed before this check that shares such a file is not registered for Kiro CLI or Qoder CLI, and keeps its existing files, until it is updated or removed, or the extension it shares the file with is removed. +Installation is refused when a command or alias would write the same file as a core command or another installed extension's command once dots become hyphens, as Cline, Forge, Junie, Kiro CLI and skills-based integrations name their files (for example `speckit.foo.bar-baz` and `speckit.foo-bar.baz`). An installed extension's names include those still registered for it, because their files stay on disk after its manifest stops declaring them. Installation is also refused while `.specify/extensions/.registry` can't be read, or while an installed extension has neither a readable manifest nor a readable list of registered commands, because its names can't be checked. An extension installed before this check that shares such a file is not registered for Kiro CLI or Qoder CLI, and keeps its existing files, until it is updated or removed, or the extension it shares the file with is removed. If a generic integration refresh cannot produce every extension invocation (for example, because a command or skill is user-modified or its source is missing), it warns and restores that extension's prior registered artifacts. Other extensions can still refresh. @@ -59,9 +59,11 @@ specify extension remove Removes an installed extension. Configuration files are backed up by default; use `--keep-config` to leave them in place or `--force` to skip the confirmation. For older Kiro CLI or Qoder CLI extensions whose names collide after hyphenation, -removal deletes the extension's legacy commands but preserves a shared migrated -prompt or skill unless its source marker identifies the removed extension as -the owner. Installed core commands are also preserved. +removal preserves a shared migrated prompt or skill unless its source marker +identifies the removed extension as the owner. The same check applies to the +extension's legacy commands and to any file at a core command's name. Installed +core commands are always preserved, also when an integration is switched or +uninstalled. ## List Installed Extensions diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 5c2e478f79..04761d2b0f 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1194,6 +1194,16 @@ def _get_installed_command_name_map( manifest = self.get_extension(ext_id) if manifest is None: + if not isinstance(recorded, dict) or any( + not isinstance(names, list) + or any(not isinstance(name, str) for name in names) + for names in recorded.values() + ): + raise ValidationError( + f"Cannot check installed command names for extension '{ext_id}': " + "its manifest cannot be read and its registered commands are invalid. " + "Fix or restore its extension.yml or .specify/extensions/.registry." + ) continue for cmd in manifest.commands: @@ -1254,8 +1264,14 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: Exact equality is not enough. Hyphenation is not injective, so two legal names can write one file. A command and its own aliases are one body and may share a path. Two command entries, or a name owned - by another extension, may not. + by another extension, may not. Unreadable installed names must not + allow an existing prompt to be overwritten (#4797). """ + if self.registry.is_corrupt(): + raise ValidationError( + "The extension registry cannot be read, so installed command names " + "cannot be checked. Fix or restore .specify/extensions/.registry." + ) declared_names = self._collect_manifest_command_names(manifest) installed_names = self._get_installed_command_name_map( exclude_extension_id=manifest.id @@ -3598,6 +3614,7 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: self._retire_legacy_flat_extension_commands( agent_name, self._valid_name_list(command_names), + extension_id, require_replacement=False, preserved_output_names=preserved[agent_name], ) @@ -3818,7 +3835,10 @@ def unregister_agent_artifacts( ) if command_names: registrar.unregister_commands( - {agent_name: command_names}, self.project_root + {agent_name: command_names}, self.project_root, + preserved_output_names={ + agent_name: self._preserved_command_files(ext_id, agent_name) + }, ) new_registered = copy.deepcopy(registered_commands) @@ -3892,6 +3912,7 @@ def _retire_legacy_flat_extension_commands( self, agent_name: str, command_names: List[str], + extension_id: str, *, require_replacement: bool = True, preserved_output_names: Optional[Set[str]] = None, @@ -3905,12 +3926,13 @@ def _retire_legacy_flat_extension_commands( inactive, or the extension disabled) is cleaned up when it is next registered (#2948). - Extension removal passes ``require_replacement=False``: the old files + Extension removal passes ``require_replacement=False``: marked old files go with the extension, including those registration left in place because a command shares a file (``_shared_command_files``). A core command's own old file and files preserved by ownership checks are never removed. """ + from .. import _print_cli_warning from ..agents import CommandRegistrar from ..integrations import get_integration @@ -3985,28 +4007,69 @@ def safe_project_dir(relative: str) -> Optional[Path]: if registrar._same_lexical_path(legacy_file, replacement): continue if legacy_file.is_symlink() or legacy_file.is_file(): + if not require_replacement: + try: + if not self._command_file_owned_by_extension(legacy_file, extension_id): + raise ExtensionError( + f"Not marked as owned by extension '{extension_id}'" + ) + except (OSError, ValueError, ExtensionError) as exc: + _print_cli_warning( + "verify ownership of", "legacy command file", str(legacy_file), exc, + continuing="Preserving the legacy file.", + ) + continue legacy_file.unlink() removed.append(legacy_file) return removed + def _command_file_owned_by_extension(self, path: Path, extension_id: str) -> bool: + """Read the generated ownership marker, ignoring later quoted markers (#4797).""" + from ..agents import CommandRegistrar + from ..shared_infra import _validate_safe_shared_directory + + _validate_safe_shared_directory(self.project_root, path.parent) + CommandRegistrar._ensure_inside(path.resolve(), self.project_root.resolve()) + content = path.read_text(encoding="utf-8") + if path.name == "SKILL.md": + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + metadata = frontmatter.get("metadata") + source = metadata.get("source") if isinstance(metadata, dict) else None + return isinstance(source, str) and ( + source == f"extension:{extension_id}" + or source.startswith(f"{extension_id}:") + ) + marker = next(( + line for line in content.splitlines() + if line.startswith("") + ), None) + return marker == f"" + def _preserved_command_files( self, extension_id: str, agent_name: str ) -> Set[str]: - """Keep shared migrated outputs unless their marker proves ownership.""" + """Keep core and shared migrated outputs unless ownership is proven (#4797).""" from .. import _print_cli_warning from ..agents import CommandRegistrar - from ..shared_infra import _validate_safe_shared_directory preserved = self._core_command_files(agent_name, installed_only=True) + core = self._core_command_files(agent_name) shared = self._shared_command_files( agent_name, include_core=False ).get(extension_id, {}) - if not shared: + if not core and not shared: return preserved registrar = CommandRegistrar(self.project_root) config = registrar.AGENT_CONFIGS[agent_name] + for stem in core - preserved: + path = self.project_root / config["dir"] / f"{stem}{config['extension']}" + try: + if not self._command_file_owned_by_extension(path, extension_id): + preserved.add(stem) + except (OSError, ValueError): + preserved.add(stem) for name in shared: stem = registrar._compute_output_name(agent_name, name, config) if stem in preserved: @@ -4015,21 +4078,7 @@ def _preserved_command_files( if not path.exists() and not path.is_symlink(): continue try: - _validate_safe_shared_directory(self.project_root, path.parent) - registrar._ensure_inside(path.resolve(), self.project_root.resolve()) - content = path.read_text(encoding="utf-8") - if config["extension"] == "/SKILL.md": - frontmatter, _ = registrar.parse_frontmatter(content) - metadata = frontmatter.get("metadata") - source = metadata.get("source") if isinstance(metadata, dict) else None - owned = isinstance(source, str) and ( - source == f"extension:{extension_id}" - or source.startswith(f"{extension_id}:") - ) - else: - owned = ( - f"" in content.splitlines() - ) + owned = self._command_file_owned_by_extension(path, extension_id) except (OSError, ValueError) as exc: preserved.add(stem) _print_cli_warning( @@ -4129,8 +4178,8 @@ def _shared_command_files( claims every name it declares. A command, its own aliases, and a registered name that writes the same file are one owner. - Removal excludes planned core files: only the integration manifest - can establish that a core command has actually been installed. + Removal checks core files separately using the integration manifest + and the file's ownership marker. """ core_files = self._core_command_files(agent_name) if not core_files: @@ -4572,6 +4621,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool self._retire_legacy_flat_extension_commands( agent_name, registered, + ext_id, ) if agent_name == "generic": diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index cc0e7ffdae..c19261cb9c 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -893,6 +893,52 @@ def test_alias_that_hyphenates_onto_a_core_prompt_never_replaces_it( assert not (prompts / "speckit.old.plan.md").exists() assert not (prompts / "plan.md").exists() + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("damage", ["missing", "empty-files"]) + def test_removal_preserves_core_without_manifest_tracking(self, tmp_path, agent, damage): + """An old alias must not delete an untracked core prompt (#4797).""" + project = _init_project(tmp_path, agent) + self._plant_extension(project, "old", [{ + "name": "speckit.old.plan", "aliases": ["plan"], "body": "OLD-BODY\n", + }], agent=agent) + core = project / ( + ".kiro/prompts/speckit-plan.md" if agent == "kiro-cli" + else ".qoder/skills/speckit-plan/SKILL.md" + ) + before = core.read_bytes() + manifest = project / ".specify/integrations" / f"{agent}.manifest.json" + if damage == "missing": + manifest.unlink() + else: + data = json.loads(manifest.read_text(encoding="utf-8")) + data["files"] = {} + manifest.write_text(json.dumps(data), encoding="utf-8") + + result = _run_in_project(project, ["extension", "remove", "old", "--force"]) + assert result.exit_code == 0, result.output + assert core.read_bytes() == before + assert "Preserving" not in result.output + + @pytest.mark.parametrize("operation", ["switch", "uninstall"]) + def test_integration_cleanup_preserves_modified_core_with_old_alias( + self, tmp_path, monkeypatch, operation + ): + """Extension cleanup must keep a core prompt preserved by teardown (#4797).""" + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + self._plant_extension(project, "old", [{ + "name": "speckit.old.plan", "aliases": ["plan"], "body": "OLD-BODY\n", + }]) + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + core = project / ".kiro/prompts/speckit-plan.md" + before = core.read_bytes() + b"\nUser customization\n" + core.write_bytes(before) + + target = "claude" if operation == "switch" else "kiro-cli" + result = _run_in_project(project, ["integration", operation, target]) + assert result.exit_code == 0, result.output + assert core.read_bytes() == before + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) @pytest.mark.parametrize("state", ["enabled", "disabled", "unreadable"]) @pytest.mark.parametrize("removed", ["foo", "foo-bar"]) @@ -936,10 +982,12 @@ def test_removing_colliding_extension_preserves_other_owners_migrated_file( agent=agent, ) legacy.parent.mkdir(parents=True, exist_ok=True) - legacy.write_text("BAR-BODY\n", encoding="utf-8") + legacy_body = "---\ndescription: Bar\n---\n\n\nBAR-BODY\n" + legacy.write_text(legacy_body, encoding="utf-8") legacy_alias = legacy.parent / "speckit-foo-bar-baz.md" + alias_body = "---\ndescription: Bar\n---\n\n\nBAR-ALIAS\n" if agent == "qodercli": - legacy_alias.write_text("BAR-ALIAS\n", encoding="utf-8") + legacy_alias.write_text(alias_body, encoding="utf-8") if state == "unreadable": ( project / ".specify/extensions/foo-bar/extension.yml" @@ -948,7 +996,7 @@ def test_removing_colliding_extension_preserves_other_owners_migrated_file( manager = ExtensionManager(project) manager.register_enabled_extensions_for_agent(agent) assert migrated.read_bytes() == before - assert legacy.read_text(encoding="utf-8") == "BAR-BODY\n" + assert legacy.read_text(encoding="utf-8") == legacy_body result = _run_in_project( project, ["extension", "remove", removed, "--force"] @@ -964,9 +1012,9 @@ def test_removing_colliding_extension_preserves_other_owners_migrated_file( if agent == "qodercli": assert not legacy_alias.exists() else: - assert legacy.read_text(encoding="utf-8") == "BAR-BODY\n" + assert legacy.read_text(encoding="utf-8") == legacy_body if agent == "qodercli": - assert legacy_alias.read_text(encoding="utf-8") == "BAR-ALIAS\n" + assert legacy_alias.read_text(encoding="utf-8") == alias_body def test_upgrade_refuses_while_an_extension_prompt_has_a_core_prompt_name( self, tmp_path, monkeypatch @@ -978,7 +1026,7 @@ def test_upgrade_refuses_while_an_extension_prompt_has_a_core_prompt_name( (#4797).""" project = _init_dotted_kiro_project(tmp_path, monkeypatch) prompts = project / ".kiro" / "prompts" - body = "---\ndescription: Old\n---\n\nOLD-BODY\n" + body = "---\ndescription: Old\n---\n\n\nOLD-BODY\n" self._plant_extension(project, "old", [ {"name": "speckit.old.plan", "body": body, "aliases": ["speckit-plan"]}, ]) @@ -1188,6 +1236,27 @@ def test_removal_keeps_a_kiro_prompt_another_extension_wrote_at_a_stale_name( assert "Preserving the shared file" in result.output assert shared.read_bytes() == b"\nBAR-BODY\n" + def test_removal_ignores_another_extensions_marker_quoted_in_body(self, tmp_path): + """The first generated marker owns a prompt even if its body quotes another (#4797).""" + project = _init_project(tmp_path, "kiro-cli") + self._plant_extension(project, "foo", [{ + "name": "speckit.foo.bar-baz", + "body": "FOO-BODY\n\nExample header:\n\n", + }]) + result = _run_in_project(project, ["integration", "use", "kiro-cli"]) + assert result.exit_code == 0, result.output + shared = project / ".kiro/prompts/speckit-foo-bar-baz.md" + before = shared.read_bytes() + assert b"" in before + self._plant_extension(project, "foo-bar", [{ + "name": "speckit.foo-bar.baz", "body": "BAR-BODY\n", + }]) + + result = _run_in_project(project, ["extension", "remove", "foo-bar", "--force"]) + assert result.exit_code == 0, result.output + assert shared.read_bytes() == before + assert "Preserving the shared file" in result.output + def test_qoder_upgrade_leaves_extensions_that_share_a_skill_unregistered( self, tmp_path ): @@ -1196,8 +1265,8 @@ def test_qoder_upgrade_leaves_extensions_that_share_a_skill_unregistered( project = _init_project(tmp_path, "qodercli") commands = project / ".qoder" / "commands" commands.mkdir(parents=True, exist_ok=True) - foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" - bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" + foo_body = "---\ndescription: Foo\n---\n\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\n\nBAR-BODY\n" self._plant_extension( project, "foo", @@ -1240,6 +1309,34 @@ def test_qoder_upgrade_leaves_extensions_that_share_a_skill_unregistered( assert "FOO-BODY" in skill.read_text(encoding="utf-8") assert not (commands / "speckit.foo.bar-baz.md").exists() + @pytest.mark.parametrize("marker", ["missing", "other-extension", "invalid-utf8", "owned"]) + def test_qoder_removal_requires_ownership_of_legacy_command(self, tmp_path, marker): + """Removal may retire a Qoder flat command only with its owner's marker (#4797).""" + project = _init_project(tmp_path, "qodercli") + self._plant_extension(project, "foo", [{ + "name": "speckit.foo.run", "body": "FOO-BODY\n", + }], agent="qodercli") + result = _run_in_project(project, ["integration", "use", "qodercli"]) + assert result.exit_code == 0, result.output + legacy = project / ".qoder/commands/speckit.foo.run.md" + legacy.parent.mkdir(parents=True, exist_ok=True) + before = { + "missing": b"User command\n", + "other-extension": b"\nOther owner's command\n", + "invalid-utf8": b"\xff", + "owned": b"---\ndescription: Foo\n---\n\n\nFOO-BODY\n", + }[marker] + legacy.write_bytes(before) + + result = _run_in_project(project, ["extension", "remove", "foo", "--force"]) + assert result.exit_code == 0, result.output + assert not (project / ".qoder/skills/speckit-foo-run/SKILL.md").exists() + if marker == "owned": + assert not legacy.exists() + else: + assert legacy.read_bytes() == before + assert "Preserving the legacy file" in result.output + def test_upgrade_migrates_alias_that_hyphenates_to_its_own_command( self, tmp_path, monkeypatch ): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 271329c6d2..78301a322e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3303,6 +3303,61 @@ def test_install_rejects_collision_with_a_name_only_the_registry_still_tracks( with pytest.raises(ValidationError, match="extension 'foo-bar'"): manager.install_from_directory(second, "0.1.0", register_commands=False) + @pytest.mark.parametrize("readable_manifest", [False, True]) + @pytest.mark.parametrize("damage", [ + "registry-json", "entry-list", "commands-list", "agent-string", "non-string-item", + ]) + def test_install_checks_names_before_writing_with_damaged_owner_metadata( + self, tmp_path, damage, readable_manifest + ): + """Unverifiable owners block installs; readable manifests still supply names (#4797).""" + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "kiro-cli") + first = self._write_named_extension( + tmp_path, "foo", [("speckit.foo.bar-baz", [])] + ) + second = self._write_named_extension( + tmp_path, "foo-bar", [( + "speckit.foo-bar.other" if readable_manifest else "speckit.foo-bar.baz", [], + )] + ) + result = _run_in_project(project, ["extension", "add", "--dev", str(first)]) + assert result.exit_code == 0, result.output + output = project / ".kiro/prompts/speckit-foo-bar-baz.md" + before = output.read_bytes() + if not readable_manifest: + (project / ".specify/extensions/foo/extension.yml").write_text( + "invalid: [", encoding="utf-8" + ) + registry = project / ".specify/extensions/.registry" + data = json.loads(registry.read_text(encoding="utf-8")) + if damage == "registry-json": + registry.write_text("{", encoding="utf-8") + else: + if damage == "entry-list": + data["extensions"]["foo"] = [] + else: + data["extensions"]["foo"]["registered_commands"] = { + "commands-list": [], + "agent-string": {"kiro-cli": "speckit.foo.bar-baz"}, + "non-string-item": {"kiro-cli": [{"name": "speckit.foo.bar-baz"}]}, + }[damage] + registry.write_text(json.dumps(data), encoding="utf-8") + registry_before = registry.read_bytes() + + result = _run_in_project(project, ["extension", "add", "--dev", str(second)]) + if readable_manifest and damage != "registry-json": + assert result.exit_code == 0, result.output + assert (project / ".kiro/prompts/speckit-foo-bar-other.md").is_file() + else: + assert result.exit_code != 0, result.output + assert "installed command names" in " ".join(result.output.split()) + assert ".specify/extensions/.registry" in result.output + assert registry.read_bytes() == registry_before + assert not (project / ".specify/extensions/foo-bar").exists() + assert output.read_bytes() == before + def test_install_rejects_alias_that_hyphenates_to_another_command( self, temp_dir, project_dir ): From 7950bed14976d4a8bbffe18a9a9582e63f8f34d9 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Wed, 7 Oct 2026 23:19:18 +0000 Subject: [PATCH 19/30] fix(extensions): keep unwritten registered names for Kiro CLI and Qoder only 3bcee7e9 kept a registered name whose command source is missing, so the dotted Kiro prompt it still has on disk stays tracked. That applied to every agent, but only Kiro CLI and Qoder check who owns a shared file on removal. In Claude, Codex, Cline, Junie or Forge, an older pair such as speckit.foo.bar-baz and speckit.foo-bar.baz shares one file; when foo's source went missing, foo kept its name, foo-bar rewrote the file, and removing foo deleted foo-bar's prompt. Other agents now drop an unwritten name as before. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 10 ++++-- .../integrations/test_command_upgrade.py | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 04761d2b0f..1c4051fe3b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4288,6 +4288,9 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool if agent_config and not skills_mode_active else {} ) + # Only agents whose removal checks who owns a shared file keep a + # registered name this pass did not write (see below). + keep_unwritten = bool(self._core_command_files(agent_name)) for ext_id, metadata in self.registry.list().items(): if not metadata.get("enabled", True): @@ -4364,7 +4367,9 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool # another extension may now own that file. Retirement # below still runs only for names written this pass, # and only once that pass's replacement file exists - # (#4797, #2948). + # (#4797, #2948). Other agents drop an unwritten name + # as before: an older pair of extensions can share its + # file, and their removal doesn't check the owner. declared_names: Set[str] = set() for command in manifest.commands: if not isinstance(command, dict): @@ -4383,7 +4388,8 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool ) kept = [ name for name in previous - if name in registered or name in declared_names + if name in registered + or (keep_unwritten and name in declared_names) ] merged = kept + [ name for name in registered if name not in kept diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index c19261cb9c..9671777d95 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -636,6 +636,42 @@ def test_upgrade_drops_a_kiro_command_the_manifest_no_longer_declares( assert (prompts / "speckit-git-feature.md").is_file() assert (prompts / "speckit-plan.md").is_file() + @pytest.mark.parametrize( + ("agent", "output"), + [ + ("claude", ".claude/skills/speckit-foo-bar-baz/SKILL.md"), + ("cline", ".clinerules/workflows/speckit-foo-bar-baz.md"), + ("junie", ".junie/commands/speckit-foo-bar-baz.md"), + ], + ) + def test_unwritten_name_is_dropped_where_removal_does_not_check_owners( + self, tmp_path, agent, output + ): + """Only Kiro CLI and Qoder keep a registered name whose source is + missing, because only their removal checks who owns a shared file. + Elsewhere an older pair can share the file, and keeping ``foo``'s + name would let removing ``foo`` delete ``foo-bar``'s (#4797).""" + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, agent) + self._plant_extension(project, "foo", [ + {"name": "speckit.foo.bar-baz", "body": "FOO-BODY\n"}, + ], agent=agent) + self._plant_extension(project, "foo-bar", [ + {"name": "speckit.foo-bar.baz", "body": "BAR-BODY\n"}, + ], agent=agent) + ( + project / ".specify/extensions/foo/commands/speckit.foo.bar-baz.md" + ).unlink() + ExtensionManager(project).register_enabled_extensions_for_agent(agent) + shared = project / output + before = shared.read_bytes() + assert b"BAR-BODY" in before + + result = _run_in_project(project, ["extension", "remove", "foo", "--force"]) + assert result.exit_code == 0, result.output + assert shared.read_bytes() == before + def _plant_extension( self, project, From 237f781b70709f7831754a2996bb6d6f2dd3980e Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Thu, 8 Oct 2026 00:29:28 +0000 Subject: [PATCH 20/30] fix(extensions): let a disabled extension's manifest claim its files A disabled extension claimed only the names its registry entry lists for the agent, because its manifest's commands aren't registered. When that entry can't be read (registered_commands or its per-agent value isn't a list of names), it claimed nothing, so integration use, switch or upgrade registered another extension's command over its prompt, e.g. speckit.foo.bar-baz over a disabled speckit.foo-bar.baz. It now claims its manifest's names in that case, and registration skips the pair. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 15 +++++--- .../integrations/test_command_upgrade.py | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 1c4051fe3b..59d675479e 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4175,7 +4175,8 @@ def _shared_command_files( Every extension claims the names already registered for the agent, because their files stay on disk, including names its manifest no longer declares. An enabled extension with a readable manifest also - claims every name it declares. A command, its own aliases, and a + claims every name it declares, and so does a disabled one whose + registered names can't be read. A command, its own aliases, and a registered name that writes the same file are one owner. Removal checks core files separately using the integration manifest @@ -4202,10 +4203,16 @@ def _shared_command_files( for name in [command["name"], *(command.get("aliases") or [])]: primary_of[name] = command["name"] recorded = metadata.get("registered_commands") - names = self._valid_name_list( - recorded.get(agent_name) if isinstance(recorded, dict) else None + entry = recorded.get(agent_name) if isinstance(recorded, dict) else None + unreadable = (recorded is not None and not isinstance(recorded, dict)) or ( + entry is not None + and ( + not isinstance(entry, list) + or not all(isinstance(name, str) for name in entry) + ) ) - if manifest is not None and metadata.get("enabled", True): + names = self._valid_name_list(entry) + if manifest is not None and (metadata.get("enabled", True) or unreadable): names = list(primary_of) + [n for n in names if n not in primary_of] primary_of_stem = { os.path.normcase( diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 9671777d95..25eb884b29 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -848,6 +848,40 @@ def test_inactive_extension_keeps_its_claim_on_a_shared_kiro_prompt( ) assert not (prompts / "speckit.foo.bar-baz.md").exists() + @pytest.mark.parametrize( + "damage", + [[], "speckit.foo-bar.baz", {"kiro-cli": "speckit.foo-bar.baz"}, {"kiro-cli": [1]}], + ) + def test_disabled_extension_with_unreadable_registration_keeps_its_prompt( + self, tmp_path, damage + ): + """A disabled extension's prompt stays on disk. When its registry + entry can't be read, its manifest still names the command, so another + extension that writes the same file is not registered over it + (#4797).""" + project = _init_project(tmp_path, "kiro-cli") + self._plant_extension(project, "foo-bar", [ + {"name": "speckit.foo-bar.baz", "body": "BAR-BODY\n"}, + ], enabled=False) + shared = project / ".kiro" / "prompts" / "speckit-foo-bar-baz.md" + body = b"---\ndescription: Bar\n---\n\n\nBAR-BODY\n" + shared.write_bytes(body) + registry = project / ".specify" / "extensions" / ".registry" + data = json.loads(registry.read_text(encoding="utf-8")) + data["extensions"]["foo-bar"]["registered_commands"] = damage + registry.write_text(json.dumps(data), encoding="utf-8") + self._plant_extension(project, "foo", [ + {"name": "speckit.foo.bar-baz", "body": "FOO-BODY\n"}, + ]) + + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"]) + assert result.exit_code == 0, result.output + assert ( + "speckit.foo.bar-baz (speckit-foo-bar-baz, also written by " + "extension 'foo-bar')" + ) in " ".join(result.output.split()) + assert shared.read_bytes() == body + def test_enabling_a_colliding_extension_keeps_the_migrated_prompt( self, tmp_path, monkeypatch ): From 02c63117bd1bfde82152669089f59b0f0064e4cf Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Thu, 8 Oct 2026 19:04:59 +0000 Subject: [PATCH 21/30] fix(extensions): check ownership on every Kiro CLI and Qoder cleanup path For agents with legacy flat files, the registrar no longer unlinks an extension's raw dotted name. The ownership-checked retirement removes it, and integration switch now runs that retirement before it drops the agent's tracking (except on the pre-registration commands_only path). A file whose owner can't be proven is kept, and stays tracked while the extension is installed. The collision scan reads every registry key. A malformed entry's claims come from its manifest, and registration stops with a warning when neither can be read. Removal judges each file by its generated owner instead: the first marker in a prompt, metadata.source in a skill. Registration-time retirement checks the owner too. While a command's rename is pending, its new file isn't written over a file the extension didn't write; install and update check this before removing the old version. Kiro CLI re-init and upgrade refuse while an extension has a prompt where a core prompt goes, whatever the state of the integration manifest, and upgrade replaces another file at a core prompt the manifest doesn't track only with --force. Damaged or missing registered_commands values are read from the manifest by registration, removal and the update backup alike, the update backup includes Qoder's old commands, a dropped alias no longer deletes a file that a declared command still uses, and a dropped name whose file is kept stays tracked. Refs #4797 Assisted-by: Codex CLI (model: gpt-6-astra, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- docs/reference/extensions.md | 14 +- docs/reference/integrations.md | 4 +- src/specify_cli/agents.py | 11 + src/specify_cli/command_init.py | 9 + src/specify_cli/extensions/__init__.py | 341 ++++++--- .../extensions/_command_update_transaction.py | 19 +- .../integrations/_command_upgrade_layout.py | 41 +- .../integrations/command_upgrade.py | 53 +- .../integrations/test_command_upgrade.py | 723 +++++++++++++++++- tests/test_extensions.py | 37 + 10 files changed, 1053 insertions(+), 199 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index e8c51431b3..2b344cd8e9 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -58,12 +58,14 @@ specify extension remove Removes an installed extension. Configuration files are backed up by default; use `--keep-config` to leave them in place or `--force` to skip the confirmation. -For older Kiro CLI or Qoder CLI extensions whose names collide after hyphenation, -removal preserves a shared migrated prompt or skill unless its source marker -identifies the removed extension as the owner. The same check applies to the -extension's legacy commands and to any file at a core command's name. Installed -core commands are always preserved, also when an integration is switched or -uninstalled. +For Kiro CLI and Qoder CLI, removal deletes an extension's prompt or skill, its +legacy commands, and any file at a core command's name only when the file's +generated marker names that extension: `metadata.source` in a skill, and in a +prompt its first `` line, or `metadata.source` in its +frontmatter when it has no such line. An extension prompt, skill or +legacy command without that marker is kept, with a warning. Extension cleanup +never removes installed core commands, including the cleanup that +`specify integration switch` runs. ## List Installed Extensions diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index f21aa6eb61..393eda7192 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. 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)). `specify integration upgrade kiro-cli` renames existing prompts, and refuses before changing files while presets have commands registered for Kiro CLI, while an extension alias such as `speckit-plan` (accepted by Spec Kit 1.0.7 and earlier) already has its own prompt where a core prompt goes, or while the extension registry can't be read to check that. Another file already at a new prompt name, such as a prompt you wrote yourself, is replaced only with `--force`, and a symbolic link there never is. An extension installed before Spec Kit rejected names that hyphenate to an existing prompt is not registered for Kiro CLI and keeps its existing prompts: two extensions with `speckit.foo.bar-baz` and `speckit.foo-bar.baz` stay that way until one is removed, and one with an alias such as `plan` until it is updated or removed. | +| [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)). `specify integration upgrade kiro-cli` renames existing prompts, and refuses before changing files while presets have commands registered for Kiro CLI, while an extension alias such as `speckit-plan` (accepted by Spec Kit 1.0.7 and earlier) already has its own prompt where a core prompt goes, or while the extension registry can't be read to check that. `specify init --here --force` also refuses while an extension's prompt is where a core prompt goes. When `integration upgrade` writes a core prompt that the integration manifest doesn't track, as during the rename, another file at that name is replaced only with `--force`, and a symbolic link there never is. While an extension prompt still has its old name, Spec Kit does not write its new `speckit-.md` over a file that extension did not write: registration skips the extension, and `extension add`/`update` refuse until the file is moved or removed. An extension installed before Spec Kit rejected names that hyphenate to an existing prompt is not registered for Kiro CLI and keeps its existing prompts: two extensions with `speckit.foo.bar-baz` and `speckit.foo-bar.baz` stay that way until one is removed, and one with an alias such as `plan` until it is updated or removed. | | [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` | | @@ -39,7 +39,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` | | [opencode](https://opencode.ai/) | `opencode` | | | [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | -| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | +| [Qoder CLI](https://qoder.com/cli) | `qodercli` | While an extension command still exists in `.qoder/commands`, Spec Kit does not write its new skill over a file that extension did not write: registration skips the extension, and `extension add`/`update` refuse until the file is moved or removed. | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | | [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | Workflow `command` and `prompt` steps can't run through SHAI: its CLI accepts prompt text (as arguments, on stdin, or through `shai agent`), but hands it to its auto-fix agent and never loads `.shai/commands`, so those steps fail | diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index f1d794b95e..b504687059 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1356,6 +1356,8 @@ def unregister_commands( registered_commands: Dict[str, List[str]], project_root: Path, preserved_output_names: Dict[str, set[str]] | None = None, + *, + preserve_legacy_flat_commands: bool = False, ) -> None: """Remove previously registered command files from agent directories. @@ -1369,6 +1371,9 @@ def unregister_commands( shared migrated files not owned by the extension being removed. Protection applies to both formatted and raw command names. + Extension callers preserve legacy flat names for ownership-checked + retirement by their manager (#4797); preset cleanup is unchanged. + Args: registered_commands: Dict mapping agent names to command name lists project_root: Path to project root @@ -1380,6 +1385,11 @@ def unregister_commands( continue agent_config = self.AGENT_CONFIGS[agent_name] + from .integrations import get_integration + + preserve_raw = preserve_legacy_flat_commands and bool( + getattr(get_integration(agent_name), "legacy_flat_command_dir", None) + ) commands_dir = self._resolve_agent_dir( agent_name, agent_config, project_root, ) @@ -1408,6 +1418,7 @@ def unregister_commands( names_to_clean.append(output_name) if ( output_name != cmd_name + and not preserve_raw and os.path.normcase(cmd_name) not in preserved and self._is_safe_command_name(cmd_name) ): diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index c2630f0ef8..56398e22ee 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -558,6 +558,15 @@ def init( ) raise typer.Exit(1) + if selected_ai == "kiro-cli": + from .integrations._command_upgrade_layout import ( + _check_extension_command_claims, _planned_command_files, + ) + + _check_extension_command_claims( + project_path, resolved_integration, _planned_command_files(resolved_integration) + ) + current_dir = Path.cwd() setup_lines = [ diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 59d675479e..dfa1ce6b37 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1323,6 +1323,52 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: + "\n- ".join(collisions) ) + from .._init_options import MISSING_INIT_OPTIONS_FILE, resolve_active_agent_for_registration + from ..integrations import INTEGRATION_REGISTRY + + active_agent = resolve_active_agent_for_registration(self.project_root) + agents = INTEGRATION_REGISTRY if active_agent is MISSING_INIT_OPTIONS_FILE else [active_agent] + for agent in agents: + if agent: + self._validate_command_destinations(manifest, agent) + + def _validate_command_destinations(self, manifest: ExtensionManifest, agent_name: str) -> None: + """Protect occupied destinations while a flat-command rename is pending (#4797).""" + from ..agents import CommandRegistrar + from ..integrations import get_integration + + integration = get_integration(agent_name) + legacy_dir = getattr(integration, "legacy_flat_command_dir", None) + if not legacy_dir: + return + registrar = CommandRegistrar(self.project_root) + config = registrar.AGENT_CONFIGS[agent_name] + names = self._collect_manifest_command_names(manifest) + recorded = (self.registry.get(manifest.id) or {}).get("registered_commands", {}) + tracked = self._valid_name_list(recorded.get(agent_name)) if isinstance(recorded, dict) else [] + legacy_by_stem: Dict[str, List[Path]] = {} + for name in list(names) + tracked: + stem = os.path.normcase(registrar._compute_output_name(agent_name, name, config)) + legacy_by_stem.setdefault(stem, []).append(self.project_root / legacy_dir / f"{name}{integration.legacy_flat_command_extension}") + for name in names: + stem = registrar._compute_output_name(agent_name, name, config) + path = self.project_root / config["dir"] / f"{stem}{config['extension']}" + if not os.path.lexists(path) or not any( + os.path.lexists(legacy) and not registrar._same_lexical_path(legacy, path) + for legacy in legacy_by_stem[os.path.normcase(stem)] + ): + continue + try: + if self._command_file_owned_by_extension(path, manifest.id, skill=config["extension"] == "/SKILL.md"): + continue + except (OSError, ValueError): + pass + raise ValidationError( + f"Cannot register extension '{manifest.id}': {path.relative_to(self.project_root)} is not marked as owned " + "by this extension. Move or remove it and retry; none of this extension's " + "files were written or retired." + ) + @staticmethod def _load_extensionignore( source_dir: Path, @@ -3578,8 +3624,8 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: # Get registered commands and skills before removal metadata = self.registry.get(extension_id) - registered_commands = ( - metadata.get("registered_commands", {}) if metadata else {} + registered_commands = self._recover_registered_commands( + extension_id, metadata.get("registered_commands") if metadata is not None else {} ) raw_skills = metadata.get("registered_skills", []) if metadata else [] # Normalize: must be a list of plain strings to avoid corrupted-registry errors @@ -3591,33 +3637,9 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: extension_dir = self.extensions_dir / extension_id # Unregister commands from all AI agents - if registered_commands: - safe_commands = dict(registered_commands) - if "generic" in safe_commands: - safe_commands.pop("generic") - if safe_commands: - preserved = { - agent_name: self._preserved_command_files( - extension_id, agent_name - ) - for agent_name in safe_commands - } - CommandRegistrar().unregister_commands( - safe_commands, - self.project_root, - preserved_output_names=preserved, - ) - # Registration leaves an extension's old flat files in place - # when it skips the extension, and they live outside the - # registrar's directory for Qoder (#4797). - for agent_name, command_names in safe_commands.items(): - self._retire_legacy_flat_extension_commands( - agent_name, - self._valid_name_list(command_names), - extension_id, - require_replacement=False, - preserved_output_names=preserved[agent_name], - ) + for agent_name, command_names in registered_commands.items(): + if agent_name != "generic": + self._unregister_extension_commands(extension_id, agent_name, command_names) if metadata: self._remove_generic_artifact_paths(extension_id, metadata) @@ -3772,6 +3794,24 @@ def _valid_name_list(value: Any) -> List[str]: return [] return [item for item in value if isinstance(item, str)] + def _recover_registered_commands(self, extension_id: str, recorded: Any) -> Dict[str, Any]: + """Recover unknown legacy command tracking without losing cleanup names (#4797).""" + from ..integrations import INTEGRATION_REGISTRY + + commands = copy.deepcopy(recorded) if isinstance(recorded, dict) else {} + for key, integration in INTEGRATION_REGISTRY.items(): + if getattr(integration, "legacy_flat_command_dir", None) and ( + not isinstance(recorded, dict) + or (key in recorded and recorded[key] != self._valid_name_list(recorded[key])) + ): + manifest = self.get_extension(extension_id) + valid = self._valid_name_list(commands.get(key)) + commands[key] = ( + valid + [name for name in self._collect_manifest_command_names(manifest) if name not in valid] + if manifest is not None else None + ) + return commands + def unregister_agent_artifacts( self, agent_name: str, @@ -3821,29 +3861,29 @@ def unregister_agent_artifacts( ) updates: Dict[str, Any] = {} - registered_commands = metadata.get("registered_commands", {}) - if ( - isinstance(registered_commands, dict) - and agent_name in registered_commands + registered_commands = metadata.get("registered_commands") + readable_commands = isinstance(registered_commands, dict) + if (readable_commands and agent_name in registered_commands) or ( + not readable_commands and self._core_command_files(agent_name) ): - command_names = self._valid_name_list( - registered_commands.get(agent_name) - ) + command_names = registered_commands.get(agent_name) if readable_commands else None if agent_name == "generic": command_names = self._generic_owned_names( - metadata, command_names, skills=False, extension_id=ext_id - ) - if command_names: - registrar.unregister_commands( - {agent_name: command_names}, self.project_root, - preserved_output_names={ - agent_name: self._preserved_command_files(ext_id, agent_name) - }, + metadata, self._valid_name_list(command_names), skills=False, extension_id=ext_id ) + remaining = self._unregister_extension_commands( + ext_id, agent_name, command_names, retire_legacy=not commands_only + ) - new_registered = copy.deepcopy(registered_commands) - new_registered.pop(agent_name, None) - updates["registered_commands"] = new_registered + if remaining is None: + continue + if readable_commands: + new_registered = copy.deepcopy(registered_commands) + if remaining: + new_registered[agent_name] = remaining + else: + new_registered.pop(agent_name, None) + updates["registered_commands"] = new_registered registered_skills = self._valid_name_list( metadata.get("registered_skills", []) @@ -3908,6 +3948,56 @@ def unregister_agent_artifacts( if updates: self.registry.update(ext_id, updates) + def _unregister_extension_commands( + self, extension_id: str, agent_name: str, command_names: Any, + *, retire_legacy: bool = True, + ) -> Optional[List[str]]: + """Retire owned commands and keep names whose files remain (#4797, #2948).""" + from ..integrations import get_integration + + integration = get_integration(agent_name) + legacy_dir = getattr(integration, "legacy_flat_command_dir", None) + if legacy_dir and command_names != self._valid_name_list(command_names): + manifest = self.get_extension(extension_id) + if manifest is None: + from .. import _print_cli_warning + + _print_cli_warning( + "clean up commands for", "extension", extension_id, + ExtensionError("Neither its manifest nor its registered commands can be read."), + continuing=f"Keeping its command files for {agent_name}.", + ) + return None + valid = self._valid_name_list(command_names) + command_names = valid + [name for name in self._collect_manifest_command_names(manifest) if name not in valid] + command_names = self._valid_name_list(command_names) + preserved = self._preserved_command_files(extension_id, agent_name, command_names) + registrar = CommandRegistrar(self.project_root) + registrar.unregister_commands( + {agent_name: command_names}, self.project_root, + preserved_output_names={agent_name: preserved}, + ) + if retire_legacy: + self._retire_legacy_flat_extension_commands( + agent_name, command_names, extension_id, + require_replacement=False, preserved_output_names=preserved, + ) + if not legacy_dir: + return [] + config = registrar.AGENT_CONFIGS[agent_name] + remaining = [] + for name in command_names: + if not registrar._registrar._is_safe_command_name(name): + continue + stem = registrar._registrar._compute_output_name(agent_name, name, config) + paths = ( + self.project_root / config["dir"] / f"{stem}{config['extension']}", + self.project_root / legacy_dir / f"{name}{integration.legacy_flat_command_extension}", + ) + if any(os.path.lexists(path) for path in paths): + remaining.append(name) + return remaining + def _retire_legacy_flat_extension_commands( self, agent_name: str, @@ -4007,24 +4097,23 @@ def safe_project_dir(relative: str) -> Optional[Path]: if registrar._same_lexical_path(legacy_file, replacement): continue if legacy_file.is_symlink() or legacy_file.is_file(): - if not require_replacement: - try: - if not self._command_file_owned_by_extension(legacy_file, extension_id): - raise ExtensionError( - f"Not marked as owned by extension '{extension_id}'" - ) - except (OSError, ValueError, ExtensionError) as exc: - _print_cli_warning( - "verify ownership of", "legacy command file", str(legacy_file), exc, - continuing="Preserving the legacy file.", + try: + if not self._command_file_owned_by_extension(legacy_file, extension_id, skill=False): + raise ExtensionError( + f"Not marked as owned by extension '{extension_id}'" ) - continue + except (OSError, ValueError, ExtensionError) as exc: + _print_cli_warning( + "verify ownership of", "legacy command file", str(legacy_file), exc, + continuing="Preserving the legacy file.", + ) + continue legacy_file.unlink() removed.append(legacy_file) return removed - def _command_file_owned_by_extension(self, path: Path, extension_id: str) -> bool: + def _command_file_owned_by_extension(self, path: Path, extension_id: str, *, skill: bool) -> bool: """Read the generated ownership marker, ignoring later quoted markers (#4797).""" from ..agents import CommandRegistrar from ..shared_infra import _validate_safe_shared_directory @@ -4032,33 +4121,27 @@ def _command_file_owned_by_extension(self, path: Path, extension_id: str) -> boo _validate_safe_shared_directory(self.project_root, path.parent) CommandRegistrar._ensure_inside(path.resolve(), self.project_root.resolve()) content = path.read_text(encoding="utf-8") - if path.name == "SKILL.md": - frontmatter, _ = CommandRegistrar.parse_frontmatter(content) - metadata = frontmatter.get("metadata") - source = metadata.get("source") if isinstance(metadata, dict) else None - return isinstance(source, str) and ( - source == f"extension:{extension_id}" - or source.startswith(f"{extension_id}:") - ) marker = next(( line for line in content.splitlines() if line.startswith("") ), None) - return marker == f"" + if not skill and marker is not None: + return marker == f"" + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + metadata = frontmatter.get("metadata") + source = metadata.get("source") if isinstance(metadata, dict) else None + return isinstance(source, str) and (source == f"extension:{extension_id}" or source.startswith(f"{extension_id}:")) def _preserved_command_files( - self, extension_id: str, agent_name: str + self, extension_id: str, agent_name: str, command_names: List[str] ) -> Set[str]: - """Keep core and shared migrated outputs unless ownership is proven (#4797).""" + """Keep migrated outputs unless their provenance proves ownership (#4797).""" from .. import _print_cli_warning from ..agents import CommandRegistrar preserved = self._core_command_files(agent_name, installed_only=True) core = self._core_command_files(agent_name) - shared = self._shared_command_files( - agent_name, include_core=False - ).get(extension_id, {}) - if not core and not shared: + if not core: return preserved registrar = CommandRegistrar(self.project_root) @@ -4066,11 +4149,11 @@ def _preserved_command_files( for stem in core - preserved: path = self.project_root / config["dir"] / f"{stem}{config['extension']}" try: - if not self._command_file_owned_by_extension(path, extension_id): + if not self._command_file_owned_by_extension(path, extension_id, skill=config["extension"] == "/SKILL.md"): preserved.add(stem) except (OSError, ValueError): preserved.add(stem) - for name in shared: + for name in command_names: stem = registrar._compute_output_name(agent_name, name, config) if stem in preserved: continue @@ -4078,7 +4161,7 @@ def _preserved_command_files( if not path.exists() and not path.is_symlink(): continue try: - owned = self._command_file_owned_by_extension(path, extension_id) + owned = self._command_file_owned_by_extension(path, extension_id, skill=config["extension"] == "/SKILL.md") except (OSError, ValueError) as exc: preserved.add(stem) _print_cli_warning( @@ -4194,25 +4277,32 @@ def _shared_command_files( os.path.normcase(stem): {("", stem)} for stem in core_files } if include_core else {} claims: List[tuple[str, str, str, str]] = [] - for ext_id, metadata in self.registry.list().items(): - if not isinstance(metadata, dict): - continue + if self.registry.is_corrupt(): + raise ExtensionError( + "Cannot check extension command ownership: the registry is unreadable. " + "Fix or restore .specify/extensions/.registry and retry." + ) + for ext_id in self.registry.keys(): + metadata = self.registry.get(ext_id) manifest = self.get_extension(ext_id) primary_of: Dict[str, str] = {} for command in manifest.commands if manifest is not None else []: for name in [command["name"], *(command.get("aliases") or [])]: primary_of[name] = command["name"] - recorded = metadata.get("registered_commands") - entry = recorded.get(agent_name) if isinstance(recorded, dict) else None - unreadable = (recorded is not None and not isinstance(recorded, dict)) or ( - entry is not None - and ( - not isinstance(entry, list) - or not all(isinstance(name, str) for name in entry) - ) + recorded = metadata.get("registered_commands") if metadata is not None else None + entry = recorded.get(agent_name, []) if isinstance(recorded, dict) else None + unreadable = not isinstance(recorded, dict) or ( + not isinstance(entry, list) + or not all(isinstance(name, str) for name in entry) ) + if manifest is None and unreadable: + raise ExtensionError( + f"Cannot check command ownership for extension '{ext_id}': " + "its manifest and registered commands cannot be read. " + "Fix or restore .specify/extensions/.registry and retry." + ) names = self._valid_name_list(entry) - if manifest is not None and (metadata.get("enabled", True) or unreadable): + if manifest is not None and (unreadable or metadata.get("enabled", True)): names = list(primary_of) + [n for n in names if n not in primary_of] primary_of_stem = { os.path.normcase( @@ -4290,15 +4380,26 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool agent_skills_dir = _resolve_agent_skills_dir(self.project_root, agent_name) # Registration below changes no claim this reads (#4797). - shared_files = ( - self._shared_command_files(agent_name) - if agent_config and not skills_mode_active - else {} - ) + try: + shared_files = ( + self._shared_command_files(agent_name) + if agent_config and not skills_mode_active else {} + ) + except ExtensionError as exc: + from .. import _print_cli_warning + + _print_cli_warning( + "register extension artifacts for", "integration", agent_name, exc, + continuing="Preserving extension files and registry tracking.", + ) + return # Only agents whose removal checks who owns a shared file keep a # registered name this pass did not write (see below). keep_unwritten = bool(self._core_command_files(agent_name)) + def output_stem(name): + return os.path.normcase(registrar._registrar._compute_output_name(agent_name, name, agent_config)) + for ext_id, metadata in self.registry.list().items(): if not metadata.get("enabled", True): continue @@ -4346,6 +4447,16 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool "extension so that only one command writes " "each file." ) + self._validate_command_destinations(manifest, agent_name) + registered_commands = metadata.get("registered_commands") + new_registered = self._recover_registered_commands(ext_id, registered_commands) + previous = self._valid_name_list(new_registered.get(agent_name)) + declared_stems = {output_stem(name) for name in self._collect_manifest_command_names(manifest)} if keep_unwritten else set() + remaining_commands = [] + if keep_unwritten: + remaining_commands = self._unregister_extension_commands( + ext_id, agent_name, [name for name in previous if output_stem(name) not in declared_stems] + ) or [] registered = registrar.register_commands_for_agent( agent_name, manifest, ext_dir, self.project_root ) @@ -4357,10 +4468,6 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool registered, skills=False, ) - registered_commands = metadata.get("registered_commands", {}) - if not isinstance(registered_commands, dict): - registered_commands = {} - new_registered = copy.deepcopy(registered_commands) # register_commands skips a missing source and returns # only the names it wrote; it does not raise. Replacing # the agent's list with that return value (or dropping @@ -4368,35 +4475,20 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool # that is still on disk, and extension removal then # cannot delete it. Keep a previously registered name # when this pass did not write it and the manifest - # still declares it. A name that was never registered - # is not added. A name the manifest no longer declares - # is not kept: removal deletes the formatted path, and - # another extension may now own that file. Retirement - # below still runs only for names written this pass, + # still declares its output. This also keeps a dropped + # alias's legacy file tracked for removal (#4797). + # Unknown tracking is recovered from the manifest. An + # output the manifest no longer declares is retired + # above using provenance checks; survivors stay tracked. + # Retirement below still runs only for names written this pass, # and only once that pass's replacement file exists # (#4797, #2948). Other agents drop an unwritten name # as before: an older pair of extensions can share its # file, and their removal doesn't check the owner. - declared_names: Set[str] = set() - for command in manifest.commands: - if not isinstance(command, dict): - continue - primary_name = command.get("name") - if isinstance(primary_name, str): - declared_names.add(primary_name) - aliases = command.get("aliases") or [] - if isinstance(aliases, list): - declared_names.update( - alias for alias in aliases - if isinstance(alias, str) - ) - previous = self._valid_name_list( - new_registered.get(agent_name) - ) kept = [ name for name in previous - if name in registered - or (keep_unwritten and name in declared_names) + if name in registered or name in remaining_commands + or (keep_unwritten and output_stem(name) in declared_stems) ] merged = kept + [ name for name in registered if name not in kept @@ -4609,8 +4701,8 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool metadata, fully_replaced, skills=False, extension_id=ext_id, ) - registrar.unregister_commands( - {agent_name: fully_replaced}, self.project_root + remaining = self._unregister_extension_commands( + ext_id, agent_name, fully_replaced ) registered_commands = metadata.get( "registered_commands", {} @@ -4621,7 +4713,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool new_registered = copy.deepcopy(registered_commands) remaining_commands = [ c for c in new_registered[agent_name] - if c not in fully_replaced + if c not in fully_replaced or c in remaining ] if remaining_commands: new_registered[agent_name] = remaining_commands @@ -4873,6 +4965,7 @@ def unregister_commands( registered_commands, project_root, preserved_output_names=preserved_output_names, + preserve_legacy_flat_commands=True, ) def register_commands_for_claude( diff --git a/src/specify_cli/extensions/_command_update_transaction.py b/src/specify_cli/extensions/_command_update_transaction.py index 81c7199d55..6f5b454a33 100644 --- a/src/specify_cli/extensions/_command_update_transaction.py +++ b/src/specify_cli/extensions/_command_update_transaction.py @@ -83,6 +83,7 @@ def run_update_command(extension: str | None) -> None: ) from exc hook_executor = HookExecutor(project_root) from ..agents import CommandRegistrar as _AgentReg # used in backup and rollback paths + from ..integrations import get_integration # UNSET sentinel: backup not yet captured (exception before backup step) UNSET = object() @@ -269,7 +270,9 @@ def backup_extension_skills(skill_names, *, skills_dir=None): shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) # 3. Backup command files for all agents - registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {} + registered_commands = manager._recover_registered_commands( + extension_id, backup_registry_entry.get("registered_commands") if isinstance(backup_registry_entry, dict) else {} + ) for agent_name, cmd_names in registered_commands.items(): if agent_name not in registrar.AGENT_CONFIGS: continue @@ -277,7 +280,7 @@ def backup_extension_skills(skill_names, *, skills_dir=None): commands_dir = _AgentReg._resolve_agent_dir( agent_name, agent_config, project_root ) - dirs_to_backup = [commands_dir] + dirs_to_backup = [(commands_dir, agent_config["extension"])] legacy = agent_config.get("legacy_dir") if legacy: legacy_dir = project_root / legacy @@ -285,9 +288,13 @@ def backup_extension_skills(skill_names, *, skills_dir=None): legacy_dir.exists() and legacy_dir != commands_dir ): - dirs_to_backup.append(legacy_dir) + dirs_to_backup.append((legacy_dir, agent_config["extension"])) + integration = get_integration(agent_name) + legacy_flat = getattr(integration, "legacy_flat_command_dir", None) + if legacy_flat: + dirs_to_backup.append((project_root / legacy_flat, integration.legacy_flat_command_extension)) - for cmd_name in cmd_names: + for cmd_name in manager._valid_name_list(cmd_names): output_name = _AgentReg._compute_output_name( agent_name, cmd_name, agent_config ) @@ -298,13 +305,13 @@ def backup_extension_skills(skill_names, *, skills_dir=None): ): names_to_backup.append(cmd_name) - for dir_index, target_dir in enumerate( + for dir_index, (target_dir, suffix) in enumerate( dirs_to_backup ): for name in names_to_backup: cmd_file = ( target_dir - / f"{name}{agent_config['extension']}" + / f"{name}{suffix}" ) try: _AgentReg._ensure_inside( diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 66541aad14..48caa449eb 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -145,7 +145,7 @@ def _extension_commands_at(project_root, integration, rel_paths) -> list[str]: found = [] for ext_id, metadata in extensions.items(): recorded = ( - metadata.get("registered_commands", {}) + metadata.get("registered_commands") if isinstance(metadata, dict) else None ) @@ -158,7 +158,7 @@ def _extension_commands_at(project_root, integration, rel_paths) -> list[str]: isinstance(name, str) for name in names ): raise _ExtensionRegistryUnreadableError( - f"extension '{ext_id}' registered_commands is malformed" + f"extension '{ext_id}' registered_commands is missing or malformed" ) for name in names: rel = (PurePath(commands_dir) / f"{name}{suffix}").as_posix() @@ -167,6 +167,43 @@ def _extension_commands_at(project_root, integration, rel_paths) -> list[str]: return sorted(found) +def _check_extension_command_claims(project_root, integration, planned_command_files) -> None: + """Refuse core writes at extension claims during upgrade and Kiro init (#4797).""" + import typer + + from .._console import console + from ._helpers import _cli_error_detail + + key = integration.key + try: + taken = _extension_commands_at( + project_root, integration, planned_command_files + ) + except _ExtensionRegistryUnreadableError as exc: + console.print( + f"[red]Error:[/red] Cannot write '{key}' core command files: the " + "extension registry could not be read." + ) + console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") + console.print( + "Writing them could replace an extension's files, so this is refused " + "before changing files. Fix or restore " + "[cyan].specify/extensions/.registry[/cyan] and retry." + ) + raise typer.Exit(1) + if taken: + console.print( + f"[red]Error:[/red] Cannot write '{key}' core command files while " + "extension commands use a core command's file name: " + f"[bold]{', '.join(taken)}[/bold]." + ) + console.print( + "Writing them would replace the extension's files, so this is refused " + "before changing files. Update or remove the extension(s), then retry." + ) + raise typer.Exit(1) + + 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 40f27090a2..7320d4c7e5 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -14,10 +14,9 @@ ) from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys from ._command_upgrade_layout import ( - _ExtensionRegistryUnreadableError, _PresetRegistryUnreadableError, + _check_extension_command_claims, _command_file_names_changed, - _extension_commands_at, _installed_command_presets_affecting_agent, _installed_presets_affecting_agent, _legacy_command_root_changed, @@ -200,9 +199,11 @@ def integration_upgrade( # new file would replace the override. Refuse before any mutation, as for # the layout changes above. planned_command_files = _planned_command_files(integration) - if _command_file_names_changed( + renamed = _command_file_names_changed( integration, old_manifest.files, planned_command_files - ): + ) + untracked = [rel for rel in planned_command_files if rel not in old_manifest.files] + if renamed: try: affected_presets = _installed_command_presets_affecting_agent( project_root, @@ -238,49 +239,15 @@ def integration_upgrade( f" [cyan]specify preset add [/cyan]" ) raise typer.Exit(1) - # Extension commands were written under their own names, and Spec - # Kit 1.0.7 and earlier accepted an alias such as ``speckit-plan``, - # whose file is where the renamed core command goes. Writing the core - # file would replace the extension's (or write through its dev-mode - # symlink into the extension directory). - try: - taken = _extension_commands_at( - project_root, integration, planned_command_files - ) - except _ExtensionRegistryUnreadableError as exc: - console.print( - f"[red]Error:[/red] Cannot rename '{key}' command files: the " - "extension registry could not be read to verify which " - "extension commands use the new file names." - ) - console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") - console.print( - "The upgrade could write a core command over an extension's " - "file, so it is refused before changing files. Fix or restore " - "[cyan].specify/extensions/.registry[/cyan] and retry." - ) - raise typer.Exit(1) - if taken: - console.print( - f"[red]Error:[/red] Cannot rename '{key}' command files while " - "extension commands use the new file name of a core command: " - f"[bold]{', '.join(taken)}[/bold]." - ) - console.print( - "The upgrade would write a core command over the extension's " - "file, so it is refused before changing files. Update or " - "remove the extension(s), then run the upgrade again." - ) - raise typer.Exit(1) + # Kiro CLI's manifest may not show the rename (e.g. empty files), so its + # untracked core files get the same checks. + if renamed or (key == "kiro-cli" and untracked): + _check_extension_command_claims(project_root, integration, planned_command_files if renamed else untracked) # Any other file already at a new core name is tracked by nothing, # e.g. a prompt the user wrote because Kiro ignored the dotted names, # so ownership can't be verified. Replace it only with --force, and # never write through a symlink to wherever it points. - occupied = sorted( - rel for rel in planned_command_files - if rel not in old_manifest.files - and os.path.lexists(project_root / rel) - ) + occupied = sorted(rel for rel in untracked if os.path.lexists(project_root / rel)) linked = [rel for rel in occupied if (project_root / rel).is_symlink()] if linked or (occupied and not force): console.print( diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 25eb884b29..525a14b02c 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -61,6 +61,23 @@ def _write_command_preset(tmp_path, preset_id): return preset_src +def _write_command_extension(tmp_path, aliases=()): + import yaml + + source = tmp_path / "audit-source" + (source / "commands").mkdir(parents=True) + (source / "commands/run.md").write_bytes(b"---\ndescription: Audit\n---\nAUDIT-BODY\n") + (source / "extension.yml").write_bytes(yaml.safe_dump({ + "schema_version": "1.0", + "extension": {"id": "audit", "name": "Audit", "version": "1.0.0", "description": "Test"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [{ + "name": "speckit.audit.run", "file": "commands/run.md", "aliases": list(aliases), + }]}, + }).encode()) + return source + + 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 @@ -73,7 +90,7 @@ def _init_dotted_kiro_project(tmp_path, monkeypatch, *commands): KiroCliIntegration, "command_filename", MarkdownIntegration.command_filename, ) - m.delitem(CommandRegistrar.AGENT_CONFIGS["kiro-cli"], "format_name") + m.delitem(CommandRegistrar.AGENT_CONFIGS["kiro-cli"], "format_name", raising=False) project = _init_project(tmp_path, "kiro-cli") for args in commands: result = _run_in_project(project, args) @@ -635,6 +652,8 @@ def test_upgrade_drops_a_kiro_command_the_manifest_no_longer_declares( prompts = project / ".kiro" / "prompts" assert (prompts / "speckit-git-feature.md").is_file() assert (prompts / "speckit-plan.md").is_file() + assert not (prompts / "speckit-git-commit.md").exists() + assert not (prompts / "speckit-git-c.md").exists() @pytest.mark.parametrize( ("agent", "output"), @@ -672,6 +691,642 @@ def test_unwritten_name_is_dropped_where_removal_does_not_check_owners( assert result.exit_code == 0, result.output assert shared.read_bytes() == before + @pytest.mark.parametrize("operation", ["remove", "switch", "upgrade"]) + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("body", [b"USER\n", b"\n", b"\xff"]) + def test_legacy_extension_cleanup_respects_ownership_and_replacements( + self, tmp_path, operation, agent, body + ): + project = _init_project(tmp_path, agent) + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, result.output + directory = ".kiro/prompts" if agent == "kiro-cli" else ".qoder/commands" + legacy = project / directory / "speckit.git.commit.md" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_bytes(body) + args = ( + ["extension", "remove", "git", "--force"] if operation == "remove" + else ["integration", "switch", "claude"] if operation == "switch" + else ["integration", "upgrade", agent] + ) + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + assert legacy.read_bytes() == body + assert "Preserving the legacy file" in result.output + if operation == "switch": + from specify_cli.extensions import ExtensionManager + + tracked = ExtensionManager(project).registry.get("git")["registered_commands"] + assert "speckit.git.commit" in tracked[agent] + legacy.write_bytes(b"\nRestored ownership\n") + result = _run_in_project(project, ["extension", "remove", "git", "--force"]) + assert result.exit_code == 0, result.output + assert not legacy.exists() + + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("commands_only", [False, True]) + def test_integration_cleanup_retires_owned_legacy_extension_commands( + self, tmp_path, agent, commands_only + ): + project = _init_project(tmp_path, agent) + # A pre-existing collision keeps both owners in the legacy layout. + for ext_id, name in [("foo", "speckit.foo.bar-baz"), ("foo-bar", "speckit.foo-bar.baz")]: + self._plant_extension(project, ext_id, [{"name": name, "body": "BODY\n"}], agent=agent) + directory = ".kiro/prompts" if agent == "kiro-cli" else ".qoder/commands" + legacy = project / directory / "speckit.foo.bar-baz.md" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_bytes(b"\nFOO\n") + if commands_only: + from specify_cli.extensions import ExtensionManager + + manager = ExtensionManager(project) + manager.unregister_agent_artifacts(agent, commands_only=True) + assert legacy.exists() + assert agent in manager.registry.get("foo")["registered_commands"] + assert manager.remove("foo") + assert not legacy.exists() + return + result = _run_in_project(project, ["integration", "switch", "claude"]) + assert result.exit_code == 0, result.output + assert not legacy.exists() + from specify_cli.extensions import ExtensionManager + + assert agent not in ExtensionManager(project).registry.get("foo")["registered_commands"] + + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("operation", ["use", "upgrade", "remove"]) + @pytest.mark.parametrize("readable_manifest", [True, False]) + @pytest.mark.parametrize("damage", ["entry", "null-agent", "missing"]) + def test_malformed_owner_cannot_lose_shared_extension_file( + self, tmp_path, agent, operation, readable_manifest, damage + ): + project = _init_project(tmp_path, agent) + self._plant_extension(project, "foo-bar", [{ + "name": "speckit.foo-bar.baz", "body": "BAR\n", "aliases": ["speckit-foo-bar-baz"], + }], agent=agent) + result = _run_in_project(project, ["integration", "use", agent]) + assert result.exit_code == 0, result.output + output = ( + ".kiro/prompts/speckit-foo-bar-baz.md" if agent == "kiro-cli" + else ".qoder/skills/speckit-foo-bar-baz/SKILL.md" + ) + shared = project / output + before = shared.read_bytes() + self._plant_extension(project, "foo", [{ + "name": "speckit.foo.bar-baz", "body": "FOO\n", + }], agent=agent) + registry_path = project / ".specify/extensions/.registry" + registry = json.loads(registry_path.read_bytes()) + registry["extensions"]["foo-bar"] = ( + "damaged" if damage == "entry" + else {"enabled": False, "registered_commands": {agent: None}} + ) + if damage == "missing": + del registry["extensions"]["foo-bar"]["registered_commands"] + registry_path.write_bytes(json.dumps(registry).encode()) + if not readable_manifest: + (project / ".specify/extensions/foo-bar/extension.yml").write_bytes(b"invalid: [") + args = ( + ["extension", "remove", "foo", "--force"] if operation == "remove" + else ["integration", operation, agent] + ) + result = _run_in_project(project, args) + assert shared.read_bytes() == before + assert result.exit_code == 0, result.output + if operation == "remove": + del registry["extensions"]["foo"] + assert json.loads(registry_path.read_bytes()) == registry + elif not readable_manifest: + assert "foo-bar" in result.output + assert "Fix or restore .specify/extensions/.registry" in _normalize_cli_output(result.output) + assert json.loads(registry_path.read_bytes()) == registry + + @pytest.mark.parametrize("operation", ["use", "upgrade", "switch", "reinstall", "update"]) + @pytest.mark.parametrize("linked", [False, True]) + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + def test_migration_preserves_unowned_destination(self, tmp_path, monkeypatch, operation, linked, agent): + import yaml + from specify_cli.extensions import ExtensionCatalog, ExtensionManager + + source = _write_command_extension(tmp_path) + if agent == "kiro-cli": + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)] + ) + destination = project / ".kiro/prompts/speckit-audit-run.md" + else: + project = _init_project(tmp_path, agent) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + destination = project / ".qoder/skills/speckit-audit-run/SKILL.md" + legacy = project / ".qoder/commands/speckit.audit.run.md" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_bytes(destination.read_bytes()) + destination.unlink() + target = project / "user-prompt.md" + before = b"USER WORKAROUND PROMPT\n" + if linked: + target.write_bytes(before) + try: + destination.symlink_to(target) + except OSError: + pytest.skip("Symlinks are unavailable") + else: + destination.write_bytes(before) + if operation == "switch": + assert _run_in_project(project, ["integration", "install", "claude"]).exit_code == 0 + assert _run_in_project(project, ["integration", "use", "claude"]).exit_code == 0 + tracked = ExtensionManager(project).registry.get("audit")["registered_commands"][agent] + if operation == "update": + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda *args: { + "id": "audit", "name": "Audit", "version": "2.0.0", "bundled": True, "_install_allowed": True, + }) + monkeypatch.setattr("specify_cli._locate_bundled_extension", lambda key: source) + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda *args: pytest.fail("Unexpected download")) + manifest = yaml.safe_load((source / "extension.yml").read_bytes()) + manifest["extension"]["version"] = "2.0.0" + (source / "extension.yml").write_bytes(yaml.safe_dump(manifest).encode()) + from contextlib import chdir + + with chdir(project): + result = runner.invoke(app, ["extension", "update", "audit"], input="y\n", catch_exceptions=False) + else: + args = ["extension", "add", "--dev", str(source), "--force"] if operation == "reinstall" else ["integration", operation, agent] + result = _run_in_project(project, args) + assert destination.read_bytes() == before + if linked: + assert destination.is_symlink() and target.read_bytes() == before + assert "Move or remove" in _normalize_cli_output(result.output) + assert destination.relative_to(project).as_posix() in _normalize_cli_output(result.output) + assert ExtensionManager(project).registry.get("audit")["registered_commands"][agent] == tracked + + @pytest.mark.parametrize("alias", ["speckit-plan", "plan"]) + @pytest.mark.parametrize("linked", [False, True]) + @pytest.mark.parametrize("core_manifest,operation", [ + (state, "init") for state in ("readable", "missing", "unreadable", "empty") + ] + [("empty", "upgrade")]) + def test_kiro_reinit_preserves_legacy_alias(self, tmp_path, monkeypatch, alias, linked, core_manifest, operation): + from specify_cli.extensions import ExtensionManager + + source = _write_command_extension(tmp_path, aliases=[alias]) + with monkeypatch.context() as patch: + patch.setattr(ExtensionManager, "_validate_install_conflicts", lambda *args: None) + project = _init_dotted_kiro_project( + tmp_path, patch, ["extension", "add", "--dev", str(source)] + ) + path = project / ".kiro/prompts" / f"{alias}.md" + before = path.read_bytes() + if linked: + target = project / ".specify/extensions/audit/alias-cache.md" + target.write_bytes(before) + path.unlink() + try: + path.symlink_to(target) + except OSError: + pytest.skip("Symlinks are unavailable") + manifest_path = project / ".specify/integrations/kiro-cli.manifest.json" + if core_manifest == "missing": + manifest_path.unlink() + elif core_manifest == "unreadable": + manifest_path.write_bytes(b"invalid: [") + elif core_manifest == "empty": + manifest = json.loads(manifest_path.read_bytes()) + manifest["files"] = {} + manifest_path.write_bytes(json.dumps(manifest).encode()) + snapshot = {p: p.read_bytes() for root in (project / ".specify", project / ".kiro") for p in root.rglob("*") if p.is_file()} + args = (["integration", "upgrade", "kiro-cli"] if operation == "upgrade" else + ["init", "--here", "--integration", "kiro-cli", "--script", "sh", "--ignore-agent-tools", "--force"]) + result = _run_in_project(project, args) + assert path.read_bytes() == before + if linked: + assert path.is_symlink() and target.read_bytes() == before + if alias == "speckit-plan": + assert result.exit_code == 1, result.output + assert {p: p.read_bytes() for p in snapshot} == snapshot + else: + assert result.exit_code == 0, result.output + + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("legacy", [False, True]) + @pytest.mark.parametrize("content", [None, b"USER FILE\n", b"\xff"]) + def test_manifest_rename_retires_owned_output(self, tmp_path, monkeypatch, agent, legacy, content): + import yaml + from specify_cli.agents import CommandRegistrar + from specify_cli.extensions import ExtensionManager + + source = _write_command_extension(tmp_path) + if legacy and agent == "kiro-cli": + project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) + old = project / ".kiro/prompts/speckit.audit.run.md" + else: + project = _init_project(tmp_path, agent) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + assert _run_in_project(project, ["integration", "use", agent]).exit_code == 0 + registrar = CommandRegistrar(project) + config = registrar.AGENT_CONFIGS[agent] + stem = registrar._compute_output_name(agent, "speckit.audit.run", config) + old = project / config["dir"] / f"{stem}{config['extension']}" + if legacy: + flat = project / ".qoder/commands/speckit.audit.run.md" + flat.parent.mkdir(parents=True) + flat.write_bytes(old.read_bytes()) + old.unlink() + old = flat + if content is not None: + old.unlink() + old.write_bytes(content) + path = project / ".specify/extensions/audit/extension.yml" + manifest = yaml.safe_load(path.read_bytes()) + manifest["provides"]["commands"][0]["name"] = "speckit.audit.renamed" + path.write_bytes(yaml.safe_dump(manifest).encode()) + assert _run_in_project(project, ["integration", "use", agent]).exit_code == 0 + if content is None: + assert not old.exists() and not old.is_symlink() + else: + assert old.read_bytes() == content + assert "speckit.audit.run" in ExtensionManager(project).registry.get("audit")["registered_commands"][agent] + result = _run_in_project(project, ["extension", "remove", "audit", "--force"]) + assert result.exit_code == 0, result.output + if content is not None: + assert old.read_bytes() == content + assert "Preserving" in result.output + + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("operation", ["remove", "switch"]) + @pytest.mark.parametrize("readable_manifest", [True, False]) + @pytest.mark.parametrize("damage", ["per-agent", None, [], "speckit.audit.run", "missing", "empty", "stale"]) + def test_malformed_cleanup_recovers_names_or_keeps_tracking(self, tmp_path, agent, operation, readable_manifest, damage): + from specify_cli.agents import CommandRegistrar + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, agent) + source = _write_command_extension(tmp_path, aliases=["audit-extra"] if damage in ("missing", "empty") else []) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + assert _run_in_project(project, ["integration", "use", agent]).exit_code == 0 + registrar = CommandRegistrar(project) + config = registrar.AGENT_CONFIGS[agent] + def output(name): + return project / config["dir"] / f"{registrar._compute_output_name(agent, name, config)}{config['extension']}" + owned, unrelated = output("speckit.audit.run"), output("audit-extra" if damage in ("missing", "empty") else "s") + owned_body = owned.read_bytes() + unrelated.parent.mkdir(parents=True, exist_ok=True) + unrelated.unlink(missing_ok=True) + unrelated.write_bytes(b"USER\n") + manager = ExtensionManager(project) + recorded = {agent: "speckit.audit.run"} if damage == "per-agent" else damage + if damage == "stale": + import yaml + + manifest_path = project / ".specify/extensions/audit/extension.yml" + manifest = yaml.safe_load(manifest_path.read_bytes()) + manifest["provides"]["commands"][0]["name"] = "speckit.audit.renamed" + manifest_path.write_bytes(yaml.safe_dump(manifest).encode()) + recorded = {agent: ["speckit.audit.run", 1]} + manager.registry.update("audit", {"registered_commands": recorded}) + if damage in ("missing", "empty"): + registry = project / ".specify/extensions/.registry" + data = json.loads(registry.read_bytes()) + if damage == "missing": + del data["extensions"]["audit"]["registered_commands"] + else: + data["extensions"]["audit"] = {} + registry.write_bytes(json.dumps(data).encode()) + if not readable_manifest: + (project / ".specify/extensions/audit/extension.yml").write_bytes(b"invalid: [") + args = ["extension", "remove", "audit", "--force"] if operation == "remove" else ["integration", "switch", "claude"] + result = _run_in_project(project, args) + assert unrelated.read_bytes() == b"USER\n" + if readable_manifest: + assert result.exit_code == 0, result.output + assert not owned.exists() + if operation == "switch" and damage in ("missing", "empty"): + tracked = ExtensionManager(project).registry.get("audit")["registered_commands"] + assert {"speckit.audit.run", "audit-extra"} <= set(tracked[agent]) + unrelated.write_bytes(owned_body) + assert _run_in_project(project, ["extension", "remove", "audit", "--force"]).exit_code == 0 + assert not unrelated.exists() + else: + assert os.path.lexists(owned) + metadata = ExtensionManager(project).registry.get("audit") + if operation == "remove": + assert result.exit_code == 0, result.output + assert metadata is None + elif damage in ("missing", "empty"): + assert "registered_commands" not in metadata + else: + assert metadata["registered_commands"] == recorded + assert "Neither its manifest nor its registered commands can be read." in _normalize_cli_output(result.output) + assert f"Keeping its command files for {agent}." in _normalize_cli_output(result.output) + + @pytest.mark.parametrize("target_agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("damage", ["missing", "null-agent"]) + def test_refresh_recovers_unknown_tracking_before_removal(self, tmp_path, target_agent, damage): + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, "kiro-cli") + assert _run_in_project(project, ["extension", "add", "git"]).exit_code == 0 + prompt = next((project / ".kiro/prompts").glob("*git*commit.md")) + before = prompt.read_bytes() + if target_agent == "kiro-cli": + (project / ".specify/extensions/git/commands/speckit.git.commit.md").unlink() + else: + assert _run_in_project(project, ["integration", "install", target_agent]).exit_code == 0 + registry = project / ".specify/extensions/.registry" + data = json.loads(registry.read_bytes()) + if damage == "missing": + del data["extensions"]["git"]["registered_commands"] + else: + data["extensions"]["git"]["registered_commands"] = {"kiro-cli": None} + registry.write_bytes(json.dumps(data).encode()) + result = _run_in_project(project, ["integration", "use", target_agent]) + assert result.exit_code == 0, result.output + assert prompt.read_bytes() == before + tracked = ExtensionManager(project).registry.get("git")["registered_commands"] + assert "speckit.git.commit" in tracked["kiro-cli"] + assert _run_in_project(project, ["extension", "remove", "git", "--force"]).exit_code == 0 + assert not prompt.exists() + + @pytest.mark.parametrize("tracking", ["valid", "missing", "null-agent"]) + def test_failed_update_restores_qoder_legacy_commands(self, tmp_path, monkeypatch, tracking): + import yaml + from contextlib import chdir + from specify_cli.extensions import ExtensionCatalog, ExtensionManager + + project = _init_project(tmp_path, "qodercli") + source = _write_command_extension(tmp_path) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + legacy = project / ".qoder/commands/speckit.audit.run.md" + legacy.parent.mkdir(parents=True) + body = b"\nLOCAL LEGACY EDIT\n" + legacy.write_bytes(body) + (project / ".qoder/skills/speckit-audit-run/SKILL.md").unlink() + registry = project / ".specify/extensions/.registry" + data = json.loads(registry.read_bytes()) + if tracking == "missing": + del data["extensions"]["audit"]["registered_commands"] + elif tracking == "null-agent": + data["extensions"]["audit"]["registered_commands"] = {"qodercli": None} + registry.write_bytes(json.dumps(data).encode()) + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda *args: { + "id": "audit", "name": "Audit", "version": "2.0.0", "bundled": True, "_install_allowed": True, + }) + monkeypatch.setattr("specify_cli._locate_bundled_extension", lambda key: source) + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda *args: pytest.fail("Unexpected download")) + manifest_path = source / "extension.yml" + manifest = yaml.safe_load(manifest_path.read_bytes()) + manifest["extension"]["version"] = "2.0.0" + manifest_path.write_bytes(yaml.safe_dump(manifest).encode()) + def fail_install(*args, **kwargs): + raise RuntimeError("Injected install failure") + monkeypatch.setattr(ExtensionManager, "install_from_zip", fail_install) + with chdir(project): + result = runner.invoke(app, ["extension", "update", "audit"], input="y\n", catch_exceptions=False) + assert result.exit_code == 1, result.output + assert "Injected install failure" in result.output + assert legacy.read_bytes() == body + assert json.loads(registry.read_bytes()) == data + + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("operation", ["use", "upgrade"]) + @pytest.mark.parametrize("missing_source", [False, True]) + def test_dropped_alias_keeps_declared_command_output(self, tmp_path, agent, operation, missing_source): + import yaml + from specify_cli.agents import CommandRegistrar + + project = _init_project(tmp_path, agent) + source = _write_command_extension(tmp_path, aliases=["audit-run"]) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + assert _run_in_project(project, ["integration", "use", agent]).exit_code == 0 + registrar = CommandRegistrar(project) + config = registrar.AGENT_CONFIGS[agent] + stem = registrar._compute_output_name(agent, "speckit.audit.run", config) + path = project / config["dir"] / f"{stem}{config['extension']}" + before = path.read_bytes() + manifest_path = project / ".specify/extensions/audit/extension.yml" + manifest = yaml.safe_load(manifest_path.read_bytes()) + manifest["provides"]["commands"][0]["aliases"] = [] + manifest_path.write_bytes(yaml.safe_dump(manifest).encode()) + if missing_source: + (manifest_path.parent / "commands/run.md").unlink() + result = _run_in_project(project, ["integration", operation, agent]) + assert result.exit_code == 0, result.output + assert path.is_file() + assert path.read_bytes() == before if missing_source else b"AUDIT-BODY" in path.read_bytes() + + @pytest.mark.parametrize("missing_source", [False, True]) + def test_dropped_legacy_alias_stays_tracked_for_removal(self, tmp_path, monkeypatch, missing_source): + import yaml + from specify_cli.extensions import ExtensionManager + + source = _write_command_extension(tmp_path, aliases=["audit-run"]) + project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) + alias = project / ".kiro/prompts/audit-run.md" + assert alias.is_file() + path = project / ".specify/extensions/audit/extension.yml" + manifest = yaml.safe_load(path.read_bytes()) + manifest["provides"]["commands"][0]["aliases"] = [] + path.write_bytes(yaml.safe_dump(manifest).encode()) + if missing_source: + (path.parent / "commands/run.md").unlink() + assert _run_in_project(project, ["integration", "use", "kiro-cli"]).exit_code == 0 + assert "audit-run" in ExtensionManager(project).registry.get("audit")["registered_commands"]["kiro-cli"] + assert _run_in_project(project, ["extension", "remove", "audit", "--force"]).exit_code == 0 + assert not os.path.lexists(alias) + + @pytest.mark.parametrize("operation", ["use", "upgrade", "reinstall", "update", "remove"]) + def test_template_metadata_does_not_hide_generated_owner(self, tmp_path, monkeypatch, operation): + from specify_cli.extensions import ExtensionCatalog + from specify_cli.agents import CommandRegistrar + from contextlib import chdir + + project = _init_project(tmp_path, "kiro-cli") + source = _write_command_extension(tmp_path) + (source / "commands/run.md").write_bytes( + b"---\ndescription: Audit\nmetadata: {source: upstream-template}\n---\nAUDIT-BODY\n" + ) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + registrar = CommandRegistrar(project) + config = registrar.AGENT_CONFIGS["kiro-cli"] + stem = registrar._compute_output_name("kiro-cli", "speckit.audit.run", config) + path = project / config["dir"] / f"{stem}{config['extension']}" + assert path.is_file() + if operation == "update": + manifest_path = source / "extension.yml" + manifest_path.write_bytes(manifest_path.read_bytes().replace(b"version: 1.0.0", b"version: 2.0.0")) + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda *args: { + "id": "audit", "name": "Audit", "version": "2.0.0", "bundled": True, "_install_allowed": True, + }) + monkeypatch.setattr("specify_cli._locate_bundled_extension", lambda key: source) + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda *args: pytest.fail("Unexpected download")) + with chdir(project): + result = runner.invoke(app, ["extension", "update", "audit"], input="y\n", catch_exceptions=False) + else: + args = (["extension", "remove", "audit", "--force"] if operation == "remove" else + ["extension", "add", "--dev", str(source), "--force"] if operation == "reinstall" else + ["integration", operation, "kiro-cli"]) + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + assert "not marked as owned" not in _normalize_cli_output(result.output) + if operation == "remove": + assert not os.path.lexists(path) + else: + assert b"AUDIT-BODY" in path.read_bytes() + + @pytest.mark.parametrize("agent,source_metadata", [ + ("kiro-cli", "foo:upstream-template"), ("kiro-cli", "extension:foo"), ("qodercli", None), + ]) + def test_generated_owner_overrules_other_formats_marker(self, tmp_path, monkeypatch, agent, source_metadata): + import yaml + from specify_cli.extensions import ExtensionManager + + sources = [] + for extension_id, command in (("foo", "speckit.foo.bar-baz"), ("foo-bar", "speckit.foo-bar.baz")): + source = _write_command_extension(tmp_path / extension_id) + path = source / "extension.yml" + manifest = yaml.safe_load(path.read_bytes()) + manifest["extension"]["id"] = extension_id + manifest["provides"]["commands"][0]["name"] = command + if extension_id == "foo-bar": + manifest["provides"]["commands"][0]["aliases"] = ["speckit-foo-bar-baz"] if agent == "kiro-cli" else [] + frontmatter = {"description": "Borrowed template", "metadata": {"source": source_metadata}} + (source / "commands/run.md").write_bytes( + ("---\n" + yaml.safe_dump(frontmatter) + "---\nSURVIVOR\n\n").encode() + ) + path.write_bytes(yaml.safe_dump(manifest).encode()) + sources.append(source) + with monkeypatch.context() as patch: + patch.setattr(ExtensionManager, "_validate_install_conflicts", lambda *args: None) + commands = [["extension", "add", "--dev", str(source)] for source in sources] + if agent == "kiro-cli": + project = _init_dotted_kiro_project(tmp_path, patch, *commands) + else: + project = _init_project(tmp_path, agent) + for args in commands: + assert _run_in_project(project, args).exit_code == 0 + path = project / (".kiro/prompts/speckit-foo-bar-baz.md" if agent == "kiro-cli" else + ".qoder/skills/speckit-foo-bar-baz/SKILL.md") + before = path.read_bytes() + assert b"SURVIVOR" in before + result = _run_in_project(project, ["extension", "remove", "foo", "--force"]) + assert result.exit_code == 0, result.output + assert path.read_bytes() == before + assert ExtensionManager(project).registry.get("foo-bar")["enabled"] + + @pytest.mark.parametrize("readable_preset", [True, False]) + @pytest.mark.parametrize("operation", ["use", "upgrade", "reinstall", "update"]) + def test_pending_migration_preserves_preset_override(self, tmp_path, monkeypatch, operation, readable_preset): + import yaml + from contextlib import chdir + from specify_cli.extensions import ExtensionCatalog, ExtensionManager + + source = _write_command_extension(tmp_path) + project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) + assert _run_in_project(project, ["extension", "disable", "audit"]).exit_code == 0 + assert _run_in_project(project, ["integration", "upgrade", "kiro-cli"]).exit_code == 0 + preset = _write_command_preset(tmp_path, "audit-preset") + preset_path = preset / "preset.yml" + data = yaml.safe_load(preset_path.read_bytes()) + data["provides"]["templates"][0]["name"] = "speckit.audit.run" + preset_path.write_bytes(yaml.safe_dump(data).encode()) + assert _run_in_project(project, ["preset", "add", "--dev", str(preset)]).exit_code == 0 + destination = project / ".kiro/prompts/speckit-audit-run.md" + before = destination.read_bytes() + legacy = project / ".kiro/prompts/speckit.audit.run.md" + old_content = legacy.read_bytes() + tracked = ExtensionManager(project).registry.get("audit")["registered_commands"] + if not readable_preset: + (project / ".specify/presets/audit-preset/preset.yml").write_bytes(b"invalid: [") + assert _run_in_project(project, ["extension", "enable", "audit"]).exit_code == 0 + if operation == "update": + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda *args: { + "id": "audit", "name": "Audit", "version": "2.0.0", "bundled": True, "_install_allowed": True, + }) + monkeypatch.setattr("specify_cli._locate_bundled_extension", lambda key: source) + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda *args: pytest.fail("Unexpected download")) + manifest_path = source / "extension.yml" + manifest = yaml.safe_load(manifest_path.read_bytes()) + manifest["extension"]["version"] = "2.0.0" + manifest_path.write_bytes(yaml.safe_dump(manifest).encode()) + with chdir(project): + result = runner.invoke(app, ["extension", "update", "audit"], input="y\n", catch_exceptions=False) + else: + args = (["extension", "add", "--dev", str(source), "--force"] if operation == "reinstall" else + ["integration", operation, "kiro-cli"] + (["--force"] if operation == "upgrade" else [])) + result = _run_in_project(project, args) + assert result.exit_code == (1 if operation in ("reinstall", "update") else 0), result.output + assert "Move or remove" in _normalize_cli_output(result.output) + assert destination.read_bytes() == before + assert legacy.read_bytes() == old_content + assert ExtensionManager(project).registry.get("audit")["registered_commands"] == tracked + + @pytest.mark.parametrize("alias", ["SKILL", "ordinary-alias"]) + @pytest.mark.parametrize("operation", ["remove", "switch", "use", "upgrade"]) + def test_flat_alias_uses_command_ownership_regardless_of_basename(self, tmp_path, monkeypatch, alias, operation): + source = _write_command_extension(tmp_path, aliases=[alias]) + project = _init_dotted_kiro_project( + tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)], ["integration", "use", "kiro-cli"] + ) + legacy = project / ".kiro/prompts" / f"{alias}.md" + assert b"" in legacy.read_bytes() + if operation == "remove": + args = ["extension", "remove", "audit", "--force"] + elif operation == "switch": + args = ["integration", "switch", "claude"] + else: + args = ["integration", operation, "kiro-cli"] + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + if operation in ("use", "upgrade"): + assert _run_in_project(project, ["extension", "remove", "audit", "--force"]).exit_code == 0 + assert not os.path.lexists(legacy) + + @pytest.mark.parametrize("operation", ["reinstall", "update"]) + @pytest.mark.parametrize("destination_kind", ["file", "symlink", "absent"]) + def test_package_rename_preserves_pending_destination(self, tmp_path, monkeypatch, operation, destination_kind): + import yaml + from contextlib import chdir + from specify_cli.extensions import ExtensionCatalog + + source = _write_command_extension(tmp_path) + project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) + destination = project / ".kiro/prompts/speckit-audit-run.md" + target = project / "user-workaround.md" + before = b"USER WORKAROUND\n" + if destination_kind == "file": + destination.write_bytes(before) + elif destination_kind == "symlink": + target.write_bytes(before) + try: + destination.symlink_to(target) + except OSError: + pytest.skip("Symlinks are unavailable") + path = source / "extension.yml" + manifest = yaml.safe_load(path.read_bytes()) + manifest["provides"]["commands"][0].update(name="speckit.audit.other", aliases=["audit-run"]) + manifest["extension"]["version"] = "2.0.0" + path.write_bytes(yaml.safe_dump(manifest).encode()) + if operation == "update": + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda *args: { + "id": "audit", "name": "Audit", "version": "2.0.0", "bundled": True, "_install_allowed": True, + }) + monkeypatch.setattr("specify_cli._locate_bundled_extension", lambda key: source) + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda *args: pytest.fail("Unexpected download")) + with chdir(project): + result = runner.invoke(app, ["extension", "update", "audit"], input="y\n", catch_exceptions=False) + else: + result = _run_in_project(project, ["extension", "add", "--dev", str(source), "--force"]) + if destination_kind == "absent": + assert result.exit_code == 0, result.output + from specify_cli.agents import CommandRegistrar + + registrar = CommandRegistrar(project) + config = registrar.AGENT_CONFIGS["kiro-cli"] + stem = registrar._compute_output_name("kiro-cli", "audit-run", config) + assert b"AUDIT-BODY" in (project / config["dir"] / f"{stem}{config['extension']}").read_bytes() + else: + assert destination.read_bytes() == before + if destination_kind == "symlink": + assert destination.is_symlink() and target.read_bytes() == before + def _plant_extension( self, project, @@ -736,9 +1391,9 @@ def test_upgrade_leaves_extensions_that_hyphenate_to_one_prompt_unregistered( tmp_path, monkeypatch, ["extension", "add", "git"] ) prompts = project / ".kiro" / "prompts" - foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" - bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" - other_body = "---\ndescription: Other\n---\n\nOTHER-BODY\n" + foo_body = "---\ndescription: Foo\n---\n\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\n\nBAR-BODY\n" + other_body = "---\ndescription: Other\n---\n\n\nOTHER-BODY\n" self._plant_extension(project, "foo", [ {"name": "speckit.foo.bar-baz", "body": foo_body}, {"name": "speckit.foo.other", "body": other_body}, @@ -810,8 +1465,8 @@ def test_inactive_extension_keeps_its_claim_on_a_shared_kiro_prompt( first one delete that file, so both stay as they were (#4797).""" project = _init_dotted_kiro_project(tmp_path, monkeypatch) prompts = project / ".kiro" / "prompts" - foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" - bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" + foo_body = "---\ndescription: Foo\n---\n\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\n\nBAR-BODY\n" self._plant_extension(project, "foo", [ {"name": "speckit.foo.bar-baz", "body": foo_body}, ]) @@ -850,7 +1505,7 @@ def test_inactive_extension_keeps_its_claim_on_a_shared_kiro_prompt( @pytest.mark.parametrize( "damage", - [[], "speckit.foo-bar.baz", {"kiro-cli": "speckit.foo-bar.baz"}, {"kiro-cli": [1]}], + [[], "speckit.foo-bar.baz", {"kiro-cli": "speckit.foo-bar.baz"}, {"kiro-cli": [1]}, "missing"], ) def test_disabled_extension_with_unreadable_registration_keeps_its_prompt( self, tmp_path, damage @@ -867,9 +1522,12 @@ def test_disabled_extension_with_unreadable_registration_keeps_its_prompt( body = b"---\ndescription: Bar\n---\n\n\nBAR-BODY\n" shared.write_bytes(body) registry = project / ".specify" / "extensions" / ".registry" - data = json.loads(registry.read_text(encoding="utf-8")) - data["extensions"]["foo-bar"]["registered_commands"] = damage - registry.write_text(json.dumps(data), encoding="utf-8") + data = json.loads(registry.read_bytes()) + if damage == "missing": + del data["extensions"]["foo-bar"]["registered_commands"] + else: + data["extensions"]["foo-bar"]["registered_commands"] = damage + registry.write_bytes(json.dumps(data).encode()) self._plant_extension(project, "foo", [ {"name": "speckit.foo.bar-baz", "body": "FOO-BODY\n"}, ]) @@ -892,8 +1550,8 @@ def test_enabling_a_colliding_extension_keeps_the_migrated_prompt( deletes its own prompt, and the other one moves on the next pass.""" project = _init_dotted_kiro_project(tmp_path, monkeypatch) prompts = project / ".kiro" / "prompts" - foo_body = "---\ndescription: Foo\n---\n\nFOO-BODY\n" - bar_body = "---\ndescription: Bar\n---\n\nBAR-BODY\n" + foo_body = "---\ndescription: Foo\n---\n\n\nFOO-BODY\n" + bar_body = "---\ndescription: Bar\n---\n\n\nBAR-BODY\n" self._plant_extension(project, "foo", [ {"name": "speckit.foo.bar-baz", "body": foo_body}, ]) @@ -941,7 +1599,7 @@ def test_alias_that_hyphenates_onto_a_core_prompt_never_replaces_it( prompt while deleting the extension's own files (#4797).""" project = _init_dotted_kiro_project(tmp_path, monkeypatch) prompts = project / ".kiro" / "prompts" - body = "---\ndescription: Old\n---\n\nOLD-BODY\n" + body = "---\ndescription: Old\n---\n\n\nOLD-BODY\n" self._plant_extension(project, "old", [ {"name": "speckit.old.plan", "body": body, "aliases": ["plan"]}, ]) @@ -993,7 +1651,7 @@ def test_removal_preserves_core_without_manifest_tracking(self, tmp_path, agent, def test_integration_cleanup_preserves_modified_core_with_old_alias( self, tmp_path, monkeypatch, operation ): - """Extension cleanup must keep a core prompt preserved by teardown (#4797).""" + """Switch tests extension cleanup; uninstall tests only manifest teardown (#4797).""" project = _init_dotted_kiro_project(tmp_path, monkeypatch) self._plant_extension(project, "old", [{ "name": "speckit.old.plan", "aliases": ["plan"], "body": "OLD-BODY\n", @@ -1132,7 +1790,7 @@ def test_upgrade_refuses_while_an_extension_prompt_has_a_core_prompt_name( @pytest.mark.parametrize( "corruption", - ["registry", "entry", "registered_commands", "agent_entry", "agent_item"], + ["registry", "entry", "registered_commands", "agent_entry", "agent_item", "missing"], ) def test_upgrade_refuses_kiro_prompt_rename_while_extension_registry_is_unreadable( self, tmp_path, monkeypatch, corruption @@ -1160,9 +1818,11 @@ def test_upgrade_refuses_kiro_prompt_rename_while_extension_registry_is_unreadab entry["registered_commands"] = "kiro-cli" elif corruption == "agent_entry": entry["registered_commands"] = {"kiro-cli": "speckit-plan"} + elif corruption == "missing": + del entry["registered_commands"] else: entry["registered_commands"] = {"kiro-cli": [1, 2]} - registry.write_text(json.dumps(data), encoding="utf-8") + registry.write_bytes(json.dumps(data).encode()) manifest = project / ".specify" / "integrations" / "kiro-cli.manifest.json" before = {path.name: path.read_bytes() for path in prompts.iterdir()} manifest_before = manifest.read_bytes() @@ -1181,6 +1841,37 @@ def test_upgrade_refuses_kiro_prompt_rename_while_extension_registry_is_unreadab } == before assert manifest.read_bytes() == manifest_before + @pytest.mark.parametrize("linked", [False, True]) + @pytest.mark.parametrize("force", [False, True]) + def test_kiro_upgrade_checks_untracked_core_destinations(self, tmp_path, monkeypatch, linked, force): + """An empty core manifest cannot bypass destination protection (#4797).""" + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + manifest_path = project / ".specify/integrations/kiro-cli.manifest.json" + manifest = json.loads(manifest_path.read_bytes()) + manifest["files"] = {} + manifest_path.write_bytes(json.dumps(manifest).encode()) + destination = project / ".kiro/prompts/speckit-plan.md" + body = b"USER PLAN\n" + target = project / "user-plan.md" + if linked: + target.write_bytes(body) + try: + destination.symlink_to(target) + except OSError: + pytest.skip("Symlinks are unavailable") + else: + destination.write_bytes(body) + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli"] + (["--force"] if force else [])) + if force and not linked: + assert result.exit_code == 0, result.output + assert destination.read_bytes() != body + else: + assert result.exit_code == 1, result.output + assert ".kiro/prompts/speckit-plan.md" in result.output + assert destination.read_bytes() == body + if linked: + assert destination.is_symlink() and target.read_bytes() == body + @pytest.mark.parametrize("kind", ["file", "symlink"]) def test_upgrade_replaces_a_kiro_prompt_it_did_not_install_only_with_force( self, tmp_path, monkeypatch, kind diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 78301a322e..b21937ecc8 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3434,6 +3434,43 @@ def test_remove_extension(self, extension_dir, project_dir): assert not manager.registry.is_installed("test-ext") assert not ext_dir.exists() + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("damaged", [None, [], "speckit.test-ext.hello"]) + def test_scoped_cleanup_preserves_malformed_aggregate_tracking(self, extension_dir, project_dir, agent, damaged): + from specify_cli.agents import CommandRegistrar as AgentRegistrar + + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + manager.register_enabled_extensions_for_agent(agent) + registrar = AgentRegistrar(project_dir) + config = registrar.AGENT_CONFIGS[agent] + stem = registrar._compute_output_name(agent, "speckit.test-ext.hello", config) + path = project_dir / config["dir"] / f"{stem}{config['extension']}" + assert path.is_file() + manager.registry.update("test-ext", {"registered_commands": damaged}) + manager.unregister_agent_artifacts(agent) + assert not os.path.lexists(path) + assert manager.registry.get("test-ext")["registered_commands"] == damaged + + @pytest.mark.parametrize("operation", ["remove", "unregister"]) + def test_malformed_command_names_never_delete_individual_characters(self, extension_dir, project_dir, operation): + from specify_cli.agents import CommandRegistrar as AgentRegistrar + + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + registrar = AgentRegistrar(project_dir) + config = registrar.AGENT_CONFIGS["gemini"] + stem = registrar._compute_output_name("gemini", "s", config) + path = project_dir / config["dir"] / f"{stem}{config['extension']}" + path.parent.mkdir(parents=True) + path.write_bytes(b"USER FILE\n") + manager.registry.update("test-ext", {"registered_commands": {"gemini": "speckit.test-ext.hello"}}) + if operation == "remove": + assert manager.remove("test-ext") + else: + manager.unregister_agent_artifacts("gemini") + assert path.read_bytes() == b"USER FILE\n" + def test_remove_nonexistent(self, project_dir): """Test removing non-existent extension.""" manager = ExtensionManager(project_dir) From 8014038c4668629e30b58ba0ccc0340a22c5e1ea Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Thu, 8 Oct 2026 19:28:37 +0000 Subject: [PATCH 22/30] fix(integrations): detect a pending Kiro rename from the prompts on disk An integration manifest with an empty files map doesn't show the dotted to hyphenated rename, so integration upgrade skipped the preset refusal while a preset's dotted override was still in .kiro/prompts. Kiro CLI upgrade now also treats the rename as pending while a dotted core prompt is on disk, so the preset refusal and the other rename checks run as with a complete manifest. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- .../integrations/_command_upgrade_layout.py | 16 ++++++++++++++++ src/specify_cli/integrations/command_upgrade.py | 9 +++++++++ .../integrations/test_command_upgrade.py | 12 ++++++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py index 48caa449eb..4c214fb26f 100644 --- a/src/specify_cli/integrations/_command_upgrade_layout.py +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -96,6 +96,22 @@ def _command_file_names_changed(integration, old_files, new_files) -> bool: return bool(removed & added) +def _command_files_on_disk(project_root, integration) -> set[str]: + """Return manifest keys for the entries directly in the command dir.""" + commands_dir = (integration.registrar_config or {}).get("dir") + if not isinstance(commands_dir, str) or not commands_dir.strip(): + return set() + try: + with os.scandir(Path(project_root) / commands_dir) as entries: + return { + (PurePath(commands_dir) / entry.name).as_posix() + for entry in entries + if not entry.is_dir(follow_symlinks=False) + } + except (FileNotFoundError, NotADirectoryError): + return set() + + class _ExtensionRegistryUnreadableError(Exception): """Raised when the extension registry can't show who owns a command file. diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 7320d4c7e5..5f51a42bad 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -17,6 +17,7 @@ _PresetRegistryUnreadableError, _check_extension_command_claims, _command_file_names_changed, + _command_files_on_disk, _installed_command_presets_affecting_agent, _installed_presets_affecting_agent, _legacy_command_root_changed, @@ -202,6 +203,14 @@ def integration_upgrade( renamed = _command_file_names_changed( integration, old_manifest.files, planned_command_files ) + if not renamed and key == "kiro-cli": + # The manifest may not show the rename (e.g. empty ``files``); the + # dotted core prompts still on disk do. + renamed = _command_file_names_changed( + integration, + _command_files_on_disk(project_root, integration) - planned_command_files, + planned_command_files, + ) untracked = [rel for rel in planned_command_files if rel not in old_manifest.files] if renamed: try: diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 525a14b02c..811faca1db 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -397,18 +397,26 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): assert (prompts / "speckit-plan.md").is_file() assert (prompts / "speckit-git-commit.md").is_file() + @pytest.mark.parametrize("empty_manifest", [False, True]) def test_upgrade_refuses_kiro_prompt_rename_while_presets_are_installed( - self, tmp_path, monkeypatch + self, tmp_path, monkeypatch, empty_manifest ): """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.""" + as for the Kilo command-root and command/skills layout migrations. + An integration manifest with empty ``files`` doesn't show the rename, + but the dotted prompts still on disk do.""" preset_src = _write_command_preset(tmp_path, "cmd-preset") project = _init_dotted_kiro_project( tmp_path, monkeypatch, ["preset", "add", "--dev", str(preset_src)] ) + if empty_manifest: + manifest_path = project / ".specify/integrations/kiro-cli.manifest.json" + manifest = json.loads(manifest_path.read_bytes()) + manifest["files"] = {} + manifest_path.write_bytes(json.dumps(manifest).encode()) 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"] From f77f6ad38bc96c0e89d8bba2ef8700d35c0d12e7 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Thu, 8 Oct 2026 19:43:30 +0000 Subject: [PATCH 23/30] fix(integrations): ignore dotted Kiro prompts left after the rename An upgrade from an integration manifest with an empty files map can't remove the untracked dotted core prompts, so they stay in .kiro/prompts. The on-disk check then kept treating the rename as pending, and a preset installed afterwards made every later upgrade refuse. A dotted prompt now counts only while the manifest doesn't track its hyphenated replacement. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- .../integrations/command_upgrade.py | 8 +++--- .../integrations/test_command_upgrade.py | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 5f51a42bad..70c7ecdf41 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -203,15 +203,15 @@ def integration_upgrade( renamed = _command_file_names_changed( integration, old_manifest.files, planned_command_files ) + untracked = [rel for rel in planned_command_files if rel not in old_manifest.files] if not renamed and key == "kiro-cli": - # The manifest may not show the rename (e.g. empty ``files``); the - # dotted core prompts still on disk do. + # The manifest may not show the rename (e.g. empty ``files``); a dotted + # core prompt on disk does, until the manifest tracks its replacement. renamed = _command_file_names_changed( integration, _command_files_on_disk(project_root, integration) - planned_command_files, - planned_command_files, + untracked, ) - untracked = [rel for rel in planned_command_files if rel not in old_manifest.files] if renamed: try: affected_presets = _installed_command_presets_affecting_agent( diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 811faca1db..1feefe7222 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -433,6 +433,33 @@ def test_upgrade_refuses_kiro_prompt_rename_while_presets_are_installed( assert "cmd-preset" in result.output assert {path.name: path.read_bytes() for path in prompts.iterdir()} == before + def test_kiro_upgrade_after_migration_ignores_leftover_dotted_prompts( + self, tmp_path, monkeypatch + ): + """An upgrade from an empty integration manifest can't retire the + untracked dotted core prompts. Once the manifest tracks their + replacements, those leftovers no longer make the rename pending, so a + preset installed afterwards doesn't block later upgrades (#4797).""" + project = _init_dotted_kiro_project(tmp_path, monkeypatch) + manifest_path = project / ".specify/integrations/kiro-cli.manifest.json" + manifest = json.loads(manifest_path.read_bytes()) + manifest["files"] = {} + manifest_path.write_bytes(json.dumps(manifest).encode()) + 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() + + preset_src = _write_command_preset(tmp_path, "cmd-preset") + result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) + assert result.exit_code == 0, result.output + # --force: the preset's override changed the tracked speckit-plan.md. + result = _run_in_project(project, ["integration", "upgrade", "kiro-cli", "--force"]) + assert result.exit_code == 0, result.output + assert "Overridden plan content" in (prompts / "speckit-plan.md").read_text( + encoding="utf-8" + ) + def test_upgrade_keeps_dotted_kiro_prompts_when_reregistration_fails( self, tmp_path, monkeypatch ): From 64ebbc8089312119c7eda910ea908f1d8fb43cbd Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 9 Oct 2026 15:50:03 +0000 Subject: [PATCH 24/30] fix(integrations): journal Kiro CLI prompt rewrites like Cline and Junie #4862 routes Cline's and Junie's setup() post-processing writes through _file_changes.write_bytes so an integration lifecycle transaction can restore them. Kiro CLI's setup() rewrite, added on this branch, still wrote the prompt directly; it now uses the same helper. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/integrations/kiro_cli/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 0f0eb510d9..a6482ae196 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -167,7 +167,9 @@ def setup( content = path.read_bytes().decode("utf-8") updated = self.post_process_command_content(content) if updated != content: - path.write_bytes(updated.encode("utf-8")) + from .._file_changes import write_bytes + + write_bytes(path, updated.encode("utf-8")) self.record_file_in_manifest(path, project_root, manifest) return created From 5b7a6747ba22ae875622dcaf217d556945ef09c6 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 9 Oct 2026 15:50:18 +0000 Subject: [PATCH 25/30] fix(extensions): print the unowned prompt path with forward slashes The refusal named the file with the platform's separator, so on Windows it read .kiro\prompts\speckit-audit-run.md while the rest of the messages, and test_migration_preserves_unowned_destination, use the project-relative POSIX form. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6e52bfa583..b1436817a3 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1369,7 +1369,7 @@ def _validate_command_destinations(self, manifest: ExtensionManifest, agent_name except (OSError, ValueError): pass raise ValidationError( - f"Cannot register extension '{manifest.id}': {path.relative_to(self.project_root)} is not marked as owned " + f"Cannot register extension '{manifest.id}': {path.relative_to(self.project_root).as_posix()} is not marked as owned " "by this extension. Move or remove it and retry; none of this extension's " "files were written or retired." ) From 5ba189e0e2effdd7f1b61ab7f65233a53444ab4e Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 9 Oct 2026 16:36:41 +0000 Subject: [PATCH 26/30] test(integrations): build the dotted Kiro fixture from the integration's config _init_dotted_kiro_project simulated a pre-#4797 install by deleting format_name from the cached CommandRegistrar.AGENT_CONFIGS entry. Since #4862, loading a project's installed integrations rebuilds that cache from each integration's registrar_config, so the fixture wrote hyphenated prompts, and the migration tests built on it failed or no longer reached the migration. It now removes format_name from KiroCliIntegration.registrar_config, resets through unload_installed_integrations() as the installed-adapter tests do, and checks that init wrote the dotted speckit.plan.md. Refs #4797 Assisted-by: Codex CLI (model: gpt-6-astra, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- .../integrations/test_command_upgrade.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 1feefe7222..69edd1dcbf 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -80,21 +80,26 @@ def _write_command_extension(tmp_path, aliases=()): 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.installer import unload_installed_integrations 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", raising=False) - project = _init_project(tmp_path, "kiro-cli") - for args in commands: - result = _run_in_project(project, args) - assert result.exit_code == 0, result.output + try: + with monkeypatch.context() as m: + m.setattr( + KiroCliIntegration, "command_filename", + MarkdownIntegration.command_filename, + ) + m.delitem(KiroCliIntegration.registrar_config, "format_name") + unload_installed_integrations() + project = _init_project(tmp_path, "kiro-cli") + assert (project / ".kiro/prompts/speckit.plan.md").is_file() + assert not (project / ".kiro/prompts/speckit-plan.md").exists() + for args in commands: + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + finally: + unload_installed_integrations() return project From a844c738ac41ecdcfb262dffca0ce9e5e80f2ee8 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 9 Oct 2026 16:38:21 +0000 Subject: [PATCH 27/30] fix(extensions): retire a dropped Kiro CLI or Qoder alias that shares its prompt An alias that formats to its command's file (audit-run and speckit.audit.run both become speckit-audit-run) stayed tracked after the extension dropped it, and its old flat file (.kiro/prompts/ audit-run.md, .qoder/commands/audit-run.md) stayed invokable until the extension was removed. Once registration has written the shared file, the alias's old file is now retired with the same ownership check, the shared file is kept, and the alias stops being tracked unless its old file had to be kept. Refs #4797 Assisted-by: Codex CLI (model: gpt-6-astra, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 57 +++++++------ .../integrations/test_command_upgrade.py | 84 ++++++++++++++++--- 2 files changed, 105 insertions(+), 36 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index b1436817a3..c52b4956cd 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1176,9 +1176,9 @@ def _get_installed_command_name_map( """Return registered command and alias names for installed extensions. Besides its manifest's names, an extension contributes the names the - registry tracks for it. Their command files stay on disk while its - manifest can't be read, or no longer declares them, until it is - registered again or removed, and removal deletes them (#4797). + registry tracks for it. Their files may outlive a changed or unreadable + manifest. Kiro CLI/Qoder cleanup preserves files whose ownership is + unproven (#4797). """ installed_names: Dict[str, str] = {} @@ -3996,6 +3996,7 @@ def unregister_agent_artifacts( def _unregister_extension_commands( self, extension_id: str, agent_name: str, command_names: Any, *, retire_legacy: bool = True, + preserved_output_names: Optional[Set[str]] = None, ) -> Optional[List[str]]: """Retire owned commands and keep names whose files remain (#4797, #2948).""" from ..integrations import get_integration @@ -4016,7 +4017,8 @@ def _unregister_extension_commands( valid = self._valid_name_list(command_names) command_names = valid + [name for name in self._collect_manifest_command_names(manifest) if name not in valid] command_names = self._valid_name_list(command_names) - preserved = self._preserved_command_files(extension_id, agent_name, command_names) + preserved_output_names = {os.path.normcase(name) for name in (preserved_output_names or set())} + preserved = self._preserved_command_files(extension_id, agent_name, command_names) | preserved_output_names registrar = CommandRegistrar(self.project_root) registrar.unregister_commands( {agent_name: command_names}, self.project_root, @@ -4039,6 +4041,10 @@ def _unregister_extension_commands( self.project_root / config["dir"] / f"{stem}{config['extension']}", self.project_root / legacy_dir / f"{name}{integration.legacy_flat_command_extension}", ) + # A surviving command owns the shared output; only a separate + # legacy file can keep this dropped alias tracked (#4797). + if os.path.normcase(stem) in preserved_output_names: + paths = tuple(path for path in paths if not registrar._registrar._same_lexical_path(path, paths[0])) if any(os.path.lexists(path) for path in paths): remaining.append(name) return remaining @@ -4425,7 +4431,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool agent_skills_dir = _resolve_agent_skills_dir(self.project_root, agent_name) - # Registration below changes no claim this reads (#4797). + # Snapshot claims before registration and stale-name retirement (#4797). try: shared_files = ( self._shared_command_files(agent_name) @@ -4497,7 +4503,8 @@ def output_stem(name): registered_commands = metadata.get("registered_commands") new_registered = self._recover_registered_commands(ext_id, registered_commands) previous = self._valid_name_list(new_registered.get(agent_name)) - declared_stems = {output_stem(name) for name in self._collect_manifest_command_names(manifest)} if keep_unwritten else set() + declared_names = self._collect_manifest_command_names(manifest) if keep_unwritten else {} + declared_stems = {output_stem(name) for name in declared_names} remaining_commands = [] if keep_unwritten: remaining_commands = self._unregister_extension_commands( @@ -4514,27 +4521,29 @@ def output_stem(name): registered, skills=False, ) - # register_commands skips a missing source and returns - # only the names it wrote; it does not raise. Replacing - # the agent's list with that return value (or dropping - # the entry when nothing was written) untracks a prompt - # that is still on disk, and extension removal then - # cannot delete it. Keep a previously registered name - # when this pass did not write it and the manifest - # still declares its output. This also keeps a dropped - # alias's legacy file tracked for removal (#4797). - # Unknown tracking is recovered from the manifest. An - # output the manifest no longer declares is retired - # above using provenance checks; survivors stay tracked. - # Retirement below still runs only for names written this pass, - # and only once that pass's replacement file exists - # (#4797, #2948). Other agents drop an unwritten name - # as before: an older pair of extensions can share its - # file, and their removal doesn't check the owner. + stale_aliases = [] + if keep_unwritten: + replaced_stems = {output_stem(name) for name in registered} + stale_aliases = [ + name for name in previous + if name not in declared_names and output_stem(name) in replaced_stems + ] + if stale_aliases: + remaining_commands += self._unregister_extension_commands( + ext_id, agent_name, stale_aliases, + preserved_output_names=declared_stems, + ) or [] + # Missing sources are skipped, so keep unwritten names + # whose outputs are still declared. Retire a dropped + # alias only after its shared replacement was written, + # preserving that output and tracking any unproven raw + # file left behind (#4797, #2948). Other agents still + # drop unwritten names: their removal doesn't check + # who owns a shared file. kept = [ name for name in previous if name in registered or name in remaining_commands - or (keep_unwritten and output_stem(name) in declared_stems) + or (keep_unwritten and output_stem(name) in declared_stems and name not in stale_aliases) ] merged = kept + [ name for name in registered if name not in kept diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 69edd1dcbf..048e9bf367 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -1152,25 +1152,85 @@ def test_dropped_alias_keeps_declared_command_output(self, tmp_path, agent, oper assert path.is_file() assert path.read_bytes() == before if missing_source else b"AUDIT-BODY" in path.read_bytes() - @pytest.mark.parametrize("missing_source", [False, True]) - def test_dropped_legacy_alias_stays_tracked_for_removal(self, tmp_path, monkeypatch, missing_source): + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + @pytest.mark.parametrize("operation,kept", [ + ("use", None), ("upgrade", None), ("switch", None), ("reinstall", None), ("update", None), + ("use", "unmarked"), ("use", "other-owner"), ("use", "missing-source"), + ]) + def test_dropped_legacy_alias_is_retired_on_registration(self, tmp_path, monkeypatch, agent, operation, kept): import yaml - from specify_cli.extensions import ExtensionManager + from contextlib import chdir + from specify_cli.extensions import ExtensionCatalog, ExtensionManager source = _write_command_extension(tmp_path, aliases=["audit-run"]) - project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) - alias = project / ".kiro/prompts/audit-run.md" - assert alias.is_file() - path = project / ".specify/extensions/audit/extension.yml" + if agent == "kiro-cli": + project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) + alias = project / ".kiro/prompts/audit-run.md" + output = project / ".kiro/prompts/speckit-audit-run.md" + else: + project = _init_project(tmp_path, agent) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + output = project / ".qoder/skills/speckit-audit-run/SKILL.md" + alias = project / ".qoder/commands/audit-run.md" + alias.parent.mkdir(parents=True) + alias.write_bytes(b"---\ndescription: Audit\n---\n\n\nAUDIT-BODY\n") + (alias.parent / "speckit.audit.run.md").write_bytes(alias.read_bytes()) + output.unlink() + original_alias = alias.read_bytes() + assert b"" in original_alias + assert not output.exists() + if operation == "switch": + assert _run_in_project(project, ["integration", "install", "claude"]).exit_code == 0 + assert _run_in_project(project, ["integration", "use", "claude"]).exit_code == 0 + extension_dir = source if operation in ("reinstall", "update") else project / ".specify/extensions/audit" + path = extension_dir / "extension.yml" manifest = yaml.safe_load(path.read_bytes()) manifest["provides"]["commands"][0]["aliases"] = [] + manifest["extension"]["version"] = "2.0.0" path.write_bytes(yaml.safe_dump(manifest).encode()) - if missing_source: - (path.parent / "commands/run.md").unlink() - assert _run_in_project(project, ["integration", "use", "kiro-cli"]).exit_code == 0 - assert "audit-run" in ExtensionManager(project).registry.get("audit")["registered_commands"]["kiro-cli"] - assert _run_in_project(project, ["extension", "remove", "audit", "--force"]).exit_code == 0 + command_source = extension_dir / "commands/run.md" + updated = command_source.read_bytes().replace(b"AUDIT-BODY", b"UPDATED-PRIMARY") + command_source.write_bytes(updated) + if kept == "missing-source": + command_source.unlink() + elif kept == "unmarked": + alias.unlink() + alias.write_bytes(b"USER ALIAS\n") + elif kept == "other-owner": + alias.unlink() + alias.write_bytes(b"\n" + original_alias) + before = alias.read_bytes() + if operation == "update": + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda *args: { + "id": "audit", "name": "Audit", "version": "2.0.0", "bundled": True, "_install_allowed": True, + }) + monkeypatch.setattr("specify_cli._locate_bundled_extension", lambda key: source) + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda *args: pytest.fail("Unexpected download")) + with chdir(project): + result = runner.invoke(app, ["extension", "update", "audit"], input="y\n", catch_exceptions=False) + else: + args = (["extension", "add", "--dev", str(source), "--force"] if operation == "reinstall" else + ["integration", operation, agent]) + result = _run_in_project(project, args) + assert result.exit_code == 0, result.output + if kept: + assert alias.read_bytes() == before + tracked = ExtensionManager(project).registry.get("audit")["registered_commands"][agent] + assert {"audit-run", "speckit.audit.run"} <= set(tracked) + if kept != "missing-source": + assert b"UPDATED-PRIMARY" in output.read_bytes() + # Retry once ownership and the replacement source are available. + alias.write_bytes(original_alias) + command_source.write_bytes(updated) + result = _run_in_project(project, ["integration", "use", agent]) + assert result.exit_code == 0, result.output assert not os.path.lexists(alias) + assert b"UPDATED-PRIMARY" in output.read_bytes() + tracked = ExtensionManager(project).registry.get("audit")["registered_commands"][agent] + assert "audit-run" not in tracked + assert "speckit.audit.run" in tracked + assert _run_in_project(project, ["extension", "remove", "audit", "--force"]).exit_code == 0 + assert not os.path.lexists(output) @pytest.mark.parametrize("operation", ["use", "upgrade", "reinstall", "update", "remove"]) def test_template_metadata_does_not_hide_generated_owner(self, tmp_path, monkeypatch, operation): From e8bf882d79829b5cac0e5a827b61524ff33b33ee Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 9 Oct 2026 16:40:11 +0000 Subject: [PATCH 28/30] fix(extensions): keep Kiro CLI and Qoder cleanup in the project's context After #4862, this branch's cleanup differed from main's in three ways: - Retirement deleted an old flat file outside the lifecycle journal, so a failed integration switch restored the extension registry but not the dotted prompt it had deleted. - Registrars built for one agent also read generic's settings, so damaged generic options made extension remove fail for a Kiro CLI or Qoder extension. - Helpers looked up the agent's integration before the project's installed integrations were loaded. Retirement now deletes through _file_changes.unlink, those registrars include generic only for the generic agent, and the helpers run under @project_registration. Refs #4797 Assisted-by: Codex CLI (model: gpt-6-astra, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 19 ++- .../integrations/test_command_upgrade.py | 125 ++++++++++++++++++ 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index c52b4956cd..c3df0fd457 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1337,6 +1337,7 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: if agent: self._validate_command_destinations(manifest, agent) + @project_registration def _validate_command_destinations(self, manifest: ExtensionManifest, agent_name: str) -> None: """Protect occupied destinations while a flat-command rename is pending (#4797).""" from ..agents import CommandRegistrar @@ -1346,7 +1347,7 @@ def _validate_command_destinations(self, manifest: ExtensionManifest, agent_name legacy_dir = getattr(integration, "legacy_flat_command_dir", None) if not legacy_dir: return - registrar = CommandRegistrar(self.project_root) + registrar = CommandRegistrar(self.project_root, include_generic=agent_name == "generic") config = registrar.AGENT_CONFIGS[agent_name] names = self._collect_manifest_command_names(manifest) recorded = (self.registry.get(manifest.id) or {}).get("registered_commands", {}) @@ -3839,6 +3840,7 @@ def _valid_name_list(value: Any) -> List[str]: return [] return [item for item in value if isinstance(item, str)] + @project_registration def _recover_registered_commands(self, extension_id: str, recorded: Any) -> Dict[str, Any]: """Recover unknown legacy command tracking without losing cleanup names (#4797).""" from ..integrations import INTEGRATION_REGISTRY @@ -3993,6 +3995,7 @@ def unregister_agent_artifacts( if updates: self.registry.update(ext_id, updates) + @project_registration def _unregister_extension_commands( self, extension_id: str, agent_name: str, command_names: Any, *, retire_legacy: bool = True, @@ -4019,7 +4022,7 @@ def _unregister_extension_commands( command_names = self._valid_name_list(command_names) preserved_output_names = {os.path.normcase(name) for name in (preserved_output_names or set())} preserved = self._preserved_command_files(extension_id, agent_name, command_names) | preserved_output_names - registrar = CommandRegistrar(self.project_root) + registrar = CommandRegistrar(self.project_root, include_generic=agent_name == "generic") registrar.unregister_commands( {agent_name: command_names}, self.project_root, preserved_output_names={agent_name: preserved}, @@ -4077,6 +4080,7 @@ def _retire_legacy_flat_extension_commands( from .. import _print_cli_warning from ..agents import CommandRegistrar from ..integrations import get_integration + from ..integrations._file_changes import unlink integration = get_integration(agent_name) legacy_dir = getattr(integration, "legacy_flat_command_dir", None) @@ -4091,7 +4095,7 @@ def _retire_legacy_flat_extension_commands( ): return [] - registrar = CommandRegistrar(self.project_root) + registrar = CommandRegistrar(self.project_root, include_generic=agent_name == "generic") agent_config = registrar.AGENT_CONFIGS.get(agent_name) if not agent_config: return [] @@ -4160,7 +4164,7 @@ def safe_project_dir(relative: str) -> Optional[Path]: continuing="Preserving the legacy file.", ) continue - legacy_file.unlink() + unlink(legacy_file) removed.append(legacy_file) return removed @@ -4196,7 +4200,7 @@ def _preserved_command_files( if not core: return preserved - registrar = CommandRegistrar(self.project_root) + registrar = CommandRegistrar(self.project_root, include_generic=agent_name == "generic") config = registrar.AGENT_CONFIGS[agent_name] for stem in core - preserved: path = self.project_root / config["dir"] / f"{stem}{config['extension']}" @@ -4232,6 +4236,7 @@ def _preserved_command_files( ) return preserved + @project_registration def _core_command_files( self, agent_name: str, *, installed_only: bool = False ) -> Set[str]: @@ -4264,7 +4269,7 @@ def _core_command_files( ): return set() - registrar = CommandRegistrar(self.project_root) + registrar = CommandRegistrar(self.project_root, include_generic=agent_name == "generic") agent_config = registrar.AGENT_CONFIGS.get(agent_name) if not agent_config: return set() @@ -4323,7 +4328,7 @@ def _shared_command_files( from ..agents import CommandRegistrar - registrar = CommandRegistrar(self.project_root) + registrar = CommandRegistrar(self.project_root, include_generic=agent_name == "generic") agent_config = registrar.AGENT_CONFIGS[agent_name] owners: Dict[str, Set[tuple[str, str]]] = { os.path.normcase(stem): {("", stem)} for stem in core_files diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 048e9bf367..b63d3fef79 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -103,6 +103,65 @@ def _init_dotted_kiro_project(tmp_path, monkeypatch, *commands): return project +@pytest.fixture +def offline_adapter(tmp_path, monkeypatch): + import io + import zipfile + import yaml + from specify_cli.authentication import http + from specify_cli.integrations import IntegrationCatalog, installer + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + installer.unload_installed_integrations() + + def install(project, *, operation="install", extra_code=""): + descriptor = { + "schema_version": "1.0", + "integration": { + "id": "sample-agent", "name": "Sample Agent", "version": "1.0.0", + "description": "Offline test adapter", + }, + "requires": {"speckit_version": ">=0.6.0"}, + } + code = '''from specify_cli.integrations.base import MarkdownIntegration +class SampleIntegration(MarkdownIntegration): + key = "sample-agent" + config = {"name": "Sample Agent", "folder": ".sample-agent", "commands_subdir": "commands", "requires_cli": False, "install_url": "https://example.com"} + registrar_config = {"dir": ".sample-agent/commands", "format": "markdown", "args": "$ARGUMENTS", "extension": ".md"} + multi_install_safe = True +''' + extra_code + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as package: + package.writestr("integration.yml", yaml.safe_dump(descriptor)) + package.writestr("__init__.py", code) + data = archive.getvalue() + url = "https://example.com/sample-agent.zip" + info = { + **descriptor["integration"], "download_url": url, + "sha256": hashlib.sha256(data).hexdigest(), + "_catalog_name": "offline", "_install_allowed": True, + } + + class Response(io.BytesIO): + headers = {"Content-Type": "application/zip"} + + def geturl(self): + return url + + with monkeypatch.context() as patch: + patch.setattr(IntegrationCatalog, "get_integration_info", lambda *args, **kwargs: info) + patch.setattr(http, "open_url", lambda *args, **kwargs: Response(data)) + return _run_in_project(project, [ + "integration", operation, "sample-agent", "--trust-integration", "--script", "sh", + ]) + + yield install + installer.unload_installed_integrations() + + class TestIntegrationUpgradeDetailed: def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") @@ -402,6 +461,72 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch): assert (prompts / "speckit-plan.md").is_file() assert (prompts / "speckit-git-commit.md").is_file() + def test_failed_external_switch_restores_legacy_prompt(self, tmp_path, monkeypatch, offline_adapter): + from specify_cli.integrations import installer + + source = _write_command_extension(tmp_path) + project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) + legacy = project / ".kiro/prompts/speckit.audit.run.md" + before = legacy.read_bytes() + registry = project / ".specify/extensions/.registry" + before_registry = registry.read_bytes() + + def fail_commit(*args, **kwargs): + assert not os.path.lexists(legacy) + raise OSError("Injected external package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = offline_adapter(project, operation="switch") + assert result.exit_code == 1, result.output + assert "Injected external package commit failure" in result.output + assert registry.read_bytes() == before_registry + assert legacy.read_bytes() == before + + @pytest.mark.parametrize("agent", ["kiro-cli", "qodercli"]) + def test_remove_after_generic_settings_damage(self, tmp_path, agent): + project = _init_project(tmp_path, agent) + source = _write_command_extension(tmp_path) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + command = project / (".kiro/prompts/speckit-audit-run.md" if agent == "kiro-cli" else + ".qoder/skills/speckit-audit-run/SKILL.md") + assert command.is_file() + result = _run_in_project(project, [ + "integration", "install", "generic", "--integration-options=--commands-dir .custom/commands", "--force", + ]) + assert result.exit_code == 0, result.output + assert _run_in_project(project, ["integration", "use", "generic"]).exit_code == 0 + state = project / ".specify/integration.json" + data = json.loads(state.read_bytes()) + data["integration_settings"]["generic"].update(parsed_options={}, raw_options="") + state.write_bytes(json.dumps(data).encode()) + + result = _run_in_project(project, ["extension", "remove", "audit", "--force"]) + assert result.exit_code == 0, result.output + assert not os.path.lexists(command) + assert not (project / ".specify/extensions/audit").exists() + + def test_external_adapter_removal_preserves_unowned_command(self, tmp_path, offline_adapter): + from specify_cli.integrations.installer import unload_installed_integrations + + project = _init_project(tmp_path, "kiro-cli") + result = offline_adapter(project, extra_code=( + ' legacy_flat_command_dir = ".sample-agent/legacy"\n' + ' legacy_flat_command_extension = ".md"\n' + )) + assert result.exit_code == 0, result.output + assert _run_in_project(project, ["integration", "use", "sample-agent"]).exit_code == 0 + source = _write_command_extension(tmp_path) + assert _run_in_project(project, ["extension", "add", "--dev", str(source)]).exit_code == 0 + command = project / ".sample-agent/commands/speckit.audit.run.md" + assert command.is_file() + command.unlink() + command.write_bytes(b"USER OWNED FILE\n") + unload_installed_integrations() + + result = _run_in_project(project, ["extension", "remove", "audit", "--force"]) + assert result.exit_code == 0, result.output + assert command.read_bytes() == b"USER OWNED FILE\n" + @pytest.mark.parametrize("empty_manifest", [False, True]) def test_upgrade_refuses_kiro_prompt_rename_while_presets_are_installed( self, tmp_path, monkeypatch, empty_manifest From e9b6a9bb4a55635c3390cb7a4f5a02529712a1eb Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 9 Oct 2026 16:55:58 +0000 Subject: [PATCH 29/30] fix(extensions): load installed integrations before checking every agent Without init-options.json, extension install checks the destinations of every integration. It iterated INTEGRATION_REGISTRY itself, and the check loads the project's installed integrations into it (#4862), so extension add failed with 'dictionary changed size during iteration' once a project had an installed integration. The project's integrations are now loaded first, and the check walks a copy of the keys. Refs #4797 Assisted-by: Codex CLI (model: gpt-6-astra, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 10 ++++++---- .../integrations/test_command_upgrade.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index c3df0fd457..3f1f32a9ea 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1330,12 +1330,14 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: from .._init_options import MISSING_INIT_OPTIONS_FILE, resolve_active_agent_for_registration from ..integrations import INTEGRATION_REGISTRY + from ..integrations.installer import project_integrations active_agent = resolve_active_agent_for_registration(self.project_root) - agents = INTEGRATION_REGISTRY if active_agent is MISSING_INIT_OPTIONS_FILE else [active_agent] - for agent in agents: - if agent: - self._validate_command_destinations(manifest, agent) + with project_integrations(self.project_root): + agents = list(INTEGRATION_REGISTRY) if active_agent is MISSING_INIT_OPTIONS_FILE else [active_agent] + for agent in agents: + if agent: + self._validate_command_destinations(manifest, agent) @project_registration def _validate_command_destinations(self, manifest: ExtensionManifest, agent_name: str) -> None: diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index b63d3fef79..80ec52ed70 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -527,6 +527,19 @@ def test_external_adapter_removal_preserves_unowned_command(self, tmp_path, offl assert result.exit_code == 0, result.output assert command.read_bytes() == b"USER OWNED FILE\n" + def test_extension_add_without_init_options_loads_installed_adapters(self, tmp_path, offline_adapter): + from specify_cli.integrations.installer import unload_installed_integrations + + project = _init_project(tmp_path, "claude") + (project / ".specify/init-options.json").unlink() + result = offline_adapter(project) + assert result.exit_code == 0, result.output + unload_installed_integrations() + + source = _write_command_extension(tmp_path) + result = _run_in_project(project, ["extension", "add", "--dev", str(source)]) + assert result.exit_code == 0, result.output + @pytest.mark.parametrize("empty_manifest", [False, True]) def test_upgrade_refuses_kiro_prompt_rename_while_presets_are_installed( self, tmp_path, monkeypatch, empty_manifest From 7c446b398eccd94e7c87f543add9439beb0b313b Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 9 Oct 2026 17:25:09 +0000 Subject: [PATCH 30/30] fix(extensions): keep a prompt that is a symlink loop Before Python 3.13, Path.resolve() raises RuntimeError on a symlink loop. The ownership check let it escape, while its callers keep a file they can't verify only on OSError or ValueError, so extension remove failed on a looping Kiro CLI or Qoder file instead of keeping it with a warning. The check now raises OSError for a loop. Refs #4797 Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/__init__.py | 6 +++++- .../integrations/test_command_upgrade.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3f1f32a9ea..8648bdca6c 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4177,7 +4177,11 @@ def _command_file_owned_by_extension(self, path: Path, extension_id: str, *, ski from ..shared_infra import _validate_safe_shared_directory _validate_safe_shared_directory(self.project_root, path.parent) - CommandRegistrar._ensure_inside(path.resolve(), self.project_root.resolve()) + try: + resolved = path.resolve() + except RuntimeError as exc: # symlink loop before Python 3.13 + raise OSError(f"Symlink loop at {path}") from exc + CommandRegistrar._ensure_inside(resolved, self.project_root.resolve()) content = path.read_text(encoding="utf-8") marker = next(( line for line in content.splitlines() diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 80ec52ed70..d0c05f1923 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -527,6 +527,20 @@ def test_external_adapter_removal_preserves_unowned_command(self, tmp_path, offl assert result.exit_code == 0, result.output assert command.read_bytes() == b"USER OWNED FILE\n" + def test_removal_keeps_a_looping_prompt_symlink(self, tmp_path, monkeypatch): + source = _write_command_extension(tmp_path) + project = _init_dotted_kiro_project(tmp_path, monkeypatch, ["extension", "add", "--dev", str(source)]) + legacy = project / ".kiro/prompts/speckit.audit.run.md" + legacy.unlink() + try: + legacy.symlink_to(legacy.name) + except OSError: + pytest.skip("Symlinks are unavailable") + + result = _run_in_project(project, ["extension", "remove", "audit", "--force"]) + assert result.exit_code == 0, result.output + assert legacy.is_symlink() + def test_extension_add_without_init_options_loads_installed_adapters(self, tmp_path, offline_adapter): from specify_cli.integrations.installer import unload_installed_integrations