From d96146dd106cdb6e5dd324684edaabdcc272ea91 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 11:19:44 +0800 Subject: [PATCH 1/2] fix(kilocode): write commands to .kilo/commands --- AGENTS.md | 11 +-- docs/reference/integrations.md | 6 +- docs/upgrade.md | 6 +- .../integrations/kilocode/__init__.py | 7 +- tests/integrations/test_integration_forge.py | 7 +- .../integrations/test_integration_kilocode.py | 72 ++++++++++++++++++- 6 files changed, 92 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 040eb07a63..061b8b12b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,13 +64,14 @@ class KilocodeIntegration(MarkdownIntegration): key = "kilocode" config = { "name": "Kilo Code", - "folder": ".kilocode/", - "commands_subdir": "workflows", + "folder": ".kilo/", + "commands_subdir": "commands", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".kilocode/workflows", + "dir": ".kilo/commands", + "legacy_dir": ".kilocode/workflows", "format": "markdown", "args": "$ARGUMENTS", "extension": ".md", @@ -201,8 +202,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat specify init my-project --integration # Verify files were created in the commands directory configured by -# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/) -ls -R my-project/.kilocode/workflows/ +# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/) +ls -R my-project/.kilo/commands/ # Uninstall cleanly cd my-project && specify integration uninstall diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 4db672cd58..1b8a86cad0 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -25,7 +25,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` | | [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. | | [Junie](https://junie.jetbrains.com/) | `junie` | | -| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | +| [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` | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | @@ -269,12 +269,14 @@ The currently declared multi-install safe integrations are: | `codebuddy` | `.codebuddy/commands` | | `codex` | `.agents/skills` | | `cursor-agent` | `.cursor/skills` | +| `droid` | `.factory/skills` | | `firebender` | `.firebender/commands` | | `gemini` | `.gemini/commands` | | `grok` | `.grok/skills` | | `junie` | `.junie/commands` | -| `kilocode` | `.kilocode/workflows` | +| `kilocode` | `.kilo/commands` | | `kiro-cli` | `.kiro/prompts` | +| `lingma` | `.lingma/skills` | | `omp` | `.omp/commands` | | `pi` | `.pi/prompts` | | `qodercli` | `.qoder/commands` | diff --git a/docs/upgrade.md b/docs/upgrade.md index 708664d265..5bacebd912 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -196,7 +196,7 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command ```bash # Navigate to the agent's commands folder -cd .kilocode/workflows/ +cd .kilo/commands/ # List files and identify duplicates ls -la @@ -248,8 +248,8 @@ specify extension update This happens with IDE-based agents (Kilo Code, Cline, etc.). ```bash -# Find the agent folder (example: .kilocode/workflows/) -cd .kilocode/workflows/ +# Find the agent folder (example: .kilo/commands/) +cd .kilo/commands/ # List all files ls -la diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py index 0924843286..00a832fe71 100644 --- a/src/specify_cli/integrations/kilocode/__init__.py +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -7,13 +7,14 @@ class KilocodeIntegration(MarkdownIntegration): key = "kilocode" config = { "name": "Kilo Code", - "folder": ".kilocode/", - "commands_subdir": "workflows", + "folder": ".kilo/", + "commands_subdir": "commands", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".kilocode/workflows", + "dir": ".kilo/commands", + "legacy_dir": ".kilocode/workflows", "format": "markdown", "args": "$ARGUMENTS", "extension": ".md", diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index dc42f135a8..0559e9be98 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -422,7 +422,12 @@ def test_registrar_does_not_affect_other_agents(self, tmp_path): # Kilo Code uses standard markdown format without name injection. # The format_name callback should not be invoked for non-Forge agents. - kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md" + kilocode_cmd = ( + tmp_path + / ".kilo" + / "commands" + / "speckit.my-extension.example.md" + ) assert kilocode_cmd.exists() content = kilocode_cmd.read_text(encoding="utf-8") diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py index 86e6520a50..ea3065bc30 100644 --- a/tests/integrations/test_integration_kilocode.py +++ b/tests/integrations/test_integration_kilocode.py @@ -1,10 +1,76 @@ """Tests for KilocodeIntegration.""" +from specify_cli.agents import CommandRegistrar +from specify_cli.integrations import get_integration + from .test_integration_base_markdown import MarkdownIntegrationTests class TestKilocodeIntegration(MarkdownIntegrationTests): KEY = "kilocode" - FOLDER = ".kilocode/" - COMMANDS_SUBDIR = "workflows" - REGISTRAR_DIR = ".kilocode/workflows" + FOLDER = ".kilo/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".kilo/commands" + + def test_registrar_config_has_legacy_dir(self): + integration = get_integration(self.KEY) + assert integration.registrar_config["legacy_dir"] == ".kilocode/workflows" + + def test_legacy_dir_extension_registration(self, tmp_path): + """Extension commands still register into legacy Kilo projects.""" + legacy_dir = tmp_path / ".kilocode" / "workflows" + legacy_dir.mkdir(parents=True) + (legacy_dir / "speckit.specify.md").write_text( + "# existing", encoding="utf-8" + ) + + src_dir = tmp_path / "_ext_src" + src_dir.mkdir() + (src_dir / "myext.md").write_text( + "---\ndescription: test\n---\n# ext command", + encoding="utf-8", + ) + + registrar = CommandRegistrar() + commands = [{"name": "speckit.myext", "file": "myext.md"}] + results = registrar.register_commands( + self.KEY, + commands, + "test-ext", + src_dir, + tmp_path, + ) + + assert results == ["speckit.myext"] + assert (legacy_dir / "speckit.myext.md").exists() + assert not (tmp_path / ".kilo" / "commands").exists() + + def test_legacy_dir_extension_unregister(self, tmp_path): + """Unregister removes commands from legacy Kilo projects.""" + legacy_dir = tmp_path / ".kilocode" / "workflows" + legacy_dir.mkdir(parents=True) + cmd_file = legacy_dir / "speckit.myext.md" + cmd_file.write_text("# ext command", encoding="utf-8") + + registrar = CommandRegistrar() + registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path) + + assert not cmd_file.exists() + + def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path): + """Unregister removes stale legacy files after Kilo path migration.""" + canonical_dir = tmp_path / ".kilo" / "commands" + canonical_dir.mkdir(parents=True) + legacy_dir = tmp_path / ".kilocode" / "workflows" + legacy_dir.mkdir(parents=True) + + canonical_cmd = canonical_dir / "speckit.myext.md" + canonical_cmd.write_text("# ext command", encoding="utf-8") + legacy_cmd = legacy_dir / "speckit.myext.md" + legacy_cmd.write_text("# stale ext command", encoding="utf-8") + + registrar = CommandRegistrar() + registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path) + + assert not canonical_cmd.exists() + assert not legacy_cmd.exists() From f6f5b793c2b8a227f77b60d50e3a98d03e876c25 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Jul 2026 17:54:52 +0800 Subject: [PATCH 2/2] fix: guard Kilo legacy command migration --- docs/upgrade.md | 22 +- src/specify_cli/extensions/__init__.py | 21 +- src/specify_cli/integrations/_helpers.py | 20 ++ .../integrations/_migrate_commands.py | 161 ++++++++++++- .../test_integration_subcommand.py | 224 ++++++++++++++++++ 5 files changed, 427 insertions(+), 21 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index 5bacebd912..8ad63fb7a8 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -195,15 +195,13 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command **Example for Kilo Code:** ```bash -# Navigate to the agent's commands folder -cd .kilo/commands/ - -# List files and identify duplicates -ls -la +# List current and legacy Kilo command folders +ls -la .kilo/commands/ +ls -la .kilocode/workflows/ # Delete old versions (example filenames - yours may differ) -rm speckit.specify-old.md -rm speckit.plan-v1.md +rm .kilocode/workflows/speckit.specify-old.md +rm .kilocode/workflows/speckit.plan-v1.md ``` Restart your IDE to refresh the command list. @@ -248,14 +246,12 @@ specify extension update This happens with IDE-based agents (Kilo Code, Cline, etc.). ```bash -# Find the agent folder (example: .kilo/commands/) -cd .kilo/commands/ - -# List all files -ls -la +# For Kilo Code, inspect both current and legacy command folders +ls -la .kilo/commands/ +ls -la .kilocode/workflows/ # Delete old command files -rm speckit.old-command-name.md +rm .kilocode/workflows/speckit.old-command-name.md # Restart your IDE ``` diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 00a6d92b41..91d8193fa1 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2179,7 +2179,13 @@ def _valid_name_list(value: Any) -> List[str]: return [] return [item for item in value if isinstance(item, str)] - def unregister_agent_artifacts(self, agent_name: str) -> None: + def unregister_agent_artifacts( + self, + agent_name: str, + *, + enabled_only: bool = False, + commands_only: bool = False, + ) -> None: """Remove extension files registered for a specific agent. Extension command files are tracked per agent in ``registered_commands``. @@ -2187,6 +2193,14 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: from that agent's skills directory (resolved via its integration config) and the registry field is cleared. + Set ``enabled_only=True`` when a caller is about to re-register enabled + extensions and must preserve disabled extensions' existing artifacts and + registry entries. + + Set ``commands_only=True`` for command-directory reconciliation where + skill artifacts are outside the target agent's lifecycle and must not + be touched. + Skips cleanup when *agent_name* is not a supported agent to avoid losing registry entries while leaving orphaned files on disk. """ @@ -2205,6 +2219,9 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: agent_skills_dir = resolve_skills_dir(self.project_root, agent_name) for ext_id, metadata in self.registry.list().items(): + if enabled_only and not metadata.get("enabled", True): + continue + updates: Dict[str, Any] = {} registered_commands = metadata.get("registered_commands", {}) @@ -2227,7 +2244,7 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: registered_skills = self._valid_name_list( metadata.get("registered_skills", []) ) - if registered_skills: + if registered_skills and not commands_only: # Only pass the resolved skills_dir when it actually exists. # Otherwise let _unregister_extension_skills fall back to # scanning all known agent skills directories, which is useful diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 415668745b..702f694ce5 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -443,6 +443,26 @@ def _unregister_extensions_for_agent( ) +def _unregister_enabled_extension_commands_for_agent( + project_root: Path, + agent_key: str, + *, + continuing: str, +) -> None: + """Best-effort removal of enabled extension command artifacts for ``agent_key``.""" + _best_effort_extension_op( + project_root, + agent_key, + lambda mgr, key: mgr.unregister_agent_artifacts( + key, + enabled_only=True, + commands_only=True, + ), + phase="clean up enabled extension command artifacts for", + continuing=continuing, + ) + + # --------------------------------------------------------------------------- # CLI formatting helpers (re-exported from _commands.py) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 7116a744c4..62151b8e8a 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -35,6 +35,7 @@ _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, + _unregister_enabled_extension_commands_for_agent, _unregister_extensions_for_agent, _update_init_options_for_integration, _write_integration_json, @@ -54,6 +55,66 @@ def _manifest_tracks_skill_layout(manifest) -> bool: return any(str(rel).endswith("/SKILL.md") for rel in manifest.files) +def _manifest_path_under(rel_path: str, root: str) -> bool: + """Return True when manifest key *rel_path* is inside project-relative *root*.""" + normalized_root = PurePath(root).as_posix().strip("/") + normalized_rel = PurePath(rel_path).as_posix().strip("/") + if not normalized_root: + return False + return normalized_rel == normalized_root or normalized_rel.startswith( + f"{normalized_root}/" + ) + + +def _legacy_command_root_changed( + integration, + project_root: Path, + old_manifest, + new_manifest, +) -> bool: + """Return True when command artifacts moved from legacy_dir to canonical dir.""" + config = integration.registrar_config or {} + canonical = config.get("dir") + legacy = config.get("legacy_dir") + if ( + not isinstance(canonical, str) + or not canonical.strip() + or not isinstance(legacy, str) + or not legacy.strip() + or PurePath(canonical).as_posix() == PurePath(legacy).as_posix() + ): + return False + + canonical_dir = project_root / canonical + legacy_dir = project_root / legacy + if not canonical_dir.is_dir() or not legacy_dir.is_dir(): + return False + + old_had_legacy = any( + _manifest_path_under(rel, legacy) for rel in old_manifest.files + ) + new_has_canonical = any( + _manifest_path_under(rel, canonical) for rel in new_manifest.files + ) + return old_had_legacy and new_has_canonical + + +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 {} + canonical = config.get("dir") + legacy = config.get("legacy_dir") + if ( + not isinstance(canonical, str) + or not canonical.strip() + or not isinstance(legacy, str) + or not legacy.strip() + or PurePath(canonical).as_posix() == PurePath(legacy).as_posix() + ): + return False + return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files) + + class _PresetRegistryUnreadableError(Exception): """Raised when an existing preset registry cannot be read or parsed. @@ -64,7 +125,12 @@ class _PresetRegistryUnreadableError(Exception): """ -def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]: +def _installed_presets_affecting_agent( + project_root, + agent_key: str, + *, + include_skills: bool = True, +) -> list[str]: """Return IDs of installed presets with artifacts registered for *agent_key*. Presets register command overrides for every detected agent and mirror @@ -117,17 +183,30 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str f"preset '{preset_id}' registered_commands is malformed" ) registered_skills = meta.get("registered_skills", []) - if not isinstance(registered_skills, (list, tuple)): - raise _PresetRegistryUnreadableError( - f"preset '{preset_id}' registered_skills is malformed" - ) + if include_skills: + if not isinstance(registered_skills, (list, tuple)): + raise _PresetRegistryUnreadableError( + f"preset '{preset_id}' registered_skills is malformed" + ) has_commands = bool(registered_commands.get(agent_key)) - has_skills = bool(registered_skills) + has_skills = include_skills and bool(registered_skills) if has_commands or has_skills: affected.append(preset_id) return affected +def _installed_command_presets_affecting_agent( + project_root, + agent_key: str, +) -> list[str]: + """Return installed presets with command artifacts registered for *agent_key*.""" + return _installed_presets_affecting_agent( + project_root, + agent_key, + include_skills=False, + ) + + @integration_app.command("switch") def integration_switch( target: str = typer.Argument(help="Integration key to switch to"), @@ -487,6 +566,60 @@ def integration_upgrade( integration, current, key, integration_options ) + legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending( + integration, + old_manifest, + ) + + # Guard: Kilo's legacy command root moves from .kilocode/workflows to + # .kilo/commands. Preset command artifacts are registered only during + # preset install/remove, with no agent-scoped re-registration hook to + # recreate them at the new command root while preserving priority and + # composition semantics. Refuse before setup writes .kilo/commands rather + # than leaving legacy preset files orphaned or registry-tracked overrides + # missing from the canonical directory. + if key == "kilocode" and legacy_command_root_upgrade_pending: + config = integration.registrar_config or {} + legacy = config.get("legacy_dir", "legacy command directory") + canonical = config.get("dir", "canonical command directory") + try: + affected_presets = _installed_command_presets_affecting_agent( + project_root, + key, + ) + except _PresetRegistryUnreadableError as exc: + console.print( + f"[red]Error:[/red] Cannot migrate '{key}' command directory " + f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: " + "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 directory migration 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 migrate '{key}' command directory " + f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while " + f"preset override(s) are installed: [bold]{preset_list}[/bold]." + ) + console.print( + "Preset command artifacts cannot yet be reconciled across this " + "command directory migration, 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} --script {selected_script} --force[/cyan]\n" + f" [cyan]specify preset add [/cyan]" + ) + raise typer.Exit(1) + # Guard: reject a command↔skills layout change while preset overrides are # installed for this agent (review #3415). A dual-mode agent (e.g. Bob) # can flip layout across an upgrade (``--skills`` / ``--legacy-commands``). @@ -646,6 +779,22 @@ def integration_upgrade( if stale_removed: console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + legacy_command_root_changed = _legacy_command_root_changed( + integration, + project_root, + old_manifest, + new_manifest, + ) + if legacy_command_root_changed: + _unregister_enabled_extension_commands_for_agent( + project_root, + key, + continuing=( + "The integration command directory changed, but legacy enabled " + "extension artifacts may need manual cleanup." + ), + ) + # Re-register enabled extensions for the upgraded agent so its extension # commands are (re)created — including agents installed before this # back-fill existed. Mirrors switch for command registration; see #2886. diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 748b707d66..40bc3fec1f 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -69,6 +69,26 @@ def _write_invalid_manifest(project, key): return manifest +def _move_kilocode_install_to_legacy_layout(project): + """Simulate a pre-.kilo Kilo install tracked under .kilocode/workflows.""" + canonical = project / ".kilo" / "commands" + legacy = project / ".kilocode" / "workflows" + assert canonical.is_dir(), "init should have created .kilo/commands/" + legacy.parent.mkdir(parents=True, exist_ok=True) + canonical.rename(legacy) + assert legacy.is_dir() + assert not canonical.exists() + + manifest_path = project / ".specify" / "integrations" / "kilocode.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_data["files"] = { + path.replace(".kilo/commands/", ".kilocode/workflows/"): info + for path, info in manifest_data.get("files", {}).items() + } + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + return canonical, legacy + + def _copy_project_template(tmp_path, template): project = tmp_path / "proj" shutil.copytree(template, project) @@ -2493,6 +2513,206 @@ def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): f"found: {[f.name for f in core_remaining]}" ) + def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): + """Upgrade moves Kilo commands from .kilocode/workflows/ to .kilo/commands/.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + old_commands = sorted(legacy.glob("speckit.*.md")) + assert old_commands, "Legacy dir should have speckit command files" + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" + new_commands = sorted(canonical.glob("speckit.*.md")) + assert new_commands, "Commands should exist in .kilo/commands/" + + core_remaining = [ + f for f in legacy.glob("speckit.*.md") + if "agent-context" not in f.name + ] + assert core_remaining == [], ( + "Legacy .kilocode/workflows/ should have no core speckit files " + f"after upgrade, found: {[f.name for f in core_remaining]}" + ) + + def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides( + self, tmp_path + ): + """Kilo legacy command-root migration must fail closed with presets.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + preset_file = legacy / "speckit.plan.md" + preset_file.write_text("# preset plan override\n", encoding="utf-8") + + presets_dir = project / ".specify" / "presets" + presets_dir.mkdir(parents=True, exist_ok=True) + (presets_dir / ".registry").write_text( + json.dumps({ + "presets": { + "my-preset": { + "version": "1.0.0", + "enabled": True, + "registered_commands": {"kilocode": ["speckit.plan"]}, + "registered_skills": [], + } + } + }), + encoding="utf-8", + ) + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code != 0, ( + "Kilo legacy command-root migration with presets must be rejected" + ) + assert "preset" in result.output.lower() + assert "my-preset" in result.output + assert ".kilocode/workflows" in strip_ansi(result.output) + assert ".kilo/commands" in strip_ansi(result.output) + assert not canonical.exists(), ( + "canonical Kilo commands must not be scaffolded after rejection" + ) + assert preset_file.read_text(encoding="utf-8") == "# preset plan override\n" + + def test_upgrade_reconciles_kilocode_legacy_extension_artifacts(self, tmp_path): + """Kilo upgrade moves enabled extension commands to the canonical dir.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + assert sorted(legacy.glob("speckit.git.*.md")), ( + "legacy Kilo should render the git extension under .kilocode/workflows" + ) + assert not canonical.exists() + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + assert sorted(canonical.glob("speckit.git.*.md")), ( + "enabled git extension commands should be recreated in .kilo/commands" + ) + assert not sorted(legacy.glob("speckit.git.*.md")), ( + "legacy git extension commands should be removed after Kilo upgrade" + ) + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "kilocode" in registered + + def test_upgrade_preserves_disabled_kilocode_legacy_extension_and_user_file( + self, tmp_path + ): + """Legacy reconciliation must not clean disabled or user-owned files.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, ["extension", "disable", "git"]) + assert result.exit_code == 0, f"extension disable failed: {result.output}" + + disabled_extension_files = sorted(legacy.glob("speckit.git.*.md")) + assert disabled_extension_files, "disabled extension artifact should remain pre-upgrade" + + user_file = legacy / "speckit.user-owned.md" + user_file.write_text("# user-owned legacy command", encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" + assert user_file.read_text(encoding="utf-8") == "# user-owned legacy command" + for disabled_file in disabled_extension_files: + assert disabled_file.exists(), ( + "disabled extension artifacts should be preserved during " + "legacy command-root reconciliation" + ) + assert not sorted(canonical.glob("speckit.git.*.md")), ( + "disabled extensions must not be re-registered in the canonical dir" + ) + + def test_upgrade_secondary_kilocode_legacy_dir_preserves_active_agent_skills( + self, tmp_path + ): + """Kilo command-root cleanup must not touch active agent extension skills.""" + project = _init_project(tmp_path, "copilot", integration_options="--skills") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + assert skill.exists(), "precondition: active copilot has the git extension skill" + + registry_path = project / ".specify" / "extensions" / ".registry" + + def _git_skills(): + data = json.loads(registry_path.read_text(encoding="utf-8")) + return data["extensions"]["git"].get("registered_skills", []) + + assert _git_skills(), "precondition: git skills registered for active copilot" + + result = _run_in_project(project, [ + "integration", "install", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, result.output + + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + legacy_git_command = legacy / "speckit.git.feature.md" + legacy_git_command.write_text("# legacy Kilo git command\n", encoding="utf-8") + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registry["extensions"]["git"].setdefault("registered_commands", {})[ + "kilocode" + ] = ["speckit.git.feature"] + registry_path.write_text(json.dumps(registry), encoding="utf-8") + assert legacy_git_command.exists(), ( + "precondition: secondary Kilo has a legacy extension command file" + ) + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, result.output + + assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" + assert sorted(canonical.glob("speckit.git.*.md")), ( + "secondary Kilo should regain enabled extension commands" + ) + assert not legacy_git_command.exists(), ( + "secondary Kilo legacy extension commands should still be cleaned up" + ) + assert skill.exists(), ( + "secondary Kilo legacy cleanup must not delete the active agent's " + "extension skill" + ) + assert _git_skills(), ( + "secondary Kilo legacy cleanup must not untrack the active agent's " + "extension skills in the registry" + ) + def test_upgrade_bob_skills_migration_preserves_manifest(self, tmp_path): """Regression (review #3415, 4724160183, comment 1). @@ -2936,6 +3156,7 @@ def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path): """ from specify_cli.integrations._migrate_commands import ( _PresetRegistryUnreadableError, + _installed_command_presets_affecting_agent, _installed_presets_affecting_agent, ) @@ -3001,6 +3222,9 @@ def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path): "p1", "p3", ] + assert _installed_command_presets_affecting_agent(project, "bob") == [ + "p1" + ] # ── Full lifecycle ───────────────────────────────────────────────────