Skip to content

Commit 2b544be

Browse files
committed
fix(opencode): use plural commands directory per official docs
The official opencode documentation specifies that custom commands should now be placed in a directory named `.opencode/commands`. Although previus path is still supported, it is recommended to use the plural form to ensure compatibility with future updates and to follow the standard convention. (See <https://opencode.ai/docs/config/#precedence-order>) Updated commands_subdir and registrar dir from `.opencode/command` to `.opencode/commands` to match opencode's canonical configuration.
1 parent 0593565 commit 2b544be

2 files changed

Lines changed: 169 additions & 4 deletions

File tree

src/specify_cli/integrations/opencode/__init__.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,104 @@
11
"""opencode integration."""
22

3+
from __future__ import annotations
4+
5+
import filecmp
6+
import shutil
7+
from pathlib import Path
8+
from typing import Any
9+
310
from ..base import MarkdownIntegration
11+
from ..manifest import IntegrationManifest
12+
13+
14+
def _migrate_legacy_command_dir(project_root: Path) -> int:
15+
"""Migrate the legacy `.opencode/command` directory to `.opencode/commands`.
16+
17+
Called after setup() has written canonical files to `.opencode/commands/`.
18+
For each legacy file:
19+
- If a same-named file exists in the new dir and is byte-identical, delete legacy.
20+
- If a same-named file exists but content differs (user-customized), preserve legacy.
21+
- If no counterpart exists, move legacy to the new dir.
22+
Symlinks are unlinked rather than traversed, and filesystem errors are silenced.
23+
24+
Returns the number of entries removed or moved from the legacy directory.
25+
"""
26+
legacy = project_root / ".opencode" / "command"
27+
28+
if legacy.is_symlink():
29+
try:
30+
legacy.unlink()
31+
return 1
32+
except OSError:
33+
return 0
34+
35+
if not legacy.is_dir():
36+
return 0
37+
38+
new_dir = project_root / ".opencode" / "commands"
39+
count = 0
40+
41+
for item in legacy.iterdir():
42+
counterpart = new_dir / item.name
43+
try:
44+
if counterpart.exists():
45+
# Only delete when byte-identical; preserve user customizations.
46+
if item.is_file() and counterpart.is_file() and filecmp.cmp(item, counterpart, shallow=False):
47+
_remove_item(item)
48+
count += 1
49+
# else: user-customized or non-file; leave in place
50+
else:
51+
new_dir.mkdir(parents=True, exist_ok=True)
52+
item.rename(counterpart)
53+
count += 1
54+
except OSError:
55+
pass
56+
57+
try:
58+
legacy.rmdir()
59+
except OSError:
60+
pass
61+
62+
return count
63+
64+
65+
def _remove_item(item: Path) -> None:
66+
"""Remove a file or directory, handling symlinks specially."""
67+
if item.is_symlink() or item.is_file():
68+
item.unlink()
69+
else:
70+
shutil.rmtree(item)
471

572

673
class OpencodeIntegration(MarkdownIntegration):
774
key = "opencode"
875
config = {
976
"name": "opencode",
1077
"folder": ".opencode/",
11-
"commands_subdir": "command",
78+
"commands_subdir": "commands",
1279
"install_url": "https://opencode.ai",
1380
"requires_cli": True,
1481
}
1582
registrar_config = {
16-
"dir": ".opencode/command",
83+
"dir": ".opencode/commands",
1784
"format": "markdown",
1885
"args": "$ARGUMENTS",
1986
"extension": ".md",
2087
}
2188
context_file = "AGENTS.md"
2289

90+
def setup(
91+
self,
92+
project_root: Path,
93+
manifest: IntegrationManifest,
94+
parsed_options: dict[str, Any] | None = None,
95+
**opts: Any,
96+
) -> list[Path]:
97+
"""Install commands and remove any legacy `.opencode/command` directory."""
98+
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
99+
_migrate_legacy_command_dir(project_root)
100+
return created
101+
23102
def build_exec_args(
24103
self,
25104
prompt: str,

tests/integrations/test_integration_opencode.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
"""Tests for OpencodeIntegration."""
22

33
from specify_cli.integrations import get_integration
4+
from specify_cli.integrations.manifest import IntegrationManifest
5+
from specify_cli.integrations.opencode import _migrate_legacy_command_dir
46

57
from .test_integration_base_markdown import MarkdownIntegrationTests
68

79

810
class TestOpencodeIntegration(MarkdownIntegrationTests):
911
KEY = "opencode"
1012
FOLDER = ".opencode/"
11-
COMMANDS_SUBDIR = "command"
12-
REGISTRAR_DIR = ".opencode/command"
13+
COMMANDS_SUBDIR = "commands"
14+
REGISTRAR_DIR = ".opencode/commands"
1315
CONTEXT_FILE = "AGENTS.md"
1416

1517
def test_build_exec_args_uses_run_command_dispatch(self):
@@ -57,3 +59,87 @@ def test_build_exec_args_keeps_plain_prompt_dispatch(self):
5759
args = integration.build_exec_args("explain this repository", output_json=False)
5860

5961
assert args == ["opencode", "run", "explain this repository"]
62+
63+
64+
class TestOpencodeCommandMigration:
65+
"""Test legacy .opencode/command → .opencode/commands migration."""
66+
67+
def test_removes_legacy_command_dir(self, tmp_path):
68+
"""Legacy file is removed when byte-identical to counterpart."""
69+
legacy = tmp_path / ".opencode" / "command"
70+
legacy.mkdir(parents=True)
71+
(legacy / "speckit.specify.md").write_text("identical content")
72+
new_dir = tmp_path / ".opencode" / "commands"
73+
new_dir.mkdir(parents=True)
74+
(new_dir / "speckit.specify.md").write_text("identical content")
75+
76+
removed = _migrate_legacy_command_dir(tmp_path)
77+
78+
assert removed == 1
79+
assert not legacy.exists()
80+
81+
def test_preserves_user_customized_legacy_command(self, tmp_path):
82+
"""Legacy file with different content (user-customized) is preserved."""
83+
legacy = tmp_path / ".opencode" / "command"
84+
legacy.mkdir(parents=True)
85+
(legacy / "speckit.specify.md").write_text("my customization")
86+
new_dir = tmp_path / ".opencode" / "commands"
87+
new_dir.mkdir(parents=True)
88+
(new_dir / "speckit.specify.md").write_text("canonical content")
89+
90+
removed = _migrate_legacy_command_dir(tmp_path)
91+
92+
assert removed == 0 # nothing was removed or moved
93+
assert (legacy / "speckit.specify.md").exists() # user file preserved
94+
assert legacy.exists() # dir not removed (still has content)
95+
96+
def test_no_op_when_no_legacy_dir(self, tmp_path):
97+
removed = _migrate_legacy_command_dir(tmp_path)
98+
assert removed == 0
99+
100+
def test_moves_user_owned_files_to_new_dir(self, tmp_path):
101+
"""Files without a counterpart in the new dir are moved, not deleted."""
102+
legacy = tmp_path / ".opencode" / "command"
103+
legacy.mkdir(parents=True)
104+
(legacy / "my-custom-command.md").write_text("user content")
105+
new_dir = tmp_path / ".opencode" / "commands"
106+
new_dir.mkdir(parents=True)
107+
108+
removed = _migrate_legacy_command_dir(tmp_path)
109+
110+
assert removed == 1
111+
assert not (legacy / "my-custom-command.md").exists()
112+
assert (new_dir / "my-custom-command.md").read_text() == "user content"
113+
114+
def test_handles_symlink_without_following(self, tmp_path):
115+
"""A symlink at the legacy path is unlinked, not traversed via rmtree."""
116+
target = tmp_path / "real_dir"
117+
target.mkdir()
118+
(legacy_parent := tmp_path / ".opencode").mkdir(parents=True)
119+
legacy = legacy_parent / "command"
120+
legacy.symlink_to(target)
121+
122+
removed = _migrate_legacy_command_dir(tmp_path)
123+
124+
assert removed == 1
125+
assert not legacy.exists()
126+
assert target.is_dir() # target is untouched
127+
128+
def test_setup_removes_legacy_dir(self, tmp_path):
129+
"""setup() preserves user-customized files and moves files without counterparts."""
130+
legacy = tmp_path / ".opencode" / "command"
131+
legacy.mkdir(parents=True)
132+
(legacy / "speckit.specify.md").write_text("old content")
133+
(legacy / "my-custom.md").write_text("user content")
134+
135+
i = get_integration("opencode")
136+
m = IntegrationManifest("opencode", tmp_path)
137+
i.setup(tmp_path, m)
138+
139+
assert (tmp_path / ".opencode" / "commands").is_dir()
140+
# User-customized speckit.specify.md is preserved in legacy dir
141+
assert (legacy / "speckit.specify.md").exists()
142+
assert legacy.exists() # dir still exists because it has the customized file
143+
# my-custom.md has no counterpart, so it was moved to new dir
144+
assert not (legacy / "my-custom.md").exists()
145+
assert (tmp_path / ".opencode" / "commands" / "my-custom.md").read_text() == "user content"

0 commit comments

Comments
 (0)