lazyopencode 0.1.0__py3-none-any.whl

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 (37) hide show
  1. lazyopencode/__init__.py +48 -0
  2. lazyopencode/__main__.py +6 -0
  3. lazyopencode/_version.py +34 -0
  4. lazyopencode/app.py +310 -0
  5. lazyopencode/bindings.py +27 -0
  6. lazyopencode/mixins/filtering.py +33 -0
  7. lazyopencode/mixins/help.py +74 -0
  8. lazyopencode/mixins/navigation.py +184 -0
  9. lazyopencode/models/__init__.py +17 -0
  10. lazyopencode/models/customization.py +120 -0
  11. lazyopencode/services/__init__.py +7 -0
  12. lazyopencode/services/discovery.py +350 -0
  13. lazyopencode/services/gitignore_filter.py +123 -0
  14. lazyopencode/services/parsers/__init__.py +152 -0
  15. lazyopencode/services/parsers/agent.py +93 -0
  16. lazyopencode/services/parsers/command.py +94 -0
  17. lazyopencode/services/parsers/mcp.py +67 -0
  18. lazyopencode/services/parsers/plugin.py +127 -0
  19. lazyopencode/services/parsers/rules.py +65 -0
  20. lazyopencode/services/parsers/skill.py +138 -0
  21. lazyopencode/services/parsers/tool.py +67 -0
  22. lazyopencode/styles/app.tcss +173 -0
  23. lazyopencode/themes.py +30 -0
  24. lazyopencode/widgets/__init__.py +17 -0
  25. lazyopencode/widgets/app_footer.py +71 -0
  26. lazyopencode/widgets/combined_panel.py +345 -0
  27. lazyopencode/widgets/detail_pane.py +338 -0
  28. lazyopencode/widgets/filter_input.py +88 -0
  29. lazyopencode/widgets/helpers/__init__.py +5 -0
  30. lazyopencode/widgets/helpers/rendering.py +17 -0
  31. lazyopencode/widgets/status_panel.py +70 -0
  32. lazyopencode/widgets/type_panel.py +501 -0
  33. lazyopencode-0.1.0.dist-info/METADATA +118 -0
  34. lazyopencode-0.1.0.dist-info/RECORD +37 -0
  35. lazyopencode-0.1.0.dist-info/WHEEL +4 -0
  36. lazyopencode-0.1.0.dist-info/entry_points.txt +2 -0
  37. lazyopencode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,184 @@
1
+ """Navigation mixin for handling panel focus and keyboard shortcuts."""
2
+
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ if TYPE_CHECKING:
6
+ from lazyopencode.app import LazyOpenCode
7
+ from lazyopencode.widgets.combined_panel import CombinedPanel
8
+ from lazyopencode.widgets.type_panel import TypePanel
9
+
10
+
11
+ class NavigationMixin:
12
+ """Mixin for application navigation."""
13
+
14
+ def _get_focused_panel(self) -> "TypePanel | CombinedPanel | None":
15
+ """Get the currently focused panel (TypePanel or CombinedPanel)."""
16
+ app = cast("LazyOpenCode", self)
17
+ for panel in app._panels:
18
+ if panel.has_focus:
19
+ return panel
20
+ return None
21
+
22
+ def action_focus_next_panel(self) -> None:
23
+ """Focus the next panel. Ensures combined panel starts at first tab."""
24
+ app = cast("LazyOpenCode", self)
25
+ all_panels = app._panels
26
+
27
+ current_idx = -1
28
+ for i, panel in enumerate(all_panels):
29
+ if panel.has_focus:
30
+ current_idx = i
31
+ break
32
+
33
+ # If main pane is focused, current_idx stays -1, next will be 0
34
+ if current_idx == -1 and app._main_pane and app._main_pane.has_focus:
35
+ # If main pane has focus, go back to last focused panel or first
36
+ if app._last_focused_panel:
37
+ app._last_focused_panel.focus()
38
+ return
39
+ else:
40
+ current_idx = -1 # Will become 0
41
+
42
+ next_idx = (current_idx + 1) % len(all_panels)
43
+ next_panel = all_panels[next_idx]
44
+ if next_panel:
45
+ # If entering combined panel from above, start at first tab
46
+ from lazyopencode.widgets.combined_panel import CombinedPanel
47
+
48
+ if isinstance(next_panel, CombinedPanel):
49
+ next_panel.switch_to_tab(0)
50
+ next_panel.focus()
51
+
52
+ def action_focus_previous_panel(self) -> None:
53
+ """Focus the previous panel. Ensures combined panel starts at last tab."""
54
+ app = cast("LazyOpenCode", self)
55
+ all_panels = app._panels
56
+
57
+ current_idx = 0
58
+ for i, panel in enumerate(all_panels):
59
+ if panel.has_focus:
60
+ current_idx = i
61
+ break
62
+
63
+ # If main pane is focused
64
+ if app._main_pane and app._main_pane.has_focus:
65
+ # If main pane has focus, go back to last focused panel or last panel
66
+ if app._last_focused_panel:
67
+ app._last_focused_panel.focus()
68
+ return
69
+ else:
70
+ current_idx = 0 # Will become -1 -> last
71
+
72
+ prev_idx = (current_idx - 1) % len(all_panels)
73
+ prev_panel = all_panels[prev_idx]
74
+ if prev_panel:
75
+ # If entering combined panel from below (or wrap around), start at last tab
76
+ from lazyopencode.widgets.combined_panel import CombinedPanel
77
+
78
+ if isinstance(prev_panel, CombinedPanel):
79
+ prev_panel.switch_to_tab(len(prev_panel.tabs) - 1)
80
+ prev_panel.focus()
81
+
82
+ def action_focus_panel_1(self) -> None:
83
+ """Focus Commands tab in first combined panel."""
84
+ app = cast("LazyOpenCode", self)
85
+ from lazyopencode.widgets.combined_panel import CombinedPanel
86
+
87
+ if len(app._panels) > 0:
88
+ panel = app._panels[0]
89
+ if isinstance(panel, CombinedPanel):
90
+ panel.switch_to_tab(0) # Commands tab
91
+ panel.focus()
92
+
93
+ def action_focus_panel_2(self) -> None:
94
+ """Focus Agents tab in first combined panel."""
95
+ app = cast("LazyOpenCode", self)
96
+ from lazyopencode.widgets.combined_panel import CombinedPanel
97
+
98
+ if len(app._panels) > 0:
99
+ panel = app._panels[0]
100
+ if isinstance(panel, CombinedPanel):
101
+ panel.switch_to_tab(1) # Agents tab
102
+ panel.focus()
103
+
104
+ def action_focus_panel_3(self) -> None:
105
+ """Focus Skills panel."""
106
+ app = cast("LazyOpenCode", self)
107
+ if len(app._panels) > 1:
108
+ app._panels[1].focus()
109
+
110
+ def action_focus_panel_4(self) -> None:
111
+ """Focus Agent Memory (Rules) panel."""
112
+ app = cast("LazyOpenCode", self)
113
+ if len(app._panels) > 2:
114
+ app._panels[2].focus()
115
+
116
+ def action_focus_panel_5(self) -> None:
117
+ """Focus MCPs tab in second combined panel."""
118
+ app = cast("LazyOpenCode", self)
119
+ from lazyopencode.widgets.combined_panel import CombinedPanel
120
+
121
+ if len(app._panels) > 3:
122
+ panel = app._panels[3]
123
+ if isinstance(panel, CombinedPanel):
124
+ panel.switch_to_tab(0) # MCPs tab
125
+ panel.focus()
126
+
127
+ def action_focus_panel_6(self) -> None:
128
+ """Focus Tools tab in second combined panel."""
129
+ app = cast("LazyOpenCode", self)
130
+ from lazyopencode.widgets.combined_panel import CombinedPanel
131
+
132
+ if len(app._panels) > 3:
133
+ panel = app._panels[3]
134
+ if isinstance(panel, CombinedPanel):
135
+ panel.switch_to_tab(1) # Tools tab
136
+ panel.focus()
137
+
138
+ def action_focus_panel_7(self) -> None:
139
+ """Focus Plugins tab in second combined panel."""
140
+ app = cast("LazyOpenCode", self)
141
+ from lazyopencode.widgets.combined_panel import CombinedPanel
142
+
143
+ if len(app._panels) > 3:
144
+ panel = app._panels[3]
145
+ if isinstance(panel, CombinedPanel):
146
+ panel.switch_to_tab(2) # Plugins tab
147
+ panel.focus()
148
+
149
+ def action_focus_main_pane(self) -> None:
150
+ """Focus the main pane."""
151
+ app = cast("LazyOpenCode", self)
152
+ if app._main_pane:
153
+ app._main_pane.focus()
154
+
155
+ def action_prev_view(self) -> None:
156
+ """Switch view based on focused widget."""
157
+ app = cast("LazyOpenCode", self)
158
+ from lazyopencode.widgets.combined_panel import CombinedPanel
159
+
160
+ focused_panel = self._get_focused_panel()
161
+ if focused_panel and isinstance(focused_panel, CombinedPanel):
162
+ focused_panel.action_prev_tab()
163
+ elif app._main_pane:
164
+ app._main_pane.action_prev_view()
165
+
166
+ def action_next_view(self) -> None:
167
+ """Switch view based on focused widget."""
168
+ app = cast("LazyOpenCode", self)
169
+ from lazyopencode.widgets.combined_panel import CombinedPanel
170
+
171
+ focused_panel = self._get_focused_panel()
172
+ if focused_panel and isinstance(focused_panel, CombinedPanel):
173
+ focused_panel.action_next_tab()
174
+ elif app._main_pane:
175
+ app._main_pane.action_next_view()
176
+
177
+ def action_go_back_from_main_pane(self) -> None:
178
+ """Return focus to the panel we drilled down from."""
179
+ app = cast("LazyOpenCode", self)
180
+ if app._last_focused_panel:
181
+ app._last_focused_panel.focus()
182
+ elif app._panels:
183
+ # Fallback to first panel
184
+ app._panels[0].focus()
@@ -0,0 +1,17 @@
1
+ """Data models for LazyOpenCode."""
2
+
3
+ from lazyopencode.models.customization import (
4
+ ConfigLevel,
5
+ Customization,
6
+ CustomizationType,
7
+ SkillFile,
8
+ SkillMetadata,
9
+ )
10
+
11
+ __all__ = [
12
+ "ConfigLevel",
13
+ "Customization",
14
+ "CustomizationType",
15
+ "SkillFile",
16
+ "SkillMetadata",
17
+ ]
@@ -0,0 +1,120 @@
1
+ """Core data models for LazyOpenCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class SkillFile:
13
+ """A file or directory within a skill folder."""
14
+
15
+ name: str
16
+ path: Path
17
+ content: str | None = None
18
+ is_directory: bool = False
19
+ children: list[SkillFile] = field(default_factory=list)
20
+
21
+
22
+ @dataclass
23
+ class SkillMetadata:
24
+ """Metadata specific to skills."""
25
+
26
+ files: list[SkillFile] = field(default_factory=list)
27
+
28
+
29
+ class ConfigLevel(Enum):
30
+ """Configuration level for customizations."""
31
+
32
+ GLOBAL = "global"
33
+ PROJECT = "project"
34
+
35
+ @property
36
+ def label(self) -> str:
37
+ """Human-readable label."""
38
+ return self.value.capitalize()
39
+
40
+ @property
41
+ def icon(self) -> str:
42
+ """Icon for display."""
43
+ return "G" if self == ConfigLevel.GLOBAL else "P"
44
+
45
+
46
+ class CustomizationType(Enum):
47
+ """Type of OpenCode customization."""
48
+
49
+ COMMAND = "command"
50
+ AGENT = "agent"
51
+ SKILL = "skill"
52
+ RULES = "rules"
53
+ MCP = "mcp"
54
+ TOOL = "tool"
55
+ PLUGIN = "plugin"
56
+
57
+ @property
58
+ def label(self) -> str:
59
+ """Human-readable label."""
60
+ return self.value.capitalize()
61
+
62
+ @property
63
+ def plural_label(self) -> str:
64
+ """Plural form for panel titles."""
65
+ if self == CustomizationType.RULES:
66
+ return "Rules"
67
+ return f"{self.label}s"
68
+
69
+ @property
70
+ def panel_key(self) -> str:
71
+ """Keyboard shortcut for this type's panel."""
72
+ mapping = {
73
+ CustomizationType.COMMAND: "1",
74
+ CustomizationType.AGENT: "2",
75
+ CustomizationType.SKILL: "3",
76
+ CustomizationType.RULES: "4",
77
+ CustomizationType.MCP: "5",
78
+ CustomizationType.TOOL: "6",
79
+ CustomizationType.PLUGIN: "7",
80
+ }
81
+ return mapping[self]
82
+
83
+
84
+ @dataclass
85
+ class Customization:
86
+ """Represents a single OpenCode customization."""
87
+
88
+ name: str
89
+ type: CustomizationType
90
+ level: ConfigLevel
91
+ path: Path
92
+ description: str | None = None
93
+ content: str | None = None
94
+ metadata: dict[str, Any] = field(default_factory=dict)
95
+ error: str | None = None
96
+
97
+ @property
98
+ def type_label(self) -> str:
99
+ """Type label for display."""
100
+ return self.type.label
101
+
102
+ @property
103
+ def level_label(self) -> str:
104
+ """Level label for display."""
105
+ return self.level.label
106
+
107
+ @property
108
+ def level_icon(self) -> str:
109
+ """Level icon for compact display."""
110
+ return self.level.icon
111
+
112
+ @property
113
+ def display_name(self) -> str:
114
+ """Formatted name for display with level indicator."""
115
+ return f"[{self.level_icon}] {self.name}"
116
+
117
+ @property
118
+ def has_error(self) -> bool:
119
+ """Check if this customization has a parse error."""
120
+ return self.error is not None
@@ -0,0 +1,7 @@
1
+ """Services for LazyOpenCode."""
2
+
3
+ from lazyopencode.services.discovery import ConfigDiscoveryService
4
+
5
+ __all__ = [
6
+ "ConfigDiscoveryService",
7
+ ]
@@ -0,0 +1,350 @@
1
+ """Configuration discovery service."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from lazyopencode.models.customization import (
7
+ ConfigLevel,
8
+ Customization,
9
+ CustomizationType,
10
+ )
11
+ from lazyopencode.services.gitignore_filter import GitignoreFilter
12
+ from lazyopencode.services.parsers import read_file_safe, strip_jsonc_comments
13
+ from lazyopencode.services.parsers.agent import AgentParser
14
+ from lazyopencode.services.parsers.command import CommandParser
15
+ from lazyopencode.services.parsers.mcp import MCPParser
16
+ from lazyopencode.services.parsers.plugin import PluginParser
17
+ from lazyopencode.services.parsers.rules import RulesParser
18
+ from lazyopencode.services.parsers.skill import SkillParser
19
+ from lazyopencode.services.parsers.tool import ToolParser
20
+
21
+
22
+ class ConfigDiscoveryService:
23
+ """Discovers OpenCode customizations from filesystem."""
24
+
25
+ def __init__(
26
+ self,
27
+ project_root: Path | None = None,
28
+ global_config_path: Path | None = None,
29
+ ) -> None:
30
+ """
31
+ Initialize discovery service.
32
+
33
+ Args:
34
+ project_root: Project root directory (defaults to cwd)
35
+ global_config_path: Global config path (defaults to ~/.config/opencode)
36
+ """
37
+ self.project_root = project_root or Path.cwd()
38
+ self.global_config_path = global_config_path or (
39
+ Path.home() / ".config" / "opencode"
40
+ )
41
+ self._gitignore_filter = GitignoreFilter(project_root=self.project_root)
42
+ self._parsers = {
43
+ CustomizationType.COMMAND: CommandParser(),
44
+ CustomizationType.AGENT: AgentParser(),
45
+ CustomizationType.SKILL: SkillParser(
46
+ gitignore_filter=self._gitignore_filter
47
+ ),
48
+ CustomizationType.RULES: RulesParser(),
49
+ CustomizationType.MCP: MCPParser(),
50
+ CustomizationType.TOOL: ToolParser(),
51
+ CustomizationType.PLUGIN: PluginParser(),
52
+ }
53
+ self._cache: list[Customization] | None = None
54
+
55
+ @property
56
+ def project_config_path(self) -> Path:
57
+ """Path to project's .opencode directory."""
58
+ return self.project_root / ".opencode"
59
+
60
+ def discover_all(self) -> list[Customization]:
61
+ """
62
+ Discover all customizations from global and project levels.
63
+
64
+ Returns:
65
+ List of all discovered customizations (de-duplicated by path)
66
+ """
67
+ if self._cache is not None:
68
+ return self._cache
69
+
70
+ customizations: list[Customization] = []
71
+
72
+ # Discover from global config
73
+ customizations.extend(self._discover_level(ConfigLevel.GLOBAL))
74
+
75
+ # Discover from project config
76
+ customizations.extend(self._discover_level(ConfigLevel.PROJECT))
77
+
78
+ # De-duplicate by (type, name, level, resolved_path) tuple
79
+ # This avoids removing items that share a source file (like multiple
80
+ # inline definitions from the same opencode.json)
81
+ seen: set[tuple] = set()
82
+ unique: list[Customization] = []
83
+ for c in customizations:
84
+ # Create a unique key for this customization
85
+ # For file-based items, use the resolved path
86
+ # For inline items, use (type, name, level) to avoid duplicates
87
+ key = (c.type, c.name, c.level, str(c.path.resolve()))
88
+ if key not in seen:
89
+ seen.add(key)
90
+ unique.append(c)
91
+
92
+ self._cache = unique
93
+ return unique
94
+
95
+ def _discover_level(self, level: ConfigLevel) -> list[Customization]:
96
+ """Discover customizations at a specific level."""
97
+ base_path = (
98
+ self.global_config_path
99
+ if level == ConfigLevel.GLOBAL
100
+ else self.project_config_path
101
+ )
102
+ customizations: list[Customization] = []
103
+
104
+ # Discover commands
105
+ customizations.extend(self._discover_commands(base_path, level))
106
+ customizations.extend(self._discover_inline_commands(level))
107
+
108
+ # Discover agents
109
+ customizations.extend(self._discover_agents(base_path, level))
110
+ customizations.extend(self._discover_inline_agents(level))
111
+
112
+ # Discover skills
113
+ customizations.extend(self._discover_skills(base_path, level))
114
+
115
+ # Discover rules (AGENTS.md)
116
+ customizations.extend(self._discover_rules(level))
117
+
118
+ # Discover instruction files from opencode.json
119
+ customizations.extend(self._discover_instructions(level))
120
+
121
+ # Discover MCPs from opencode.json
122
+ customizations.extend(self._discover_mcps(level))
123
+
124
+ # Discover tools
125
+ customizations.extend(self._discover_tools(base_path, level))
126
+
127
+ # Discover plugins
128
+ customizations.extend(self._discover_plugins(base_path, level))
129
+
130
+ return customizations
131
+
132
+ def _discover_commands(
133
+ self, base_path: Path, level: ConfigLevel
134
+ ) -> list[Customization]:
135
+ """Discover command customizations."""
136
+ commands_path = base_path / "command"
137
+ if not commands_path.exists():
138
+ return []
139
+
140
+ customizations = []
141
+ parser = self._parsers[CustomizationType.COMMAND]
142
+
143
+ for md_file in commands_path.glob("*.md"):
144
+ if parser.can_parse(md_file):
145
+ customizations.append(parser.parse(md_file, level))
146
+
147
+ return customizations
148
+
149
+ def _discover_inline_commands(self, level: ConfigLevel) -> list[Customization]:
150
+ """Discover inline commands from opencode.json."""
151
+ if level == ConfigLevel.GLOBAL:
152
+ config_path = self.global_config_path / "opencode.json"
153
+ else:
154
+ config_path = self.project_root / "opencode.json"
155
+
156
+ parser = self._parsers[CustomizationType.COMMAND]
157
+ if isinstance(parser, CommandParser) and config_path.exists():
158
+ return parser.parse_inline_commands(config_path, level)
159
+
160
+ return []
161
+
162
+ def _discover_agents(
163
+ self, base_path: Path, level: ConfigLevel
164
+ ) -> list[Customization]:
165
+ """Discover agent customizations."""
166
+ agents_path = base_path / "agent"
167
+ if not agents_path.exists():
168
+ return []
169
+
170
+ customizations = []
171
+ parser = self._parsers[CustomizationType.AGENT]
172
+
173
+ for md_file in agents_path.glob("*.md"):
174
+ if parser.can_parse(md_file):
175
+ customizations.append(parser.parse(md_file, level))
176
+
177
+ return customizations
178
+
179
+ def _discover_inline_agents(self, level: ConfigLevel) -> list[Customization]:
180
+ """Discover inline agents from opencode.json."""
181
+ if level == ConfigLevel.GLOBAL:
182
+ config_path = self.global_config_path / "opencode.json"
183
+ else:
184
+ config_path = self.project_root / "opencode.json"
185
+
186
+ parser = self._parsers[CustomizationType.AGENT]
187
+ if isinstance(parser, AgentParser) and config_path.exists():
188
+ return parser.parse_inline_agents(config_path, level)
189
+
190
+ return []
191
+
192
+ def _discover_skills(
193
+ self, base_path: Path, level: ConfigLevel
194
+ ) -> list[Customization]:
195
+ """Discover skill customizations from .opencode/skill/."""
196
+ customizations = []
197
+
198
+ # Discover from standard OpenCode path
199
+ skills_path = base_path / "skill"
200
+ customizations.extend(self._discover_skills_from_path(skills_path, level))
201
+
202
+ # Also discover from Claude-compatible path at project level
203
+ if level == ConfigLevel.PROJECT:
204
+ claude_skills_path = self.project_root / ".claude" / "skills"
205
+ customizations.extend(
206
+ self._discover_skills_from_path(claude_skills_path, level)
207
+ )
208
+
209
+ return customizations
210
+
211
+ def _discover_skills_from_path(
212
+ self, skills_path: Path, level: ConfigLevel
213
+ ) -> list[Customization]:
214
+ """Discover skills from a specific directory path."""
215
+ if not skills_path.exists():
216
+ return []
217
+
218
+ customizations = []
219
+ parser = self._parsers[CustomizationType.SKILL]
220
+
221
+ for skill_dir in skills_path.iterdir():
222
+ if skill_dir.is_dir():
223
+ skill_file = skill_dir / "SKILL.md"
224
+ if parser.can_parse(skill_file):
225
+ customizations.append(parser.parse(skill_file, level))
226
+
227
+ return customizations
228
+
229
+ def _discover_rules(self, level: ConfigLevel) -> list[Customization]:
230
+ """Discover AGENTS.md rules files."""
231
+ customizations = []
232
+ parser = self._parsers[CustomizationType.RULES]
233
+
234
+ if level == ConfigLevel.GLOBAL:
235
+ agents_md = self.global_config_path / "AGENTS.md"
236
+ else:
237
+ agents_md = self.project_root / "AGENTS.md"
238
+
239
+ if parser.can_parse(agents_md):
240
+ customizations.append(parser.parse(agents_md, level))
241
+
242
+ return customizations
243
+
244
+ def _discover_mcps(self, level: ConfigLevel) -> list[Customization]:
245
+ """Discover MCP configurations from opencode.json."""
246
+ if level == ConfigLevel.GLOBAL:
247
+ config_path = self.global_config_path / "opencode.json"
248
+ else:
249
+ config_path = self.project_root / "opencode.json"
250
+
251
+ parser = self._parsers[CustomizationType.MCP]
252
+ # Cast to MCPParser to access parse_mcps
253
+ if isinstance(parser, MCPParser) and parser.can_parse(config_path):
254
+ return parser.parse_mcps(config_path, level)
255
+
256
+ return []
257
+
258
+ def _discover_tools(
259
+ self, base_path: Path, level: ConfigLevel
260
+ ) -> list[Customization]:
261
+ """Discover tool customizations from .opencode/tool/."""
262
+ tools_path = base_path / "tool"
263
+ if not tools_path.exists():
264
+ return []
265
+
266
+ customizations = []
267
+ parser = self._parsers[CustomizationType.TOOL]
268
+
269
+ for tool_file in tools_path.iterdir():
270
+ if tool_file.is_file() and parser.can_parse(tool_file):
271
+ customizations.append(parser.parse(tool_file, level))
272
+
273
+ return customizations
274
+
275
+ def _discover_plugins(
276
+ self, base_path: Path, level: ConfigLevel
277
+ ) -> list[Customization]:
278
+ """Discover plugin customizations from .opencode/plugin/."""
279
+ plugins_path = base_path / "plugin"
280
+ if not plugins_path.exists():
281
+ return []
282
+
283
+ customizations = []
284
+ parser = self._parsers[CustomizationType.PLUGIN]
285
+
286
+ for plugin_file in plugins_path.iterdir():
287
+ if plugin_file.is_file() and parser.can_parse(plugin_file):
288
+ customizations.append(parser.parse(plugin_file, level))
289
+
290
+ return customizations
291
+
292
+ def _discover_instructions(self, level: ConfigLevel) -> list[Customization]:
293
+ """Discover instruction files from opencode.json instructions field.
294
+
295
+ Args:
296
+ level: Configuration level (GLOBAL or PROJECT)
297
+
298
+ Returns:
299
+ List of instruction file customizations
300
+ """
301
+ if level == ConfigLevel.GLOBAL:
302
+ config_path = self.global_config_path / "opencode.json"
303
+ base_dir = self.global_config_path
304
+ else:
305
+ config_path = self.project_root / "opencode.json"
306
+ base_dir = self.project_root
307
+
308
+ if not config_path.exists():
309
+ return []
310
+
311
+ # Parse opencode.json
312
+ content, error = read_file_safe(config_path)
313
+ if error or not content:
314
+ return []
315
+
316
+ try:
317
+ clean_content = strip_jsonc_comments(content)
318
+ config = json.loads(clean_content)
319
+ except (json.JSONDecodeError, Exception):
320
+ return []
321
+
322
+ instructions = config.get("instructions", [])
323
+ if not instructions:
324
+ return []
325
+
326
+ customizations = []
327
+ parser = self._parsers[CustomizationType.RULES]
328
+
329
+ for pattern in instructions:
330
+ # Resolve glob pattern relative to base_dir
331
+ matched_files = sorted(base_dir.glob(pattern))
332
+ for matched_file in matched_files:
333
+ if matched_file.is_file() and isinstance(parser, RulesParser):
334
+ customizations.append(
335
+ parser.parse_instruction(matched_file, base_dir, level)
336
+ )
337
+
338
+ return customizations
339
+
340
+ def refresh(self) -> None:
341
+ """Clear cache and force re-discovery."""
342
+ self._cache = None
343
+
344
+ def by_type(self, ctype: CustomizationType) -> list[Customization]:
345
+ """Get customizations filtered by type."""
346
+ return [c for c in self.discover_all() if c.type == ctype]
347
+
348
+ def by_level(self, level: ConfigLevel) -> list[Customization]:
349
+ """Get customizations filtered by level."""
350
+ return [c for c in self.discover_all() if c.level == level]