lazyopencode 0.1.0__py3-none-any.whl → 0.2.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 +12 -1
- lazyopencode/_version.py +2 -2
- lazyopencode/app.py +152 -23
- lazyopencode/bindings.py +2 -0
- lazyopencode/mixins/help.py +2 -0
- lazyopencode/mixins/navigation.py +21 -26
- lazyopencode/models/customization.py +35 -4
- lazyopencode/services/claude_code/__init__.py +9 -0
- lazyopencode/services/claude_code/discovery.py +158 -0
- lazyopencode/services/claude_code/parsers/__init__.py +7 -0
- lazyopencode/services/claude_code/parsers/agent.py +58 -0
- lazyopencode/services/claude_code/parsers/command.py +75 -0
- lazyopencode/services/claude_code/parsers/skill.py +130 -0
- lazyopencode/services/claude_code/plugin_loader.py +164 -0
- lazyopencode/services/discovery.py +25 -4
- lazyopencode/services/writer.py +119 -0
- lazyopencode/widgets/app_footer.py +1 -1
- lazyopencode/widgets/level_selector.py +130 -0
- {lazyopencode-0.1.0.dist-info → lazyopencode-0.2.0.dist-info}/METADATA +28 -25
- {lazyopencode-0.1.0.dist-info → lazyopencode-0.2.0.dist-info}/RECORD +23 -14
- {lazyopencode-0.1.0.dist-info → lazyopencode-0.2.0.dist-info}/WHEEL +0 -0
- {lazyopencode-0.1.0.dist-info → lazyopencode-0.2.0.dist-info}/entry_points.txt +0 -0
- {lazyopencode-0.1.0.dist-info → lazyopencode-0.2.0.dist-info}/licenses/LICENSE +0 -0
lazyopencode/__init__.py
CHANGED
|
@@ -38,11 +38,22 @@ def main() -> None:
|
|
|
38
38
|
help="Override user config path (default: ~/.config/opencode)",
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--claude-code",
|
|
43
|
+
action="store_true",
|
|
44
|
+
default=False,
|
|
45
|
+
help="Enable Claude Code customizations discovery (from ~/.claude/)",
|
|
46
|
+
)
|
|
47
|
+
|
|
41
48
|
args = parser.parse_args()
|
|
42
49
|
|
|
43
50
|
# Handle directory argument - resolve to absolute path
|
|
44
51
|
project_root = args.directory.resolve() if args.directory else None
|
|
45
52
|
user_config = args.user_config.resolve() if args.user_config else None
|
|
46
53
|
|
|
47
|
-
app = create_app(
|
|
54
|
+
app = create_app(
|
|
55
|
+
project_root=project_root,
|
|
56
|
+
global_config_path=user_config,
|
|
57
|
+
enable_claude_code=args.claude_code,
|
|
58
|
+
)
|
|
48
59
|
app.run()
|
lazyopencode/_version.py
CHANGED
|
@@ -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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
lazyopencode/app.py
CHANGED
|
@@ -16,6 +16,7 @@ from lazyopencode.mixins.help import HelpMixin
|
|
|
16
16
|
from lazyopencode.mixins.navigation import NavigationMixin
|
|
17
17
|
from lazyopencode.models.customization import (
|
|
18
18
|
ConfigLevel,
|
|
19
|
+
ConfigSource,
|
|
19
20
|
Customization,
|
|
20
21
|
CustomizationType,
|
|
21
22
|
)
|
|
@@ -25,6 +26,7 @@ from lazyopencode.widgets.app_footer import AppFooter
|
|
|
25
26
|
from lazyopencode.widgets.combined_panel import CombinedPanel
|
|
26
27
|
from lazyopencode.widgets.detail_pane import MainPane
|
|
27
28
|
from lazyopencode.widgets.filter_input import FilterInput
|
|
29
|
+
from lazyopencode.widgets.level_selector import LevelSelector
|
|
28
30
|
from lazyopencode.widgets.status_panel import StatusPanel
|
|
29
31
|
from lazyopencode.widgets.type_panel import TypePanel
|
|
30
32
|
|
|
@@ -44,6 +46,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
44
46
|
discovery_service: ConfigDiscoveryService | None = None,
|
|
45
47
|
project_root: Path | None = None,
|
|
46
48
|
global_config_path: Path | None = None,
|
|
49
|
+
enable_claude_code: bool = False,
|
|
47
50
|
) -> None:
|
|
48
51
|
"""Initialize LazyOpenCode application."""
|
|
49
52
|
super().__init__()
|
|
@@ -51,6 +54,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
51
54
|
self._discovery_service = discovery_service or ConfigDiscoveryService(
|
|
52
55
|
project_root=project_root,
|
|
53
56
|
global_config_path=global_config_path,
|
|
57
|
+
enable_claude_code=enable_claude_code,
|
|
54
58
|
)
|
|
55
59
|
self._customizations: list[Customization] = []
|
|
56
60
|
self._level_filter: ConfigLevel | None = None
|
|
@@ -60,7 +64,9 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
60
64
|
self._main_pane: MainPane | None = None
|
|
61
65
|
self._filter_input: FilterInput | None = None
|
|
62
66
|
self._app_footer: AppFooter | None = None
|
|
67
|
+
self._level_selector: LevelSelector | None = None
|
|
63
68
|
self._last_focused_panel: TypePanel | CombinedPanel | None = None
|
|
69
|
+
self._pending_customization: Customization | None = None
|
|
64
70
|
|
|
65
71
|
def compose(self) -> ComposeResult:
|
|
66
72
|
"""Compose the application layout."""
|
|
@@ -68,16 +74,17 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
68
74
|
self._status_panel = StatusPanel(id="status-panel")
|
|
69
75
|
yield self._status_panel
|
|
70
76
|
|
|
71
|
-
# [1]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
# [1] Type Panel: Commands
|
|
78
|
+
tp_cmd = TypePanel(CustomizationType.COMMAND, id="panel-command")
|
|
79
|
+
tp_cmd.panel_number = 1
|
|
80
|
+
self._panels.append(tp_cmd)
|
|
81
|
+
yield tp_cmd
|
|
82
|
+
|
|
83
|
+
# [2] Type Panel: Agents
|
|
84
|
+
tp_agent = TypePanel(CustomizationType.AGENT, id="panel-agent")
|
|
85
|
+
tp_agent.panel_number = 2
|
|
86
|
+
self._panels.append(tp_agent)
|
|
87
|
+
yield tp_agent
|
|
81
88
|
|
|
82
89
|
# [3] Type Panel: Skills
|
|
83
90
|
tp_skills = TypePanel(CustomizationType.SKILL, id="panel-skill")
|
|
@@ -85,23 +92,18 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
85
92
|
self._panels.append(tp_skills)
|
|
86
93
|
yield tp_skills
|
|
87
94
|
|
|
88
|
-
# [4]
|
|
89
|
-
|
|
90
|
-
tp_rules.panel_number = 4
|
|
91
|
-
self._panels.append(tp_rules)
|
|
92
|
-
yield tp_rules
|
|
93
|
-
|
|
94
|
-
# [5]+[6]+[7] Combined Panel: MCPs, Tools, Plugins
|
|
95
|
-
cp2 = CombinedPanel(
|
|
95
|
+
# [4]+[5]+[6]+[7] Combined Panel: Memory, MCPs, Tools, Plugins
|
|
96
|
+
cp = CombinedPanel(
|
|
96
97
|
tabs=[
|
|
98
|
+
(CustomizationType.RULES, 4, "Memory"),
|
|
97
99
|
(CustomizationType.MCP, 5, "MCPs"),
|
|
98
100
|
(CustomizationType.TOOL, 6, "Tools"),
|
|
99
101
|
(CustomizationType.PLUGIN, 7, "Plugins"),
|
|
100
102
|
],
|
|
101
|
-
id="panel-combined
|
|
103
|
+
id="panel-combined",
|
|
102
104
|
)
|
|
103
|
-
self._panels.append(
|
|
104
|
-
yield
|
|
105
|
+
self._panels.append(cp)
|
|
106
|
+
yield cp
|
|
105
107
|
|
|
106
108
|
self._main_pane = MainPane(id="main-pane")
|
|
107
109
|
yield self._main_pane
|
|
@@ -109,6 +111,9 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
109
111
|
self._filter_input = FilterInput(id="filter-input")
|
|
110
112
|
yield self._filter_input
|
|
111
113
|
|
|
114
|
+
self._level_selector = LevelSelector(id="level-selector")
|
|
115
|
+
yield self._level_selector
|
|
116
|
+
|
|
112
117
|
self._app_footer = AppFooter(id="app-footer")
|
|
113
118
|
yield self._app_footer
|
|
114
119
|
|
|
@@ -121,6 +126,9 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
121
126
|
self._update_status_panel()
|
|
122
127
|
project_name = self._discovery_service.project_root.name
|
|
123
128
|
self.title = f"{project_name} - LazyOpenCode"
|
|
129
|
+
self.console.set_window_title(self.title)
|
|
130
|
+
if os.name == "nt":
|
|
131
|
+
os.system(f"title {self.title}")
|
|
124
132
|
# Focus first non-empty panel or first panel
|
|
125
133
|
if self._panels:
|
|
126
134
|
self._panels[0].focus()
|
|
@@ -154,7 +162,12 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
154
162
|
"""Get customizations filtered by current level and search query."""
|
|
155
163
|
result = self._customizations
|
|
156
164
|
if self._level_filter:
|
|
157
|
-
|
|
165
|
+
# When filtering by level, only show OpenCode items (exclude Claude Code)
|
|
166
|
+
result = [
|
|
167
|
+
c
|
|
168
|
+
for c in result
|
|
169
|
+
if c.level == self._level_filter and c.source == ConfigSource.OPENCODE
|
|
170
|
+
]
|
|
158
171
|
if self._search_query:
|
|
159
172
|
query = self._search_query.lower()
|
|
160
173
|
result = [c for c in result if query in c.name.lower()]
|
|
@@ -297,14 +310,130 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
297
310
|
except Exception as e:
|
|
298
311
|
self.notify(f"Error opening editor: {e}", severity="error")
|
|
299
312
|
|
|
313
|
+
# Copy actions
|
|
314
|
+
|
|
315
|
+
def action_copy_customization(self) -> None:
|
|
316
|
+
"""Copy selected customization to another level."""
|
|
317
|
+
panel = self._get_focused_panel()
|
|
318
|
+
customization = None
|
|
319
|
+
|
|
320
|
+
if panel:
|
|
321
|
+
customization = panel.selected_customization
|
|
322
|
+
|
|
323
|
+
if not customization:
|
|
324
|
+
self.notify("No customization selected", severity="warning")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Only allow copying commands, agents, and skills
|
|
328
|
+
copyable_types = (
|
|
329
|
+
CustomizationType.COMMAND,
|
|
330
|
+
CustomizationType.AGENT,
|
|
331
|
+
CustomizationType.SKILL,
|
|
332
|
+
)
|
|
333
|
+
if customization.type not in copyable_types:
|
|
334
|
+
self.notify(
|
|
335
|
+
f"Cannot copy {customization.type_label} customizations",
|
|
336
|
+
severity="warning",
|
|
337
|
+
)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
available = customization.get_copy_targets()
|
|
341
|
+
if not available:
|
|
342
|
+
self.notify("No available target levels", severity="warning")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
self._pending_customization = customization
|
|
346
|
+
self._last_focused_panel = panel
|
|
347
|
+
if self._level_selector:
|
|
348
|
+
self._level_selector.show(available, customization.name)
|
|
349
|
+
|
|
350
|
+
def action_copy_path_to_clipboard(self) -> None:
|
|
351
|
+
"""Copy path of selected customization to clipboard."""
|
|
352
|
+
panel = self._get_focused_panel()
|
|
353
|
+
customization = None
|
|
354
|
+
|
|
355
|
+
if panel:
|
|
356
|
+
customization = panel.selected_customization
|
|
357
|
+
|
|
358
|
+
if not customization:
|
|
359
|
+
self.notify("No customization selected", severity="warning")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
file_path = customization.path
|
|
363
|
+
if customization.type == CustomizationType.SKILL:
|
|
364
|
+
file_path = customization.path.parent
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
import pyperclip
|
|
368
|
+
|
|
369
|
+
pyperclip.copy(str(file_path))
|
|
370
|
+
self.notify(f"Copied: {file_path}", severity="information")
|
|
371
|
+
except ImportError:
|
|
372
|
+
self.notify(
|
|
373
|
+
"pyperclip not installed. Run: pip install pyperclip",
|
|
374
|
+
severity="error",
|
|
375
|
+
)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
self.notify(f"Failed to copy to clipboard: {e}", severity="error")
|
|
378
|
+
|
|
379
|
+
# Level selector message handlers
|
|
380
|
+
|
|
381
|
+
def on_level_selector_level_selected(
|
|
382
|
+
self, message: LevelSelector.LevelSelected
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Handle level selection from the level selector."""
|
|
385
|
+
if self._pending_customization:
|
|
386
|
+
self._handle_copy(self._pending_customization, message.level)
|
|
387
|
+
self._pending_customization = None
|
|
388
|
+
self._restore_focus_after_selector()
|
|
389
|
+
|
|
390
|
+
def on_level_selector_selection_cancelled(
|
|
391
|
+
self,
|
|
392
|
+
message: LevelSelector.SelectionCancelled, # noqa: ARG002
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Handle level selector cancellation."""
|
|
395
|
+
self._pending_customization = None
|
|
396
|
+
self._restore_focus_after_selector()
|
|
397
|
+
|
|
398
|
+
def _handle_copy(
|
|
399
|
+
self, customization: Customization, target_level: ConfigLevel
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Handle copy operation."""
|
|
402
|
+
from lazyopencode.services.writer import CustomizationWriter
|
|
403
|
+
|
|
404
|
+
writer = CustomizationWriter(
|
|
405
|
+
global_config_path=self._discovery_service.global_config_path,
|
|
406
|
+
project_config_path=self._discovery_service.project_config_path,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
success, msg = writer.copy_customization(customization, target_level)
|
|
410
|
+
|
|
411
|
+
if success:
|
|
412
|
+
self.notify(msg, severity="information")
|
|
413
|
+
self.action_refresh()
|
|
414
|
+
else:
|
|
415
|
+
self.notify(msg, severity="error")
|
|
416
|
+
|
|
417
|
+
def _restore_focus_after_selector(self) -> None:
|
|
418
|
+
"""Restore focus to the previously focused panel."""
|
|
419
|
+
if self._last_focused_panel:
|
|
420
|
+
self._last_focused_panel.focus()
|
|
421
|
+
elif self._panels:
|
|
422
|
+
self._panels[0].focus()
|
|
423
|
+
|
|
300
424
|
|
|
301
425
|
def create_app(
|
|
302
426
|
project_root: Path | None = None,
|
|
303
427
|
global_config_path: Path | None = None,
|
|
428
|
+
enable_claude_code: bool = False,
|
|
304
429
|
) -> LazyOpenCode:
|
|
305
430
|
"""Create application with all dependencies wired."""
|
|
306
431
|
discovery_service = ConfigDiscoveryService(
|
|
307
432
|
project_root=project_root,
|
|
308
433
|
global_config_path=global_config_path,
|
|
434
|
+
enable_claude_code=enable_claude_code,
|
|
435
|
+
)
|
|
436
|
+
return LazyOpenCode(
|
|
437
|
+
discovery_service=discovery_service,
|
|
438
|
+
enable_claude_code=enable_claude_code,
|
|
309
439
|
)
|
|
310
|
-
return LazyOpenCode(discovery_service=discovery_service)
|
lazyopencode/bindings.py
CHANGED
|
@@ -7,6 +7,8 @@ APP_BINDINGS: list[BindingType] = [
|
|
|
7
7
|
Binding("?", "toggle_help", "Help"),
|
|
8
8
|
Binding("r", "refresh", "Refresh"),
|
|
9
9
|
Binding("e", "open_in_editor", "Edit"),
|
|
10
|
+
Binding("c", "copy_customization", "Copy"),
|
|
11
|
+
Binding("C", "copy_path_to_clipboard", "Copy Path", key_display="shift+c"),
|
|
10
12
|
Binding("ctrl+u", "open_user_config", "User Config"),
|
|
11
13
|
Binding("tab", "focus_next_panel", "Next Panel", show=False),
|
|
12
14
|
Binding("shift+tab", "focus_previous_panel", "Prev Panel", show=False),
|
lazyopencode/mixins/help.py
CHANGED
|
@@ -80,70 +80,65 @@ class NavigationMixin:
|
|
|
80
80
|
prev_panel.focus()
|
|
81
81
|
|
|
82
82
|
def action_focus_panel_1(self) -> None:
|
|
83
|
-
"""Focus Commands
|
|
83
|
+
"""Focus Commands panel."""
|
|
84
84
|
app = cast("LazyOpenCode", self)
|
|
85
|
-
from lazyopencode.widgets.combined_panel import CombinedPanel
|
|
86
|
-
|
|
87
85
|
if len(app._panels) > 0:
|
|
88
|
-
|
|
89
|
-
if isinstance(panel, CombinedPanel):
|
|
90
|
-
panel.switch_to_tab(0) # Commands tab
|
|
91
|
-
panel.focus()
|
|
86
|
+
app._panels[0].focus()
|
|
92
87
|
|
|
93
88
|
def action_focus_panel_2(self) -> None:
|
|
94
|
-
"""Focus Agents
|
|
89
|
+
"""Focus Agents panel."""
|
|
95
90
|
app = cast("LazyOpenCode", self)
|
|
96
|
-
|
|
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()
|
|
91
|
+
if len(app._panels) > 1:
|
|
92
|
+
app._panels[1].focus()
|
|
103
93
|
|
|
104
94
|
def action_focus_panel_3(self) -> None:
|
|
105
95
|
"""Focus Skills panel."""
|
|
106
96
|
app = cast("LazyOpenCode", self)
|
|
107
|
-
if len(app._panels) >
|
|
108
|
-
app._panels[
|
|
97
|
+
if len(app._panels) > 2:
|
|
98
|
+
app._panels[2].focus()
|
|
109
99
|
|
|
110
100
|
def action_focus_panel_4(self) -> None:
|
|
111
|
-
"""Focus Agent Memory (Rules) panel."""
|
|
101
|
+
"""Focus Agent Memory (Rules) tab in combined panel."""
|
|
112
102
|
app = cast("LazyOpenCode", self)
|
|
113
|
-
|
|
114
|
-
|
|
103
|
+
from lazyopencode.widgets.combined_panel import CombinedPanel
|
|
104
|
+
|
|
105
|
+
if len(app._panels) > 3:
|
|
106
|
+
panel = app._panels[3]
|
|
107
|
+
if isinstance(panel, CombinedPanel):
|
|
108
|
+
panel.switch_to_tab(0) # Memory tab
|
|
109
|
+
panel.focus()
|
|
115
110
|
|
|
116
111
|
def action_focus_panel_5(self) -> None:
|
|
117
|
-
"""Focus MCPs tab in
|
|
112
|
+
"""Focus MCPs tab in combined panel."""
|
|
118
113
|
app = cast("LazyOpenCode", self)
|
|
119
114
|
from lazyopencode.widgets.combined_panel import CombinedPanel
|
|
120
115
|
|
|
121
116
|
if len(app._panels) > 3:
|
|
122
117
|
panel = app._panels[3]
|
|
123
118
|
if isinstance(panel, CombinedPanel):
|
|
124
|
-
panel.switch_to_tab(
|
|
119
|
+
panel.switch_to_tab(1) # MCPs tab
|
|
125
120
|
panel.focus()
|
|
126
121
|
|
|
127
122
|
def action_focus_panel_6(self) -> None:
|
|
128
|
-
"""Focus Tools tab in
|
|
123
|
+
"""Focus Tools tab in combined panel."""
|
|
129
124
|
app = cast("LazyOpenCode", self)
|
|
130
125
|
from lazyopencode.widgets.combined_panel import CombinedPanel
|
|
131
126
|
|
|
132
127
|
if len(app._panels) > 3:
|
|
133
128
|
panel = app._panels[3]
|
|
134
129
|
if isinstance(panel, CombinedPanel):
|
|
135
|
-
panel.switch_to_tab(
|
|
130
|
+
panel.switch_to_tab(2) # Tools tab
|
|
136
131
|
panel.focus()
|
|
137
132
|
|
|
138
133
|
def action_focus_panel_7(self) -> None:
|
|
139
|
-
"""Focus Plugins tab in
|
|
134
|
+
"""Focus Plugins tab in combined panel."""
|
|
140
135
|
app = cast("LazyOpenCode", self)
|
|
141
136
|
from lazyopencode.widgets.combined_panel import CombinedPanel
|
|
142
137
|
|
|
143
138
|
if len(app._panels) > 3:
|
|
144
139
|
panel = app._panels[3]
|
|
145
140
|
if isinstance(panel, CombinedPanel):
|
|
146
|
-
panel.switch_to_tab(
|
|
141
|
+
panel.switch_to_tab(3) # Plugins tab
|
|
147
142
|
panel.focus()
|
|
148
143
|
|
|
149
144
|
def action_focus_main_pane(self) -> None:
|
|
@@ -43,6 +43,13 @@ class ConfigLevel(Enum):
|
|
|
43
43
|
return "G" if self == ConfigLevel.GLOBAL else "P"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
class ConfigSource(Enum):
|
|
47
|
+
"""Source of customization configuration."""
|
|
48
|
+
|
|
49
|
+
OPENCODE = "opencode" # Native OpenCode customizations
|
|
50
|
+
CLAUDE_CODE = "claude" # Imported from Claude Code paths
|
|
51
|
+
|
|
52
|
+
|
|
46
53
|
class CustomizationType(Enum):
|
|
47
54
|
"""Type of OpenCode customization."""
|
|
48
55
|
|
|
@@ -93,6 +100,8 @@ class Customization:
|
|
|
93
100
|
content: str | None = None
|
|
94
101
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
95
102
|
error: str | None = None
|
|
103
|
+
source: ConfigSource = ConfigSource.OPENCODE
|
|
104
|
+
source_level: str | None = None # "user", "project", "plugin" for Claude Code
|
|
96
105
|
|
|
97
106
|
@property
|
|
98
107
|
def type_label(self) -> str:
|
|
@@ -105,16 +114,38 @@ class Customization:
|
|
|
105
114
|
return self.level.label
|
|
106
115
|
|
|
107
116
|
@property
|
|
108
|
-
def level_icon(self) -> str:
|
|
109
|
-
"""Level icon for compact display."""
|
|
110
|
-
|
|
117
|
+
def level_icon(self) -> str | None:
|
|
118
|
+
"""Level icon for compact display. Returns None for OpenCode items."""
|
|
119
|
+
if self.source == ConfigSource.CLAUDE_CODE:
|
|
120
|
+
return "👾"
|
|
121
|
+
return None
|
|
111
122
|
|
|
112
123
|
@property
|
|
113
124
|
def display_name(self) -> str:
|
|
114
125
|
"""Formatted name for display with level indicator."""
|
|
115
|
-
|
|
126
|
+
icon = self.level_icon
|
|
127
|
+
parts = []
|
|
128
|
+
if icon:
|
|
129
|
+
parts.append(f"[{icon}]")
|
|
130
|
+
parts.append(self.name)
|
|
131
|
+
|
|
132
|
+
# Add dimmed plugin name for plugin-sourced items
|
|
133
|
+
if self.source_level and self.source_level.startswith("plugin:"):
|
|
134
|
+
plugin_name = self.source_level.replace("plugin:", "")
|
|
135
|
+
parts.append(f"[dim]{plugin_name}[/dim]")
|
|
136
|
+
|
|
137
|
+
return " ".join(parts)
|
|
116
138
|
|
|
117
139
|
@property
|
|
118
140
|
def has_error(self) -> bool:
|
|
119
141
|
"""Check if this customization has a parse error."""
|
|
120
142
|
return self.error is not None
|
|
143
|
+
|
|
144
|
+
def get_copy_targets(self) -> list[ConfigLevel]:
|
|
145
|
+
"""Get valid copy target levels for this item."""
|
|
146
|
+
if self.source == ConfigSource.CLAUDE_CODE:
|
|
147
|
+
return [ConfigLevel.GLOBAL, ConfigLevel.PROJECT]
|
|
148
|
+
elif self.level == ConfigLevel.GLOBAL:
|
|
149
|
+
return [ConfigLevel.PROJECT]
|
|
150
|
+
else:
|
|
151
|
+
return [ConfigLevel.GLOBAL]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Claude Code integration layer for LazyOpenCode.
|
|
2
|
+
|
|
3
|
+
This module provides discovery of Claude Code customizations from standard
|
|
4
|
+
Claude Code paths (~/.claude/, ./.claude/, plugins).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from lazyopencode.services.claude_code.discovery import ClaudeCodeDiscoveryService
|
|
8
|
+
|
|
9
|
+
__all__ = ["ClaudeCodeDiscoveryService"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Service for discovering Claude Code customizations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lazyopencode.models.customization import (
|
|
6
|
+
ConfigLevel,
|
|
7
|
+
Customization,
|
|
8
|
+
)
|
|
9
|
+
from lazyopencode.services.claude_code.parsers.agent import AgentParser
|
|
10
|
+
from lazyopencode.services.claude_code.parsers.command import CommandParser
|
|
11
|
+
from lazyopencode.services.claude_code.parsers.skill import SkillParser
|
|
12
|
+
from lazyopencode.services.claude_code.plugin_loader import PluginLoader
|
|
13
|
+
from lazyopencode.services.gitignore_filter import GitignoreFilter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClaudeCodeDiscoveryService:
|
|
17
|
+
"""Discovers Claude Code customizations from standard Claude Code paths.
|
|
18
|
+
|
|
19
|
+
This is a separate layer responsible for Claude Code integration.
|
|
20
|
+
|
|
21
|
+
Discovery paths:
|
|
22
|
+
- User: ~/.claude/
|
|
23
|
+
- Project: ./.claude/
|
|
24
|
+
- Plugins: ~/.claude/plugins/ (via plugin registry)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, project_root: Path) -> None:
|
|
28
|
+
self.project_root = project_root
|
|
29
|
+
self.user_path = Path.home() / ".claude"
|
|
30
|
+
self.plugins_path = self.user_path / "plugins"
|
|
31
|
+
self._gitignore_filter = GitignoreFilter(project_root=project_root)
|
|
32
|
+
self._plugin_loader = PluginLoader(
|
|
33
|
+
user_config_path=self.user_path,
|
|
34
|
+
project_root=project_root,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def discover_all(self) -> list[Customization]:
|
|
38
|
+
"""Discover all Claude Code customizations."""
|
|
39
|
+
customizations: list[Customization] = []
|
|
40
|
+
|
|
41
|
+
# User-level
|
|
42
|
+
customizations.extend(self._discover_from_path(self.user_path, "user"))
|
|
43
|
+
|
|
44
|
+
# Project-level
|
|
45
|
+
project_claude = self.project_root / ".claude"
|
|
46
|
+
customizations.extend(self._discover_from_path(project_claude, "project"))
|
|
47
|
+
|
|
48
|
+
# Plugin-level (using plugin registry)
|
|
49
|
+
customizations.extend(self._discover_from_plugins())
|
|
50
|
+
|
|
51
|
+
return customizations
|
|
52
|
+
|
|
53
|
+
def _discover_from_path(self, base: Path, source_level: str) -> list[Customization]:
|
|
54
|
+
"""Discover commands, agents, skills from a Claude Code path."""
|
|
55
|
+
if not base.exists():
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
customizations: list[Customization] = []
|
|
59
|
+
level = ConfigLevel.GLOBAL if source_level == "user" else ConfigLevel.PROJECT
|
|
60
|
+
|
|
61
|
+
# Commands: base/commands/**/*.md
|
|
62
|
+
commands_path = base / "commands"
|
|
63
|
+
customizations.extend(
|
|
64
|
+
self._discover_commands(commands_path, level, source_level)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Agents: base/agents/*.md
|
|
68
|
+
agents_path = base / "agents"
|
|
69
|
+
customizations.extend(self._discover_agents(agents_path, level, source_level))
|
|
70
|
+
|
|
71
|
+
# Skills: base/skills/*/SKILL.md
|
|
72
|
+
skills_path = base / "skills"
|
|
73
|
+
customizations.extend(self._discover_skills(skills_path, level, source_level))
|
|
74
|
+
|
|
75
|
+
return customizations
|
|
76
|
+
|
|
77
|
+
def _discover_commands(
|
|
78
|
+
self, commands_path: Path, level: ConfigLevel, source_level: str
|
|
79
|
+
) -> list[Customization]:
|
|
80
|
+
"""Discover command customizations from commands directory."""
|
|
81
|
+
if not commands_path.exists():
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
customizations: list[Customization] = []
|
|
85
|
+
parser = CommandParser(commands_path)
|
|
86
|
+
|
|
87
|
+
# Use rglob for nested commands (commands/**/*.md)
|
|
88
|
+
for md_file in commands_path.rglob("*.md"):
|
|
89
|
+
if parser.can_parse(md_file):
|
|
90
|
+
customizations.append(parser.parse(md_file, level, source_level))
|
|
91
|
+
|
|
92
|
+
return customizations
|
|
93
|
+
|
|
94
|
+
def _discover_agents(
|
|
95
|
+
self, agents_path: Path, level: ConfigLevel, source_level: str
|
|
96
|
+
) -> list[Customization]:
|
|
97
|
+
"""Discover agent customizations from agents directory."""
|
|
98
|
+
if not agents_path.exists():
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
customizations: list[Customization] = []
|
|
102
|
+
parser = AgentParser(agents_path)
|
|
103
|
+
|
|
104
|
+
for md_file in agents_path.glob("*.md"):
|
|
105
|
+
if parser.can_parse(md_file):
|
|
106
|
+
customizations.append(parser.parse(md_file, level, source_level))
|
|
107
|
+
|
|
108
|
+
return customizations
|
|
109
|
+
|
|
110
|
+
def _discover_skills(
|
|
111
|
+
self, skills_path: Path, level: ConfigLevel, source_level: str
|
|
112
|
+
) -> list[Customization]:
|
|
113
|
+
"""Discover skill customizations from skills directory."""
|
|
114
|
+
if not skills_path.exists():
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
customizations: list[Customization] = []
|
|
118
|
+
parser = SkillParser(skills_path, gitignore_filter=self._gitignore_filter)
|
|
119
|
+
|
|
120
|
+
for skill_dir in skills_path.iterdir():
|
|
121
|
+
if skill_dir.is_dir():
|
|
122
|
+
skill_file = skill_dir / "SKILL.md"
|
|
123
|
+
if skill_file.exists() and parser.can_parse(skill_file):
|
|
124
|
+
customizations.append(parser.parse(skill_file, level, source_level))
|
|
125
|
+
|
|
126
|
+
return customizations
|
|
127
|
+
|
|
128
|
+
def _discover_from_plugins(self) -> list[Customization]:
|
|
129
|
+
"""Discover customizations from installed Claude Code plugins."""
|
|
130
|
+
customizations: list[Customization] = []
|
|
131
|
+
|
|
132
|
+
for plugin_info in self._plugin_loader.get_all_plugins():
|
|
133
|
+
install_path = plugin_info.install_path
|
|
134
|
+
source_level = f"plugin:{plugin_info.short_name}"
|
|
135
|
+
|
|
136
|
+
# Commands
|
|
137
|
+
commands_path = install_path / "commands"
|
|
138
|
+
customizations.extend(
|
|
139
|
+
self._discover_commands(commands_path, ConfigLevel.GLOBAL, source_level)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Agents
|
|
143
|
+
agents_path = install_path / "agents"
|
|
144
|
+
customizations.extend(
|
|
145
|
+
self._discover_agents(agents_path, ConfigLevel.GLOBAL, source_level)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Skills
|
|
149
|
+
skills_path = install_path / "skills"
|
|
150
|
+
customizations.extend(
|
|
151
|
+
self._discover_skills(skills_path, ConfigLevel.GLOBAL, source_level)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return customizations
|
|
155
|
+
|
|
156
|
+
def refresh(self) -> None:
|
|
157
|
+
"""Clear cached plugin registry to force reload."""
|
|
158
|
+
self._plugin_loader.refresh()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Parsers for Claude Code customizations."""
|
|
2
|
+
|
|
3
|
+
from lazyopencode.services.claude_code.parsers.agent import AgentParser
|
|
4
|
+
from lazyopencode.services.claude_code.parsers.command import CommandParser
|
|
5
|
+
from lazyopencode.services.claude_code.parsers.skill import SkillParser
|
|
6
|
+
|
|
7
|
+
__all__ = ["CommandParser", "AgentParser", "SkillParser"]
|