diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 726b0fd2a6..244c2ad075 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -453,7 +453,9 @@ def register_commands( raise ValueError(f"Unsupported agent: {agent_name}") agent_config = self.AGENT_CONFIGS[agent_name] - commands_dir = project_root / agent_config["dir"] + commands_dir = self._resolve_agent_dir( + agent_name, agent_config, project_root, + ) commands_dir.mkdir(parents=True, exist_ok=True) registered = [] @@ -609,6 +611,40 @@ def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: CommandRegistrar._ensure_inside(prompt_file, prompts_dir) prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8") + @staticmethod + def _resolve_agent_dir( + agent_name: str, + agent_config: dict[str, Any], + project_root: Path, + ) -> Path: + """Return the agent command directory, falling back to legacy_dir. + + When the canonical directory (``agent_config["dir"]``) does not + exist but a ``legacy_dir`` is configured and present on disk, + returns the legacy path and emits a deprecation warning advising + the user to upgrade. + + Integrations that do not declare ``legacy_dir`` get the canonical + path unconditionally — no fallback, no warning. + """ + agent_dir = project_root / agent_config["dir"] + if not agent_dir.exists(): + legacy = agent_config.get("legacy_dir") + if legacy: + legacy_dir = project_root / legacy + if legacy_dir.exists(): + import warnings + + warnings.warn( + f"Found legacy {legacy}/ directory for " + f"{agent_name}. Run 'specify integration " + f"upgrade {agent_name}' to migrate to " + f"{agent_config['dir']}/.", + stacklevel=3, + ) + return legacy_dir + return agent_dir + def register_commands_for_all_agents( self, commands: List[Dict[str, Any]], @@ -633,7 +669,9 @@ def register_commands_for_all_agents( self._ensure_configs() for agent_name, agent_config in self.AGENT_CONFIGS.items(): - agent_dir = project_root / agent_config["dir"] + agent_dir = self._resolve_agent_dir( + agent_name, agent_config, project_root, + ) if agent_dir.exists(): try: @@ -681,7 +719,9 @@ def register_commands_for_non_skill_agents( for agent_name, agent_config in self.AGENT_CONFIGS.items(): if agent_config.get("extension") == "/SKILL.md": continue - agent_dir = project_root / agent_config["dir"] + agent_dir = self._resolve_agent_dir( + agent_name, agent_config, project_root, + ) if agent_dir.exists(): try: registered = self.register_commands( @@ -710,7 +750,9 @@ def unregister_commands( continue agent_config = self.AGENT_CONFIGS[agent_name] - commands_dir = project_root / agent_config["dir"] + commands_dir = self._resolve_agent_dir( + agent_name, agent_config, project_root, + ) for cmd_name in cmd_names: output_name = self._compute_output_name( diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 17db2bd11b..4fa9c724ac 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -8,12 +8,13 @@ class OpencodeIntegration(MarkdownIntegration): config = { "name": "opencode", "folder": ".opencode/", - "commands_subdir": "command", + "commands_subdir": "commands", "install_url": "https://opencode.ai", "requires_cli": True, } registrar_config = { - "dir": ".opencode/command", + "dir": ".opencode/commands", + "legacy_dir": ".opencode/command", "format": "markdown", "args": "$ARGUMENTS", "extension": ".md", diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 427fd15167..39cf5ee9f5 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,6 +1,10 @@ """Tests for OpencodeIntegration.""" +import warnings + +from specify_cli.agents import CommandRegistrar from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_markdown import MarkdownIntegrationTests @@ -8,8 +12,8 @@ class TestOpencodeIntegration(MarkdownIntegrationTests): KEY = "opencode" FOLDER = ".opencode/" - COMMANDS_SUBDIR = "command" - REGISTRAR_DIR = ".opencode/command" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".opencode/commands" CONTEXT_FILE = "AGENTS.md" def test_build_exec_args_uses_run_command_dispatch(self): @@ -57,3 +61,107 @@ def test_build_exec_args_keeps_plain_prompt_dispatch(self): args = integration.build_exec_args("explain this repository", output_json=False) assert args == ["opencode", "run", "explain this repository"] + + def test_registrar_config_has_legacy_dir(self): + integration = get_integration(self.KEY) + assert integration.registrar_config["legacy_dir"] == ".opencode/command" + + def test_legacy_dir_extension_registration(self, tmp_path): + """Extensions register in legacy .opencode/command/ with a warning.""" + # Seed a legacy project with only .opencode/command/ + legacy_dir = tmp_path / ".opencode" / "command" + legacy_dir.mkdir(parents=True) + (legacy_dir / "speckit.specify.md").write_text("# existing", encoding="utf-8") + + # Create a source command file for the registrar + 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"}] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + results = registrar.register_commands_for_all_agents( + commands, "test-ext", src_dir, tmp_path, + ) + + # Should have registered in the legacy directory + assert "opencode" in results + assert (legacy_dir / "speckit.myext.md").exists() + # Canonical directory should NOT have been created + assert not (tmp_path / ".opencode" / "commands").exists() + # Should have emitted a deprecation warning + opencode_warnings = [ + w for w in caught + if "legacy" in str(w.message) and "opencode" in str(w.message) + ] + assert len(opencode_warnings) >= 1 + assert "specify integration upgrade" in str(opencode_warnings[0].message) + + def test_legacy_dir_unregister(self, tmp_path): + """Unregister finds commands in legacy .opencode/command/ dir.""" + legacy_dir = tmp_path / ".opencode" / "command" + legacy_dir.mkdir(parents=True) + cmd_file = legacy_dir / "speckit.myext.md" + cmd_file.write_text("# ext command", encoding="utf-8") + + registrar = CommandRegistrar() + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + registrar.unregister_commands( + {"opencode": ["speckit.myext"]}, tmp_path, + ) + + assert not cmd_file.exists() + + def test_canonical_dir_preferred_over_legacy(self, tmp_path): + """When both dirs exist, canonical .opencode/commands/ is used.""" + legacy_dir = tmp_path / ".opencode" / "command" + legacy_dir.mkdir(parents=True) + canonical_dir = tmp_path / ".opencode" / "commands" + canonical_dir.mkdir(parents=True) + (canonical_dir / "speckit.specify.md").write_text("# cmd", encoding="utf-8") + + # Create a source command file for the registrar + 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"}] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + results = registrar.register_commands_for_all_agents( + commands, "test-ext", src_dir, tmp_path, + ) + + # Should register in canonical dir, not legacy + assert "opencode" in results + assert (canonical_dir / "speckit.myext.md").exists() + assert not (legacy_dir / "speckit.myext.md").exists() + # No legacy warning when canonical dir exists + opencode_warnings = [ + w for w in caught + if "legacy" in str(w.message) and "opencode" in str(w.message) + ] + assert len(opencode_warnings) == 0 + + def test_setup_writes_to_canonical_dir(self, tmp_path): + """New installs always write to .opencode/commands/ (plural).""" + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest) + + canonical = tmp_path / ".opencode" / "commands" + legacy = tmp_path / ".opencode" / "command" + assert canonical.is_dir() + assert not legacy.exists() + assert any(canonical.glob("speckit.*.md")) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 750bbb6efa..c15d2d6a64 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -762,7 +762,7 @@ def test_switch_migrates_extension_commands(self, tmp_path): assert result.exit_code == 0, result.output # Git extension commands should exist for opencode - opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" assert opencode_git_feature.exists(), "Git extension command should exist for opencode" # Old kimi extension skills should be removed @@ -837,7 +837,7 @@ def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): ]) assert result.exit_code == 0, result.output - opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" assert opencode_git_feature.exists(), "Git extension command should exist for opencode" assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed" @@ -858,7 +858,7 @@ def test_switch_does_not_register_disabled_extensions(self, tmp_path): result = _run_in_project(project, ["extension", "disable", "git"]) assert result.exit_code == 0, result.output - opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch" result = _run_in_project(project, [ @@ -1022,6 +1022,49 @@ def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): assert data["integration"] == "gemini" assert "/speckit.plan" in template.read_text(encoding="utf-8") + def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): + """Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/.""" + project = _init_project(tmp_path, "opencode") + + # Simulate a legacy project: rename commands/ back to command/ + canonical = project / ".opencode" / "commands" + legacy = project / ".opencode" / "command" + assert canonical.is_dir(), "init should have created .opencode/commands/" + canonical.rename(legacy) + assert legacy.is_dir() + assert not canonical.exists() + + # Patch the manifest to reflect old paths (command/ not commands/) + manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + patched_files = {} + for path, info in manifest_data.get("files", {}).items(): + patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info + manifest_data["files"] = patched_files + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + old_commands = sorted(legacy.glob("speckit.*.md")) + assert len(old_commands) > 0, "Legacy dir should have speckit command files" + + result = _run_in_project(project, [ + "integration", "upgrade", "opencode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + # New commands in canonical dir + assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade" + new_commands = sorted(canonical.glob("speckit.*.md")) + assert len(new_commands) > 0, "Commands should exist in .opencode/commands/" + + # Stale files removed from legacy dir + remaining = list(legacy.glob("speckit.*.md")) + assert len(remaining) == 0, ( + f"Legacy .opencode/command/ should have no speckit files after upgrade, " + f"found: {[f.name for f in remaining]}" + ) + # ── Full lifecycle ───────────────────────────────────────────────────