diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 57bb46b10c..57b079bcd1 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -292,7 +292,7 @@ The currently declared multi-install safe integrations are: | `lingma` | `.lingma/skills` | | `omp` | `.omp/commands` | | `pi` | `.pi/prompts` | -| `qodercli` | `.qoder/commands` | +| `qodercli` | `.qoder/skills` | | `qwen` | `.qwen/commands` | | `shai` | `.shai/commands` | | `tabnine` | `.tabnine/agent/commands` | diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index 5cc7098837..ec6ac0f323 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -12,7 +12,9 @@ DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"}) # Agents that always render /speckit-, regardless of ai_skills. -ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"}) +ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset( + {"devin", "droid", "grok", "qodercli", "trae", "zed"} +) # Agents that render /speckit- only when ai_skills is enabled. CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..3968e4fcbe 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3100,6 +3100,76 @@ def unregister_agent_artifacts( if updates: self.registry.update(ext_id, updates) + def _retire_legacy_flat_extension_commands( + self, + agent_name: str, + command_names: List[str], + ) -> List[Path]: + """Remove old flat commands whose replacement skills were written.""" + 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 [] + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if not agent_config or agent_config.get("extension") != "/SKILL.md": + return [] + + def safe_project_dir(relative: str) -> Optional[Path]: + rel = Path(relative) + if rel.is_absolute() or ".." in rel.parts: + return None + current = self.project_root + for part in rel.parts: + current /= part + if current.is_symlink(): + return None + try: + current.resolve().relative_to(self.project_root.resolve()) + except (OSError, ValueError): + return None + 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(): + return [] + + removed: List[Path] = [] + for command_name in command_names: + if ( + not isinstance(command_name, str) + or not command_name + or not registrar._is_safe_command_name(command_name) + ): + continue + + skill_name = registrar._compute_output_name( + agent_name, command_name, agent_config + ) + replacement = skills_root / skill_name / "SKILL.md" + if replacement.is_symlink() or not replacement.is_file(): + continue + + legacy_file = legacy_root / f"{command_name}{legacy_extension}" + if legacy_file.is_symlink() or legacy_file.is_file(): + legacy_file.unlink() + removed.append(legacy_file) + + return removed + def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None: """Register installed, enabled extensions for ``agent_name``. @@ -3160,6 +3230,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool # registration of the remaining enabled extensions for this agent. try: updates: Dict[str, Any] = {} + registered: List[str] = [] # Set when a command -> skills toggle for this same agent # defers stale command-mode cleanup until the skills # replacement below confirms success (#2948). @@ -3380,6 +3451,12 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool if new_registered != registered_commands: updates["registered_commands"] = new_registered + if registered: + self._retire_legacy_flat_extension_commands( + agent_name, + registered, + ) + if updates: self.registry.update(ext_id, updates) except Exception as ext_err: diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 03c7a90e74..27c43582b0 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -142,6 +142,12 @@ class IntegrationBase(ABC): integration that sets this flag. """ + legacy_flat_command_dir: str | None = None + """Previous flat command directory retired after skill replacements exist.""" + + legacy_flat_command_extension: str | None = None + """File extension used by commands in ``legacy_flat_command_dir``.""" + def post_process_command_content(self, content: str) -> str: """Transform command content after format rendering. diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index 13535203cf..0fec683fae 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -1,21 +1,28 @@ -"""Qoder CLI integration.""" +"""Qoder CLI integration. -from ..base import MarkdownIntegration +Qoder IDE 1.24+ dropped ``.qoder/commands/`` scanning in favour of the +skills layout: ``.qoder/skills/{skill-name}/SKILL.md`` with a ``name`` +field in frontmatter. Migrated to ``SkillsIntegration`` to match. +""" +from ..base import SkillsIntegration -class QodercliIntegration(MarkdownIntegration): + +class QodercliIntegration(SkillsIntegration): key = "qodercli" config = { "name": "Qoder CLI", "folder": ".qoder/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": "https://qoder.com/cli", "requires_cli": True, } registrar_config = { - "dir": ".qoder/commands", + "dir": ".qoder/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } + legacy_flat_command_dir = ".qoder/commands" + legacy_flat_command_extension = ".md" multi_install_safe = True diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py index 29a6d16d29..f30f62cae0 100644 --- a/tests/integrations/test_integration_qodercli.py +++ b/tests/integrations/test_integration_qodercli.py @@ -1,10 +1,39 @@ """Tests for QodercliIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import pytest +from specify_cli.integrations import get_integration -class TestQodercliIntegration(MarkdownIntegrationTests): +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestQodercliIntegration(SkillsIntegrationTests): KEY = "qodercli" FOLDER = ".qoder/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".qoder/commands" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".qoder/skills" + + def test_options_include_skills_flag(self): + """Not applicable — Qoder IDE 1.24+ is always skills-based.""" + pytest.skip( + "Qoder is always skills-based and does not expose a --skills option" + ) + + def test_options_do_not_include_skills_flag(self): + """Qoder is always skills-based; no --skills option is exposed.""" + i = get_integration(self.KEY) + assert i is not None + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 0, ( + "Qoder is always skills-based and should not expose a --skills option" + ) + + def test_requires_cli_is_true(self): + """Qoder CLI is a CLI-based agent; requires_cli must remain True.""" + i = get_integration(self.KEY) + assert i is not None + assert i.config is not None + assert i.config["requires_cli"] is True + assert i.config["name"] == "Qoder CLI" + assert i.multi_install_safe is True diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 994fecb148..eaeecc6740 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -3153,6 +3153,66 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): f"after upgrade, found: {[f.name for f in core_remaining]}" ) + 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") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skills = project / ".qoder" / "skills" + commands = project / ".qoder" / "commands" + commands.mkdir(parents=True) + + manifest_path = ( + project / ".specify" / "integrations" / "qodercli.manifest.json" + ) + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + legacy_manifest_files = {} + for path, info in manifest_data["files"].items(): + skill_path = project / path + command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1) + legacy_path = commands / f"{command_name}.md" + legacy_path.write_bytes(skill_path.read_bytes()) + legacy_manifest_files[ + legacy_path.relative_to(project).as_posix() + ] = info + manifest_data["files"] = legacy_manifest_files + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + registry_path = project / ".specify" / "extensions" / ".registry" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + git_metadata = registry["extensions"]["git"] + registered_commands = git_metadata["registered_commands"]["qodercli"] + for command_name in registered_commands: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + old_command = commands / f"{command_name}.md" + old_command.write_bytes( + (skills / skill_name / "SKILL.md").read_bytes() + ) + missing_replacement = commands / "speckit.git.missing.md" + missing_replacement.write_text("# preserve until replaced\n", encoding="utf-8") + registered_commands.append("speckit.git.missing") + git_metadata["registered_skills"] = [] + registry_path.write_text(json.dumps(registry), encoding="utf-8") + + shutil.rmtree(skills) + result = _run_in_project(project, [ + "integration", "upgrade", "qodercli", "--script", "sh", "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + for command_name in registered_commands[:-1]: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + assert (skills / skill_name / "SKILL.md").is_file() + assert not (commands / f"{command_name}.md").exists() + assert missing_replacement.is_file(), ( + "a legacy command must remain when no replacement skill was written" + ) + def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides( self, tmp_path ): diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 23627d316d..1a55c9ae87 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -143,6 +143,8 @@ def _render_invocation(project_path, ai: str, ai_skills: bool) -> str: ("devin", False, "/speckit-plan"), ("grok", True, "/speckit-plan"), ("grok", False, "/speckit-plan"), + ("qodercli", True, "/speckit-plan"), + ("qodercli", False, "/speckit-plan"), ("trae", True, "/speckit-plan"), ("trae", False, "/speckit-plan"), ("zed", True, "/speckit-plan"),