Skip to content

Commit fa19e1c

Browse files
github-actions[bot]Copilotmnriem
authored
[bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (#4205)
* Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration Apply the remediation from the bug assessment on issue #4199. Qoder IDE 1.24+ dropped .qoder/commands/ scanning in favour of the skills layout (.qoder/skills/{skill-name}/SKILL.md). Migrated QodercliIntegration from MarkdownIntegration to SkillsIntegration, updating config[commands_subdir] to 'skills' and registrar_config[dir] to '.qoder/skills' with extension '/SKILL.md'. Updated tests to use SkillsIntegrationTests base mixin. Refs #4199 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(qodercli): resolve failing skills-flag test and slash invocation Builds on the qodercli->SkillsIntegration migration (PR #4205). Qoder IDE 1.24+ is always skills-based, so it should not expose a --skills toggle. Override the inherited SkillsIntegrationTests.test_options_include_skills_flag to skip (mirroring Grok/Zed/Droid) and add a test asserting no --skills option, plus a requires_cli/name/multi_install_safe check. Also add "qodercli" to ALWAYS_SLASH_AGENTS so hooks and next-steps render the hyphenated /speckit-<name> invocation instead of the legacy dotted /speckit.<name> form. Fixes the single failing test reported for #4199. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570 * fix(qodercli): migrate legacy extension commands Retire old flat Qoder extension commands only after their replacement skills are successfully written. Cover old-layout upgrades and both slash invocation states, and update the integration reference path. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570
1 parent abfc66b commit fa19e1c

8 files changed

Lines changed: 195 additions & 12 deletions

File tree

docs/reference/integrations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ The currently declared multi-install safe integrations are:
292292
| `lingma` | `.lingma/skills` |
293293
| `omp` | `.omp/commands` |
294294
| `pi` | `.pi/prompts` |
295-
| `qodercli` | `.qoder/commands` |
295+
| `qodercli` | `.qoder/skills` |
296296
| `qwen` | `.qwen/commands` |
297297
| `shai` | `.shai/commands` |
298298
| `tabnine` | `.tabnine/agent/commands` |

src/specify_cli/_invocation_style.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"})
1313

1414
# Agents that always render /speckit-<name>, regardless of ai_skills.
15-
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
15+
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset(
16+
{"devin", "droid", "grok", "qodercli", "trae", "zed"}
17+
)
1618

1719
# Agents that render /speckit-<name> only when ai_skills is enabled.
1820
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(

src/specify_cli/extensions/__init__.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3100,6 +3100,76 @@ def unregister_agent_artifacts(
31003100
if updates:
31013101
self.registry.update(ext_id, updates)
31023102

3103+
def _retire_legacy_flat_extension_commands(
3104+
self,
3105+
agent_name: str,
3106+
command_names: List[str],
3107+
) -> List[Path]:
3108+
"""Remove old flat commands whose replacement skills were written."""
3109+
from ..agents import CommandRegistrar
3110+
from ..integrations import get_integration
3111+
3112+
integration = get_integration(agent_name)
3113+
legacy_dir = getattr(integration, "legacy_flat_command_dir", None)
3114+
legacy_extension = getattr(
3115+
integration, "legacy_flat_command_extension", None
3116+
)
3117+
if (
3118+
not isinstance(legacy_dir, str)
3119+
or not legacy_dir
3120+
or not isinstance(legacy_extension, str)
3121+
or not legacy_extension
3122+
):
3123+
return []
3124+
3125+
registrar = CommandRegistrar()
3126+
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
3127+
if not agent_config or agent_config.get("extension") != "/SKILL.md":
3128+
return []
3129+
3130+
def safe_project_dir(relative: str) -> Optional[Path]:
3131+
rel = Path(relative)
3132+
if rel.is_absolute() or ".." in rel.parts:
3133+
return None
3134+
current = self.project_root
3135+
for part in rel.parts:
3136+
current /= part
3137+
if current.is_symlink():
3138+
return None
3139+
try:
3140+
current.resolve().relative_to(self.project_root.resolve())
3141+
except (OSError, ValueError):
3142+
return None
3143+
return current
3144+
3145+
legacy_root = safe_project_dir(legacy_dir)
3146+
skills_root = safe_project_dir(str(agent_config.get("dir", "")))
3147+
if legacy_root is None or skills_root is None or not legacy_root.is_dir():
3148+
return []
3149+
3150+
removed: List[Path] = []
3151+
for command_name in command_names:
3152+
if (
3153+
not isinstance(command_name, str)
3154+
or not command_name
3155+
or not registrar._is_safe_command_name(command_name)
3156+
):
3157+
continue
3158+
3159+
skill_name = registrar._compute_output_name(
3160+
agent_name, command_name, agent_config
3161+
)
3162+
replacement = skills_root / skill_name / "SKILL.md"
3163+
if replacement.is_symlink() or not replacement.is_file():
3164+
continue
3165+
3166+
legacy_file = legacy_root / f"{command_name}{legacy_extension}"
3167+
if legacy_file.is_symlink() or legacy_file.is_file():
3168+
legacy_file.unlink()
3169+
removed.append(legacy_file)
3170+
3171+
return removed
3172+
31033173
def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
31043174
"""Register installed, enabled extensions for ``agent_name``.
31053175
@@ -3160,6 +3230,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
31603230
# registration of the remaining enabled extensions for this agent.
31613231
try:
31623232
updates: Dict[str, Any] = {}
3233+
registered: List[str] = []
31633234
# Set when a command -> skills toggle for this same agent
31643235
# defers stale command-mode cleanup until the skills
31653236
# replacement below confirms success (#2948).
@@ -3380,6 +3451,12 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
33803451
if new_registered != registered_commands:
33813452
updates["registered_commands"] = new_registered
33823453

3454+
if registered:
3455+
self._retire_legacy_flat_extension_commands(
3456+
agent_name,
3457+
registered,
3458+
)
3459+
33833460
if updates:
33843461
self.registry.update(ext_id, updates)
33853462
except Exception as ext_err:

src/specify_cli/integrations/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ class IntegrationBase(ABC):
142142
integration that sets this flag.
143143
"""
144144

145+
legacy_flat_command_dir: str | None = None
146+
"""Previous flat command directory retired after skill replacements exist."""
147+
148+
legacy_flat_command_extension: str | None = None
149+
"""File extension used by commands in ``legacy_flat_command_dir``."""
150+
145151
def post_process_command_content(self, content: str) -> str:
146152
"""Transform command content after format rendering.
147153
Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
1-
"""Qoder CLI integration."""
1+
"""Qoder CLI integration.
22
3-
from ..base import MarkdownIntegration
3+
Qoder IDE 1.24+ dropped ``.qoder/commands/`` scanning in favour of the
4+
skills layout: ``.qoder/skills/{skill-name}/SKILL.md`` with a ``name``
5+
field in frontmatter. Migrated to ``SkillsIntegration`` to match.
6+
"""
47

8+
from ..base import SkillsIntegration
59

6-
class QodercliIntegration(MarkdownIntegration):
10+
11+
class QodercliIntegration(SkillsIntegration):
712
key = "qodercli"
813
config = {
914
"name": "Qoder CLI",
1015
"folder": ".qoder/",
11-
"commands_subdir": "commands",
16+
"commands_subdir": "skills",
1217
"install_url": "https://qoder.com/cli",
1318
"requires_cli": True,
1419
}
1520
registrar_config = {
16-
"dir": ".qoder/commands",
21+
"dir": ".qoder/skills",
1722
"format": "markdown",
1823
"args": "$ARGUMENTS",
19-
"extension": ".md",
24+
"extension": "/SKILL.md",
2025
}
26+
legacy_flat_command_dir = ".qoder/commands"
27+
legacy_flat_command_extension = ".md"
2128
multi_install_safe = True
Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,39 @@
11
"""Tests for QodercliIntegration."""
22

3-
from .test_integration_base_markdown import MarkdownIntegrationTests
3+
import pytest
44

5+
from specify_cli.integrations import get_integration
56

6-
class TestQodercliIntegration(MarkdownIntegrationTests):
7+
from .test_integration_base_skills import SkillsIntegrationTests
8+
9+
10+
class TestQodercliIntegration(SkillsIntegrationTests):
711
KEY = "qodercli"
812
FOLDER = ".qoder/"
9-
COMMANDS_SUBDIR = "commands"
10-
REGISTRAR_DIR = ".qoder/commands"
13+
COMMANDS_SUBDIR = "skills"
14+
REGISTRAR_DIR = ".qoder/skills"
15+
16+
def test_options_include_skills_flag(self):
17+
"""Not applicable — Qoder IDE 1.24+ is always skills-based."""
18+
pytest.skip(
19+
"Qoder is always skills-based and does not expose a --skills option"
20+
)
21+
22+
def test_options_do_not_include_skills_flag(self):
23+
"""Qoder is always skills-based; no --skills option is exposed."""
24+
i = get_integration(self.KEY)
25+
assert i is not None
26+
opts = i.options()
27+
skills_opts = [o for o in opts if o.name == "--skills"]
28+
assert len(skills_opts) == 0, (
29+
"Qoder is always skills-based and should not expose a --skills option"
30+
)
31+
32+
def test_requires_cli_is_true(self):
33+
"""Qoder CLI is a CLI-based agent; requires_cli must remain True."""
34+
i = get_integration(self.KEY)
35+
assert i is not None
36+
assert i.config is not None
37+
assert i.config["requires_cli"] is True
38+
assert i.config["name"] == "Qoder CLI"
39+
assert i.multi_install_safe is True

tests/integrations/test_integration_subcommand.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3153,6 +3153,66 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path):
31533153
f"after upgrade, found: {[f.name for f in core_remaining]}"
31543154
)
31553155

3156+
def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path):
3157+
"""Qoder upgrade retires old extension commands after skills exist."""
3158+
project = _init_project(tmp_path, "qodercli")
3159+
result = _run_in_project(project, ["extension", "add", "git"])
3160+
assert result.exit_code == 0, f"extension add failed: {result.output}"
3161+
3162+
skills = project / ".qoder" / "skills"
3163+
commands = project / ".qoder" / "commands"
3164+
commands.mkdir(parents=True)
3165+
3166+
manifest_path = (
3167+
project / ".specify" / "integrations" / "qodercli.manifest.json"
3168+
)
3169+
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
3170+
legacy_manifest_files = {}
3171+
for path, info in manifest_data["files"].items():
3172+
skill_path = project / path
3173+
command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1)
3174+
legacy_path = commands / f"{command_name}.md"
3175+
legacy_path.write_bytes(skill_path.read_bytes())
3176+
legacy_manifest_files[
3177+
legacy_path.relative_to(project).as_posix()
3178+
] = info
3179+
manifest_data["files"] = legacy_manifest_files
3180+
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
3181+
3182+
registry_path = project / ".specify" / "extensions" / ".registry"
3183+
registry = json.loads(registry_path.read_text(encoding="utf-8"))
3184+
git_metadata = registry["extensions"]["git"]
3185+
registered_commands = git_metadata["registered_commands"]["qodercli"]
3186+
for command_name in registered_commands:
3187+
skill_name = command_name.replace("speckit.", "speckit-", 1).replace(
3188+
".", "-"
3189+
)
3190+
old_command = commands / f"{command_name}.md"
3191+
old_command.write_bytes(
3192+
(skills / skill_name / "SKILL.md").read_bytes()
3193+
)
3194+
missing_replacement = commands / "speckit.git.missing.md"
3195+
missing_replacement.write_text("# preserve until replaced\n", encoding="utf-8")
3196+
registered_commands.append("speckit.git.missing")
3197+
git_metadata["registered_skills"] = []
3198+
registry_path.write_text(json.dumps(registry), encoding="utf-8")
3199+
3200+
shutil.rmtree(skills)
3201+
result = _run_in_project(project, [
3202+
"integration", "upgrade", "qodercli", "--script", "sh", "--force",
3203+
])
3204+
assert result.exit_code == 0, f"upgrade failed: {result.output}"
3205+
3206+
for command_name in registered_commands[:-1]:
3207+
skill_name = command_name.replace("speckit.", "speckit-", 1).replace(
3208+
".", "-"
3209+
)
3210+
assert (skills / skill_name / "SKILL.md").is_file()
3211+
assert not (commands / f"{command_name}.md").exists()
3212+
assert missing_replacement.is_file(), (
3213+
"a legacy command must remain when no replacement skill was written"
3214+
)
3215+
31563216
def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides(
31573217
self, tmp_path
31583218
):

tests/integrations/test_integration_zed.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ def _render_invocation(project_path, ai: str, ai_skills: bool) -> str:
143143
("devin", False, "/speckit-plan"),
144144
("grok", True, "/speckit-plan"),
145145
("grok", False, "/speckit-plan"),
146+
("qodercli", True, "/speckit-plan"),
147+
("qodercli", False, "/speckit-plan"),
146148
("trae", True, "/speckit-plan"),
147149
("trae", False, "/speckit-plan"),
148150
("zed", True, "/speckit-plan"),

0 commit comments

Comments
 (0)