lazyopencode 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/AGENTS.md +2 -0
  2. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/PKG-INFO +1 -1
  3. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/_version.py +2 -2
  4. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/discovery.py +84 -50
  5. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/agent.py +5 -1
  6. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/command.py +5 -1
  7. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/plugin.py +1 -1
  8. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/skill.py +2 -1
  9. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/tool.py +1 -1
  10. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/conftest.py +30 -0
  11. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/discovery/test_skills.py +60 -0
  12. lazyopencode-0.2.3/tests/integration/fixtures/agents-skill/agents-compat-skill/SKILL.md +7 -0
  13. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.github/release-drafter.yml +0 -0
  14. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.github/workflows/ci.yml +0 -0
  15. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.github/workflows/publish.yml +0 -0
  16. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.github/workflows/release-drafter.yml +0 -0
  17. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.gitignore +0 -0
  18. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.opencode/command/run-quality-gates.md +0 -0
  19. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.opencode/skill/quality-gates/SKILL.md +0 -0
  20. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/.pre-commit-config.yaml +0 -0
  21. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/LICENSE +0 -0
  22. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/README.md +0 -0
  23. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/artifacts/demo.png +0 -0
  24. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/artifacts/lazyclaude-reference.png +0 -0
  25. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/opencode.json +0 -0
  26. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/pyproject.toml +0 -0
  27. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/scripts/check_quality.sh +0 -0
  28. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/__init__.py +0 -0
  29. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/__main__.py +0 -0
  30. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/app.py +0 -0
  31. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/bindings.py +0 -0
  32. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/mixins/filtering.py +0 -0
  33. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/mixins/help.py +0 -0
  34. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/mixins/navigation.py +0 -0
  35. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/models/__init__.py +0 -0
  36. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/models/customization.py +0 -0
  37. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/__init__.py +0 -0
  38. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/claude_code/__init__.py +0 -0
  39. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/claude_code/discovery.py +0 -0
  40. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/claude_code/parsers/__init__.py +0 -0
  41. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/claude_code/parsers/agent.py +0 -0
  42. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/claude_code/parsers/command.py +0 -0
  43. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/claude_code/parsers/skill.py +0 -0
  44. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/claude_code/plugin_loader.py +0 -0
  45. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/gitignore_filter.py +0 -0
  46. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/__init__.py +0 -0
  47. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/mcp.py +0 -0
  48. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/parsers/rules.py +0 -0
  49. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/services/writer.py +0 -0
  50. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/styles/app.tcss +0 -0
  51. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/themes.py +0 -0
  52. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/__init__.py +0 -0
  53. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/app_footer.py +0 -0
  54. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/combined_panel.py +0 -0
  55. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/delete_confirm.py +0 -0
  56. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/detail_pane.py +0 -0
  57. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/filter_input.py +0 -0
  58. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/helpers/__init__.py +0 -0
  59. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/helpers/rendering.py +0 -0
  60. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/level_selector.py +0 -0
  61. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/status_panel.py +0 -0
  62. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/src/lazyopencode/widgets/type_panel.py +0 -0
  63. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/AGENTS.md +0 -0
  64. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/__init__.py +0 -0
  65. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/discovery/__init__.py +0 -0
  66. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/discovery/test_agents.py +0 -0
  67. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/discovery/test_commands.py +0 -0
  68. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/discovery/test_full_discovery.py +0 -0
  69. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/discovery/test_mcps.py +0 -0
  70. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/discovery/test_rules.py +0 -0
  71. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/agent/explorer.md +0 -0
  72. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/command/greet.md +0 -0
  73. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/mcp/project-opencode.json +0 -0
  74. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/mcp/user-opencode.json +0 -0
  75. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/memory/AGENTS.md +0 -0
  76. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/project/AGENTS.md +0 -0
  77. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/project/agent/reviewer.md +0 -0
  78. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/project/command/project-cmd.md +0 -0
  79. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/project/docs/guidelines.md +0 -0
  80. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/project/skill/project-skill/SKILL.md +0 -0
  81. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/project/skill/project-skill/src/helper.py +0 -0
  82. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/skill/task-tracker/SKILL.md +0 -0
  83. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/skill/task-tracker/reference.md +0 -0
  84. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/integration/fixtures/skill/task-tracker/scripts/run.sh +0 -0
  85. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/SPEC.md +0 -0
  86. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/__init__.py +0 -0
  87. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/conftest.py +0 -0
  88. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/.opencode/agent/reviewer.md +0 -0
  89. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/.opencode/command/verify.md +0 -0
  90. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/.opencode/plugin/metrics.ts +0 -0
  91. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/.opencode/skill/deploy-helper/SKILL.md +0 -0
  92. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/.opencode/skill/deploy-helper/scripts/deploy.sh +0 -0
  93. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/.opencode/tool/search.ts +0 -0
  94. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/AGENTS.md +0 -0
  95. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/README.md +0 -0
  96. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/docs/guidelines.md +0 -0
  97. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/opencode.json +0 -0
  98. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/complex/prompts/auditor.txt +0 -0
  99. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/file_commands/.opencode/command/deploy.md +0 -0
  100. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/file_commands/.opencode/command/greet.md +0 -0
  101. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/file_commands/README.md +0 -0
  102. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/file_references/README.md +0 -0
  103. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/file_references/opencode.json +0 -0
  104. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/file_references/prompts/agent.txt +0 -0
  105. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/file_references/templates/cmd.txt +0 -0
  106. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/inline_commands/README.md +0 -0
  107. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/inline_commands/opencode.json +0 -0
  108. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/minimal/AGENTS.md +0 -0
  109. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/minimal/README.md +0 -0
  110. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/mixed_config/.opencode/agent/file-agent.md +0 -0
  111. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/mixed_config/.opencode/command/file-cmd.md +0 -0
  112. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/mixed_config/AGENTS.md +0 -0
  113. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/mixed_config/README.md +0 -0
  114. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/mixed_config/opencode.json +0 -0
  115. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/rich_opencode_json/README.md +0 -0
  116. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/rich_opencode_json/docs/api-standards.md +0 -0
  117. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/rich_opencode_json/opencode.json +0 -0
  118. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/rich_opencode_json/prompts/security-audit.txt +0 -0
  119. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/rich_opencode_json/scripts/fake-lint.sh +0 -0
  120. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/skills_with_tree/.opencode/skill/my-skill/SKILL.md +0 -0
  121. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/skills_with_tree/.opencode/skill/my-skill/docs/guide.md +0 -0
  122. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/skills_with_tree/.opencode/skill/my-skill/scripts/run.sh +0 -0
  123. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/scenarios/skills_with_tree/README.md +0 -0
  124. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/spec/test_scenarios.py +0 -0
  125. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/test_inline.py +0 -0
  126. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/test_version.py +0 -0
  127. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/tests/unit/__init__.py +0 -0
  128. {lazyopencode-0.2.1 → lazyopencode-0.2.3}/uv.lock +0 -0
@@ -85,6 +85,8 @@ The application discovers customizations from these locations:
85
85
  | Commands | `~/.config/opencode/command/*.md` | `.opencode/command/*.md` |
86
86
  | Agents | `~/.config/opencode/agent/*.md` | `.opencode/agent/*.md` |
87
87
  | Skills | `~/.config/opencode/skill/*/SKILL.md` | `.opencode/skill/*/SKILL.md` |
88
+ | Skills | `~/.agents/skill[s]/*/SKILL.md` | `.agents/skill[s]/*/SKILL.md` |
89
+ | Skills | (via Claude Code) `~/.claude/skills/*/SKILL.md` | `.claude/skill[s]/*/SKILL.md` |
88
90
  | Rules | `~/.config/opencode/AGENTS.md` | `AGENTS.md` |
89
91
  | MCPs | `~/.config/opencode/opencode.json` | `opencode.json` |
90
92
  | Tools | `~/.config/opencode/tool/*.ts` | `.opencode/tool/*.ts` |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lazyopencode
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: A lazygit-style TUI for visualizing OpenCode customizations
5
5
  Project-URL: Homepage, https://github.com/nikiforovall/lazyopencode
6
6
  Project-URL: Repository, https://github.com/nikiforovall/lazyopencode
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.1'
32
- __version_tuple__ = version_tuple = (0, 2, 1)
31
+ __version__ = version = '0.2.3'
32
+ __version_tuple__ = version_tuple = (0, 2, 3)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -73,6 +73,19 @@ class ConfigDiscoveryService:
73
73
  """Path to project's .opencode directory."""
74
74
  return self.project_root / ".opencode"
75
75
 
76
+ def _get_path_variants(self, base_path: Path, singular: str) -> list[Path]:
77
+ """Return both singular and plural path variants that exist.
78
+
79
+ Args:
80
+ base_path: Base directory to check
81
+ singular: Singular form of folder name (e.g., 'command')
82
+
83
+ Returns:
84
+ List of existing paths (both singular and plural if they exist)
85
+ """
86
+ variants = [base_path / singular, base_path / f"{singular}s"]
87
+ return [p for p in variants if p.exists()]
88
+
76
89
  def discover_all(self) -> list[Customization]:
77
90
  """
78
91
  Discover all customizations from global and project levels.
@@ -152,16 +165,13 @@ class ConfigDiscoveryService:
152
165
  self, base_path: Path, level: ConfigLevel
153
166
  ) -> list[Customization]:
154
167
  """Discover command customizations."""
155
- commands_path = base_path / "command"
156
- if not commands_path.exists():
157
- return []
158
-
159
168
  customizations = []
160
169
  parser = self._parsers[CustomizationType.COMMAND]
161
170
 
162
- for md_file in commands_path.glob("*.md"):
163
- if parser.can_parse(md_file):
164
- customizations.append(parser.parse(md_file, level))
171
+ for commands_path in self._get_path_variants(base_path, "command"):
172
+ for md_file in commands_path.glob("*.md"):
173
+ if parser.can_parse(md_file):
174
+ customizations.append(parser.parse(md_file, level))
165
175
 
166
176
  return customizations
167
177
 
@@ -182,16 +192,13 @@ class ConfigDiscoveryService:
182
192
  self, base_path: Path, level: ConfigLevel
183
193
  ) -> list[Customization]:
184
194
  """Discover agent customizations."""
185
- agents_path = base_path / "agent"
186
- if not agents_path.exists():
187
- return []
188
-
189
195
  customizations = []
190
196
  parser = self._parsers[CustomizationType.AGENT]
191
197
 
192
- for md_file in agents_path.glob("*.md"):
193
- if parser.can_parse(md_file):
194
- customizations.append(parser.parse(md_file, level))
198
+ for agents_path in self._get_path_variants(base_path, "agent"):
199
+ for md_file in agents_path.glob("*.md"):
200
+ if parser.can_parse(md_file):
201
+ customizations.append(parser.parse(md_file, level))
195
202
 
196
203
  return customizations
197
204
 
@@ -211,39 +218,72 @@ class ConfigDiscoveryService:
211
218
  def _discover_skills(
212
219
  self, base_path: Path, level: ConfigLevel
213
220
  ) -> list[Customization]:
214
- """Discover skill customizations from .opencode/skill/."""
221
+ """Discover skill customizations from .opencode/skill/ or skills/.
222
+
223
+ Also discovers from agent-compatible paths (.agents/skill[s]/)
224
+ and Claude-compatible paths (.claude/skill[s]/) per OpenCode docs:
225
+ https://opencode.ai/docs/skills/#place-files
226
+ """
215
227
  customizations = []
228
+ parser = self._parsers[CustomizationType.SKILL]
216
229
 
217
- # Discover from standard OpenCode path
218
- skills_path = base_path / "skill"
219
- customizations.extend(self._discover_skills_from_path(skills_path, level))
230
+ # Check both singular and plural variants
231
+ for skills_path in self._get_path_variants(base_path, "skill"):
232
+ for skill_dir in skills_path.iterdir():
233
+ if skill_dir.is_dir():
234
+ skill_file = skill_dir / "SKILL.md"
235
+ if parser.can_parse(skill_file):
236
+ customizations.append(parser.parse(skill_file, level))
220
237
 
221
238
  # Also discover from Claude-compatible path at project level
222
239
  if level == ConfigLevel.PROJECT:
223
- claude_skills_path = self.project_root / ".claude" / "skills"
224
- customizations.extend(
225
- self._discover_skills_from_path(claude_skills_path, level)
226
- )
240
+ for claude_skills_path in self._get_path_variants(
241
+ self.project_root / ".claude", "skill"
242
+ ):
243
+ for skill_dir in claude_skills_path.iterdir():
244
+ if skill_dir.is_dir():
245
+ skill_file = skill_dir / "SKILL.md"
246
+ if parser.can_parse(skill_file):
247
+ customizations.append(parser.parse(skill_file, level))
248
+
249
+ # Discover from agent-compatible paths (.agents/skill[s]/)
250
+ self._discover_skills_from_compat_roots(
251
+ customizations, parser, level, ".agents"
252
+ )
227
253
 
228
254
  return customizations
229
255
 
230
- def _discover_skills_from_path(
231
- self, skills_path: Path, level: ConfigLevel
232
- ) -> list[Customization]:
233
- """Discover skills from a specific directory path."""
234
- if not skills_path.exists():
235
- return []
256
+ def _discover_skills_from_compat_roots(
257
+ self,
258
+ customizations: list[Customization],
259
+ parser: object,
260
+ level: ConfigLevel,
261
+ compat_dir: str,
262
+ ) -> None:
263
+ """Discover skills from a compatibility root directory.
236
264
 
237
- customizations = []
238
- parser = self._parsers[CustomizationType.SKILL]
265
+ At PROJECT level: scans <project_root>/<compat_dir>/skill[s]/*/SKILL.md
266
+ At GLOBAL level: scans ~/<compat_dir>/skill[s]/*/SKILL.md
239
267
 
240
- for skill_dir in skills_path.iterdir():
241
- if skill_dir.is_dir():
242
- skill_file = skill_dir / "SKILL.md"
243
- if parser.can_parse(skill_file):
244
- customizations.append(parser.parse(skill_file, level))
268
+ Args:
269
+ customizations: List to append discovered skills to
270
+ parser: Skill parser instance
271
+ level: Configuration level (GLOBAL or PROJECT)
272
+ compat_dir: Compatibility directory name (e.g., ".agents")
273
+ """
274
+ if level == ConfigLevel.PROJECT:
275
+ compat_base = self.project_root / compat_dir
276
+ else:
277
+ compat_base = Path.home() / compat_dir
245
278
 
246
- return customizations
279
+ for skills_path in self._get_path_variants(compat_base, "skill"):
280
+ for skill_dir in skills_path.iterdir():
281
+ if skill_dir.is_dir():
282
+ skill_file = skill_dir / "SKILL.md"
283
+ if parser.can_parse(skill_file): # type: ignore[attr-defined]
284
+ customizations.append(
285
+ parser.parse(skill_file, level) # type: ignore[attr-defined]
286
+ )
247
287
 
248
288
  def _discover_rules(self, level: ConfigLevel) -> list[Customization]:
249
289
  """Discover AGENTS.md rules files."""
@@ -278,16 +318,13 @@ class ConfigDiscoveryService:
278
318
  self, base_path: Path, level: ConfigLevel
279
319
  ) -> list[Customization]:
280
320
  """Discover tool customizations from .opencode/tool/."""
281
- tools_path = base_path / "tool"
282
- if not tools_path.exists():
283
- return []
284
-
285
321
  customizations = []
286
322
  parser = self._parsers[CustomizationType.TOOL]
287
323
 
288
- for tool_file in tools_path.iterdir():
289
- if tool_file.is_file() and parser.can_parse(tool_file):
290
- customizations.append(parser.parse(tool_file, level))
324
+ for tools_path in self._get_path_variants(base_path, "tool"):
325
+ for tool_file in tools_path.iterdir():
326
+ if tool_file.is_file() and parser.can_parse(tool_file):
327
+ customizations.append(parser.parse(tool_file, level))
291
328
 
292
329
  return customizations
293
330
 
@@ -295,16 +332,13 @@ class ConfigDiscoveryService:
295
332
  self, base_path: Path, level: ConfigLevel
296
333
  ) -> list[Customization]:
297
334
  """Discover plugin customizations from .opencode/plugin/."""
298
- plugins_path = base_path / "plugin"
299
- if not plugins_path.exists():
300
- return []
301
-
302
335
  customizations = []
303
336
  parser = self._parsers[CustomizationType.PLUGIN]
304
337
 
305
- for plugin_file in plugins_path.iterdir():
306
- if plugin_file.is_file() and parser.can_parse(plugin_file):
307
- customizations.append(parser.parse(plugin_file, level))
338
+ for plugins_path in self._get_path_variants(base_path, "plugin"):
339
+ for plugin_file in plugins_path.iterdir():
340
+ if plugin_file.is_file() and parser.can_parse(plugin_file):
341
+ customizations.append(parser.parse(plugin_file, level))
308
342
 
309
343
  return customizations
310
344
 
@@ -23,7 +23,11 @@ class AgentParser(ICustomizationParser):
23
23
 
24
24
  def can_parse(self, path: Path) -> bool:
25
25
  """Check if path is an agent markdown file."""
26
- return path.is_file() and path.suffix == ".md" and path.parent.name == "agent"
26
+ return (
27
+ path.is_file()
28
+ and path.suffix == ".md"
29
+ and path.parent.name in ("agent", "agents")
30
+ )
27
31
 
28
32
  def parse(self, path: Path, level: ConfigLevel) -> Customization:
29
33
  """Parse agent file."""
@@ -23,7 +23,11 @@ class CommandParser(ICustomizationParser):
23
23
 
24
24
  def can_parse(self, path: Path) -> bool:
25
25
  """Check if path is a command markdown file."""
26
- return path.is_file() and path.suffix == ".md" and path.parent.name == "command"
26
+ return (
27
+ path.is_file()
28
+ and path.suffix == ".md"
29
+ and path.parent.name in ("command", "commands")
30
+ )
27
31
 
28
32
  def parse(self, path: Path, level: ConfigLevel) -> Customization:
29
33
  """Parse command file."""
@@ -32,7 +32,7 @@ class PluginParser(ICustomizationParser):
32
32
  return (
33
33
  path.is_file()
34
34
  and path.suffix in self.VALID_EXTENSIONS
35
- and path.parent.name == "plugin"
35
+ and path.parent.name in ("plugin", "plugins")
36
36
  )
37
37
 
38
38
  def parse(self, path: Path, level: ConfigLevel) -> Customization:
@@ -92,10 +92,11 @@ class SkillParser(ICustomizationParser):
92
92
 
93
93
  def can_parse(self, path: Path) -> bool:
94
94
  """Check if path is a SKILL.md file in a skill directory."""
95
+ parent_dir_name = path.parent.parent.name
95
96
  return (
96
97
  path.is_file()
97
98
  and path.name == "SKILL.md"
98
- and path.parent.parent.name == "skill"
99
+ and parent_dir_name in ("skill", "skills")
99
100
  )
100
101
 
101
102
  def parse(self, path: Path, level: ConfigLevel) -> Customization:
@@ -24,7 +24,7 @@ class ToolParser(ICustomizationParser):
24
24
  return (
25
25
  path.is_file()
26
26
  and path.suffix in self.VALID_EXTENSIONS
27
- and path.parent.name == "tool"
27
+ and path.parent.name in ("tool", "tools")
28
28
  )
29
29
 
30
30
  def parse(self, path: Path, level: ConfigLevel) -> Customization:
@@ -146,6 +146,36 @@ def project_config_path(fake_project_root: Path, fs: FakeFilesystem) -> Path:
146
146
  return project_opencode
147
147
 
148
148
 
149
+ @pytest.fixture
150
+ def agents_project_skills(fake_project_root: Path, fs: FakeFilesystem) -> Path:
151
+ """Create .agents/skills/ directory with fixture skill at project level."""
152
+ agents_skills = fake_project_root / ".agents" / "skills"
153
+ fs.create_dir(agents_skills)
154
+
155
+ fs.add_real_directory(
156
+ FIXTURES_DIR / "agents-skill" / "agents-compat-skill",
157
+ target_path=agents_skills / "agents-compat-skill",
158
+ read_only=False,
159
+ )
160
+
161
+ return agents_skills
162
+
163
+
164
+ @pytest.fixture
165
+ def agents_global_skills(fake_home: Path, fs: FakeFilesystem) -> Path:
166
+ """Create ~/.agents/skills/ directory with fixture skill at global level."""
167
+ agents_skills = fake_home / ".agents" / "skills"
168
+ fs.create_dir(agents_skills)
169
+
170
+ fs.add_real_directory(
171
+ FIXTURES_DIR / "agents-skill" / "agents-compat-skill",
172
+ target_path=agents_skills / "agents-compat-skill",
173
+ read_only=False,
174
+ )
175
+
176
+ return agents_skills
177
+
178
+
149
179
  @pytest.fixture
150
180
  def full_user_config(
151
181
  user_config_path: Path,
@@ -146,6 +146,66 @@ class TestSkillDiscovery:
146
146
  assert "reference.md" in file_names
147
147
  assert "scripts" in file_names
148
148
 
149
+ def test_discovers_project_agents_compat_skills(
150
+ self,
151
+ agents_project_skills: Path, # noqa: ARG002
152
+ fake_project_root: Path,
153
+ fake_home: Path,
154
+ ) -> None:
155
+ """Test discovering skills from .agents/skills/ at project level."""
156
+ service = ConfigDiscoveryService(
157
+ project_root=fake_project_root,
158
+ global_config_path=fake_home / ".config" / "opencode",
159
+ )
160
+
161
+ skills = service.by_type(CustomizationType.SKILL)
162
+ project_skills = [s for s in skills if s.level == ConfigLevel.PROJECT]
163
+
164
+ assert len(project_skills) == 1
165
+ assert project_skills[0].name == "agents-compat-skill"
166
+ assert project_skills[0].description == "Skill discovered from .agents/ path"
167
+
168
+ def test_discovers_global_agents_compat_skills(
169
+ self,
170
+ agents_global_skills: Path, # noqa: ARG002
171
+ fake_project_root: Path,
172
+ fake_home: Path,
173
+ ) -> None:
174
+ """Test discovering skills from ~/.agents/skills/ at global level."""
175
+ service = ConfigDiscoveryService(
176
+ project_root=fake_project_root,
177
+ global_config_path=fake_home / ".config" / "opencode",
178
+ )
179
+
180
+ skills = service.by_type(CustomizationType.SKILL)
181
+ global_skills = [s for s in skills if s.level == ConfigLevel.GLOBAL]
182
+
183
+ assert len(global_skills) == 1
184
+ assert global_skills[0].name == "agents-compat-skill"
185
+
186
+ def test_agents_compat_skills_deduplicated(
187
+ self,
188
+ project_config_path: Path, # noqa: ARG002
189
+ agents_project_skills: Path, # noqa: ARG002
190
+ fake_project_root: Path,
191
+ fake_home: Path,
192
+ ) -> None:
193
+ """Test that same skill in .opencode/ and .agents/ is not duplicated."""
194
+ # The agents-compat-skill is only in .agents/, project-skill is only in .opencode/
195
+ # So total project skills should be 2, not 3
196
+ service = ConfigDiscoveryService(
197
+ project_root=fake_project_root,
198
+ global_config_path=fake_home / ".config" / "opencode",
199
+ )
200
+
201
+ skills = service.by_type(CustomizationType.SKILL)
202
+ project_skills = [s for s in skills if s.level == ConfigLevel.PROJECT]
203
+
204
+ names = [s.name for s in project_skills]
205
+ assert "project-skill" in names
206
+ assert "agents-compat-skill" in names
207
+ assert len(project_skills) == 2
208
+
149
209
  def test_skill_nested_directory_structure(
150
210
  self,
151
211
  project_config_path: Path, # noqa: ARG002
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: agents-compat-skill
3
+ description: Skill discovered from .agents/ path
4
+ ---
5
+ # Agents-Compatible Skill
6
+
7
+ This skill is discovered from the .agents/skills/ directory.
File without changes
File without changes
File without changes
File without changes
File without changes