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.
- lazyopencode/__init__.py +48 -0
- lazyopencode/__main__.py +6 -0
- lazyopencode/_version.py +34 -0
- lazyopencode/app.py +310 -0
- lazyopencode/bindings.py +27 -0
- lazyopencode/mixins/filtering.py +33 -0
- lazyopencode/mixins/help.py +74 -0
- lazyopencode/mixins/navigation.py +184 -0
- lazyopencode/models/__init__.py +17 -0
- lazyopencode/models/customization.py +120 -0
- lazyopencode/services/__init__.py +7 -0
- lazyopencode/services/discovery.py +350 -0
- lazyopencode/services/gitignore_filter.py +123 -0
- lazyopencode/services/parsers/__init__.py +152 -0
- lazyopencode/services/parsers/agent.py +93 -0
- lazyopencode/services/parsers/command.py +94 -0
- lazyopencode/services/parsers/mcp.py +67 -0
- lazyopencode/services/parsers/plugin.py +127 -0
- lazyopencode/services/parsers/rules.py +65 -0
- lazyopencode/services/parsers/skill.py +138 -0
- lazyopencode/services/parsers/tool.py +67 -0
- lazyopencode/styles/app.tcss +173 -0
- lazyopencode/themes.py +30 -0
- lazyopencode/widgets/__init__.py +17 -0
- lazyopencode/widgets/app_footer.py +71 -0
- lazyopencode/widgets/combined_panel.py +345 -0
- lazyopencode/widgets/detail_pane.py +338 -0
- lazyopencode/widgets/filter_input.py +88 -0
- lazyopencode/widgets/helpers/__init__.py +5 -0
- lazyopencode/widgets/helpers/rendering.py +17 -0
- lazyopencode/widgets/status_panel.py +70 -0
- lazyopencode/widgets/type_panel.py +501 -0
- lazyopencode-0.1.0.dist-info/METADATA +118 -0
- lazyopencode-0.1.0.dist-info/RECORD +37 -0
- lazyopencode-0.1.0.dist-info/WHEEL +4 -0
- lazyopencode-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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]
|