Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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]],
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
marcusburghardt marked this conversation as resolved.
"legacy_dir": ".opencode/command",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
Expand Down
112 changes: 110 additions & 2 deletions tests/integrations/test_integration_opencode.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""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


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):
Expand Down Expand Up @@ -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"))
49 changes: 46 additions & 3 deletions tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
marcusburghardt marked this conversation as resolved.

# Old kimi extension skills should be removed
Expand Down Expand Up @@ -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"

Expand All @@ -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, [
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────

Expand Down