lazyopencode 0.1.1__py3-none-any.whl → 0.2.1__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 +196 -3
- lazyopencode/bindings.py +3 -0
- lazyopencode/mixins/help.py +2 -0
- lazyopencode/models/customization.py +46 -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 +147 -0
- lazyopencode/widgets/app_footer.py +8 -1
- lazyopencode/widgets/delete_confirm.py +115 -0
- lazyopencode/widgets/level_selector.py +130 -0
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.1.dist-info}/METADATA +23 -1
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.1.dist-info}/RECORD +23 -13
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.1.dist-info}/WHEEL +0 -0
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.1.dist-info}/entry_points.txt +0 -0
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.1.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.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 1)
|
|
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
|
)
|
|
@@ -23,8 +24,10 @@ from lazyopencode.services.discovery import ConfigDiscoveryService
|
|
|
23
24
|
from lazyopencode.themes import CUSTOM_THEMES
|
|
24
25
|
from lazyopencode.widgets.app_footer import AppFooter
|
|
25
26
|
from lazyopencode.widgets.combined_panel import CombinedPanel
|
|
27
|
+
from lazyopencode.widgets.delete_confirm import DeleteConfirm
|
|
26
28
|
from lazyopencode.widgets.detail_pane import MainPane
|
|
27
29
|
from lazyopencode.widgets.filter_input import FilterInput
|
|
30
|
+
from lazyopencode.widgets.level_selector import LevelSelector
|
|
28
31
|
from lazyopencode.widgets.status_panel import StatusPanel
|
|
29
32
|
from lazyopencode.widgets.type_panel import TypePanel
|
|
30
33
|
|
|
@@ -44,6 +47,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
44
47
|
discovery_service: ConfigDiscoveryService | None = None,
|
|
45
48
|
project_root: Path | None = None,
|
|
46
49
|
global_config_path: Path | None = None,
|
|
50
|
+
enable_claude_code: bool = False,
|
|
47
51
|
) -> None:
|
|
48
52
|
"""Initialize LazyOpenCode application."""
|
|
49
53
|
super().__init__()
|
|
@@ -51,6 +55,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
51
55
|
self._discovery_service = discovery_service or ConfigDiscoveryService(
|
|
52
56
|
project_root=project_root,
|
|
53
57
|
global_config_path=global_config_path,
|
|
58
|
+
enable_claude_code=enable_claude_code,
|
|
54
59
|
)
|
|
55
60
|
self._customizations: list[Customization] = []
|
|
56
61
|
self._level_filter: ConfigLevel | None = None
|
|
@@ -60,7 +65,10 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
60
65
|
self._main_pane: MainPane | None = None
|
|
61
66
|
self._filter_input: FilterInput | None = None
|
|
62
67
|
self._app_footer: AppFooter | None = None
|
|
68
|
+
self._level_selector: LevelSelector | None = None
|
|
69
|
+
self._delete_confirm: DeleteConfirm | None = None
|
|
63
70
|
self._last_focused_panel: TypePanel | CombinedPanel | None = None
|
|
71
|
+
self._pending_customization: Customization | None = None
|
|
64
72
|
|
|
65
73
|
def compose(self) -> ComposeResult:
|
|
66
74
|
"""Compose the application layout."""
|
|
@@ -105,6 +113,12 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
105
113
|
self._filter_input = FilterInput(id="filter-input")
|
|
106
114
|
yield self._filter_input
|
|
107
115
|
|
|
116
|
+
self._level_selector = LevelSelector(id="level-selector")
|
|
117
|
+
yield self._level_selector
|
|
118
|
+
|
|
119
|
+
self._delete_confirm = DeleteConfirm(id="delete-confirm")
|
|
120
|
+
yield self._delete_confirm
|
|
121
|
+
|
|
108
122
|
self._app_footer = AppFooter(id="app-footer")
|
|
109
123
|
yield self._app_footer
|
|
110
124
|
|
|
@@ -153,7 +167,12 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
153
167
|
"""Get customizations filtered by current level and search query."""
|
|
154
168
|
result = self._customizations
|
|
155
169
|
if self._level_filter:
|
|
156
|
-
|
|
170
|
+
# When filtering by level, only show OpenCode items (exclude Claude Code)
|
|
171
|
+
result = [
|
|
172
|
+
c
|
|
173
|
+
for c in result
|
|
174
|
+
if c.level == self._level_filter and c.source == ConfigSource.OPENCODE
|
|
175
|
+
]
|
|
157
176
|
if self._search_query:
|
|
158
177
|
query = self._search_query.lower()
|
|
159
178
|
result = [c for c in result if query in c.name.lower()]
|
|
@@ -167,6 +186,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
167
186
|
"""Handle selection change in a type panel."""
|
|
168
187
|
if self._main_pane:
|
|
169
188
|
self._main_pane.customization = message.customization
|
|
189
|
+
self._update_footer_can_delete(message.customization)
|
|
170
190
|
|
|
171
191
|
def on_type_panel_drill_down(self, message: TypePanel.DrillDown) -> None:
|
|
172
192
|
"""Handle drill down into a customization."""
|
|
@@ -188,6 +208,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
188
208
|
"""Handle selection change in the combined panel."""
|
|
189
209
|
if self._main_pane:
|
|
190
210
|
self._main_pane.customization = message.customization
|
|
211
|
+
self._update_footer_can_delete(message.customization)
|
|
191
212
|
|
|
192
213
|
def on_combined_panel_drill_down(self, message: CombinedPanel.DrillDown) -> None:
|
|
193
214
|
"""Handle drill down from the combined panel."""
|
|
@@ -253,7 +274,6 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
253
274
|
"""Refresh customizations from disk."""
|
|
254
275
|
self._discovery_service.refresh()
|
|
255
276
|
self._load_customizations()
|
|
256
|
-
self.notify("Refreshed", severity="information")
|
|
257
277
|
|
|
258
278
|
# action_toggle_help handled by HelpMixin
|
|
259
279
|
|
|
@@ -296,14 +316,187 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
|
|
|
296
316
|
except Exception as e:
|
|
297
317
|
self.notify(f"Error opening editor: {e}", severity="error")
|
|
298
318
|
|
|
319
|
+
# Copy actions
|
|
320
|
+
|
|
321
|
+
def action_copy_customization(self) -> None:
|
|
322
|
+
"""Copy selected customization to another level."""
|
|
323
|
+
panel = self._get_focused_panel()
|
|
324
|
+
customization = None
|
|
325
|
+
|
|
326
|
+
if panel:
|
|
327
|
+
customization = panel.selected_customization
|
|
328
|
+
|
|
329
|
+
if not customization:
|
|
330
|
+
self.notify("No customization selected", severity="warning")
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
# Only allow copying commands, agents, and skills
|
|
334
|
+
copyable_types = (
|
|
335
|
+
CustomizationType.COMMAND,
|
|
336
|
+
CustomizationType.AGENT,
|
|
337
|
+
CustomizationType.SKILL,
|
|
338
|
+
)
|
|
339
|
+
if customization.type not in copyable_types:
|
|
340
|
+
self.notify(
|
|
341
|
+
f"Cannot copy {customization.type_label} customizations",
|
|
342
|
+
severity="warning",
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
available = customization.get_copy_targets()
|
|
347
|
+
if not available:
|
|
348
|
+
self.notify("No available target levels", severity="warning")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
self._pending_customization = customization
|
|
352
|
+
self._last_focused_panel = panel
|
|
353
|
+
if self._level_selector:
|
|
354
|
+
self._level_selector.show(available, customization.name)
|
|
355
|
+
|
|
356
|
+
def action_copy_path_to_clipboard(self) -> None:
|
|
357
|
+
"""Copy path of selected customization to clipboard."""
|
|
358
|
+
panel = self._get_focused_panel()
|
|
359
|
+
customization = None
|
|
360
|
+
|
|
361
|
+
if panel:
|
|
362
|
+
customization = panel.selected_customization
|
|
363
|
+
|
|
364
|
+
if not customization:
|
|
365
|
+
self.notify("No customization selected", severity="warning")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
file_path = customization.path
|
|
369
|
+
if customization.type == CustomizationType.SKILL:
|
|
370
|
+
file_path = customization.path.parent
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
import pyperclip
|
|
374
|
+
|
|
375
|
+
pyperclip.copy(str(file_path))
|
|
376
|
+
self.notify(f"Copied: {file_path}", severity="information")
|
|
377
|
+
except ImportError:
|
|
378
|
+
self.notify(
|
|
379
|
+
"pyperclip not installed. Run: pip install pyperclip",
|
|
380
|
+
severity="error",
|
|
381
|
+
)
|
|
382
|
+
except Exception as e:
|
|
383
|
+
self.notify(f"Failed to copy to clipboard: {e}", severity="error")
|
|
384
|
+
|
|
385
|
+
# Level selector message handlers
|
|
386
|
+
|
|
387
|
+
def on_level_selector_level_selected(
|
|
388
|
+
self, message: LevelSelector.LevelSelected
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Handle level selection from the level selector."""
|
|
391
|
+
if self._pending_customization:
|
|
392
|
+
self._handle_copy(self._pending_customization, message.level)
|
|
393
|
+
self._pending_customization = None
|
|
394
|
+
self._restore_focus_after_selector()
|
|
395
|
+
|
|
396
|
+
def on_level_selector_selection_cancelled(
|
|
397
|
+
self,
|
|
398
|
+
message: LevelSelector.SelectionCancelled, # noqa: ARG002
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Handle level selector cancellation."""
|
|
401
|
+
self._pending_customization = None
|
|
402
|
+
self._restore_focus_after_selector()
|
|
403
|
+
|
|
404
|
+
def _handle_copy(
|
|
405
|
+
self, customization: Customization, target_level: ConfigLevel
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Handle copy operation."""
|
|
408
|
+
from lazyopencode.services.writer import CustomizationWriter
|
|
409
|
+
|
|
410
|
+
writer = CustomizationWriter(
|
|
411
|
+
global_config_path=self._discovery_service.global_config_path,
|
|
412
|
+
project_config_path=self._discovery_service.project_config_path,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
success, msg = writer.copy_customization(customization, target_level)
|
|
416
|
+
|
|
417
|
+
if success:
|
|
418
|
+
self.notify(msg, severity="information")
|
|
419
|
+
self.action_refresh()
|
|
420
|
+
else:
|
|
421
|
+
self.notify(msg, severity="error")
|
|
422
|
+
|
|
423
|
+
def _restore_focus_after_selector(self) -> None:
|
|
424
|
+
"""Restore focus to the previously focused panel."""
|
|
425
|
+
if self._last_focused_panel:
|
|
426
|
+
self._last_focused_panel.focus()
|
|
427
|
+
elif self._panels:
|
|
428
|
+
self._panels[0].focus()
|
|
429
|
+
|
|
430
|
+
def _update_footer_can_delete(self, customization: Customization | None) -> None:
|
|
431
|
+
"""Update footer delete indicator based on current selection."""
|
|
432
|
+
if self._app_footer:
|
|
433
|
+
self._app_footer.can_delete = (
|
|
434
|
+
customization is not None and customization.is_deletable()
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Delete actions
|
|
438
|
+
|
|
439
|
+
def action_delete_customization(self) -> None:
|
|
440
|
+
"""Delete selected customization."""
|
|
441
|
+
panel = self._get_focused_panel()
|
|
442
|
+
customization = panel.selected_customization if panel else None
|
|
443
|
+
|
|
444
|
+
if not customization:
|
|
445
|
+
self.notify("No customization selected", severity="warning")
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
if not customization.is_deletable():
|
|
449
|
+
self.notify(
|
|
450
|
+
f"Cannot delete {customization.type_label} customizations",
|
|
451
|
+
severity="warning",
|
|
452
|
+
)
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
self._last_focused_panel = panel
|
|
456
|
+
if self._delete_confirm:
|
|
457
|
+
self._delete_confirm.show(customization)
|
|
458
|
+
|
|
459
|
+
def on_delete_confirm_delete_confirmed(
|
|
460
|
+
self, message: DeleteConfirm.DeleteConfirmed
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Handle delete confirmation."""
|
|
463
|
+
from lazyopencode.services.writer import CustomizationWriter
|
|
464
|
+
|
|
465
|
+
writer = CustomizationWriter(
|
|
466
|
+
global_config_path=self._discovery_service.global_config_path,
|
|
467
|
+
project_config_path=self._discovery_service.project_config_path,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
success, msg = writer.delete_customization(message.customization)
|
|
471
|
+
|
|
472
|
+
if success:
|
|
473
|
+
self.notify(msg, severity="information")
|
|
474
|
+
self.action_refresh()
|
|
475
|
+
else:
|
|
476
|
+
self.notify(msg, severity="error")
|
|
477
|
+
|
|
478
|
+
self._restore_focus_after_selector()
|
|
479
|
+
|
|
480
|
+
def on_delete_confirm_delete_cancelled(
|
|
481
|
+
self,
|
|
482
|
+
message: DeleteConfirm.DeleteCancelled, # noqa: ARG002
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Handle delete cancellation."""
|
|
485
|
+
self._restore_focus_after_selector()
|
|
486
|
+
|
|
299
487
|
|
|
300
488
|
def create_app(
|
|
301
489
|
project_root: Path | None = None,
|
|
302
490
|
global_config_path: Path | None = None,
|
|
491
|
+
enable_claude_code: bool = False,
|
|
303
492
|
) -> LazyOpenCode:
|
|
304
493
|
"""Create application with all dependencies wired."""
|
|
305
494
|
discovery_service = ConfigDiscoveryService(
|
|
306
495
|
project_root=project_root,
|
|
307
496
|
global_config_path=global_config_path,
|
|
497
|
+
enable_claude_code=enable_claude_code,
|
|
498
|
+
)
|
|
499
|
+
return LazyOpenCode(
|
|
500
|
+
discovery_service=discovery_service,
|
|
501
|
+
enable_claude_code=enable_claude_code,
|
|
308
502
|
)
|
|
309
|
-
return LazyOpenCode(discovery_service=discovery_service)
|
lazyopencode/bindings.py
CHANGED
|
@@ -7,6 +7,9 @@ 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("d", "delete_customization", "Delete"),
|
|
12
|
+
Binding("C", "copy_path_to_clipboard", "Copy Path", key_display="shift+c"),
|
|
10
13
|
Binding("ctrl+u", "open_user_config", "User Config"),
|
|
11
14
|
Binding("tab", "focus_next_panel", "Next Panel", show=False),
|
|
12
15
|
Binding("shift+tab", "focus_previous_panel", "Prev Panel", show=False),
|
lazyopencode/mixins/help.py
CHANGED
|
@@ -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,49 @@ 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]
|
|
152
|
+
|
|
153
|
+
def is_deletable(self) -> bool:
|
|
154
|
+
"""Check if this customization can be deleted."""
|
|
155
|
+
if self.source != ConfigSource.OPENCODE:
|
|
156
|
+
return False
|
|
157
|
+
deletable_types = (
|
|
158
|
+
CustomizationType.COMMAND,
|
|
159
|
+
CustomizationType.AGENT,
|
|
160
|
+
CustomizationType.SKILL,
|
|
161
|
+
)
|
|
162
|
+
return self.type in deletable_types
|
|
@@ -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"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Parser for Claude Code subagent customizations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lazyopencode.models.customization import (
|
|
6
|
+
ConfigLevel,
|
|
7
|
+
ConfigSource,
|
|
8
|
+
Customization,
|
|
9
|
+
CustomizationType,
|
|
10
|
+
)
|
|
11
|
+
from lazyopencode.services.parsers import parse_frontmatter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentParser:
|
|
15
|
+
"""Parser for subagent markdown files.
|
|
16
|
+
|
|
17
|
+
File pattern: agents/*.md
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, agents_dir: Path) -> None:
|
|
21
|
+
"""Initialize with the agents directory path."""
|
|
22
|
+
self.agents_dir = agents_dir
|
|
23
|
+
|
|
24
|
+
def can_parse(self, path: Path) -> bool:
|
|
25
|
+
"""Check if path is a markdown file in agents directory."""
|
|
26
|
+
return path.suffix == ".md" and path.parent == self.agents_dir
|
|
27
|
+
|
|
28
|
+
def parse(self, path: Path, level: ConfigLevel, source_level: str) -> Customization:
|
|
29
|
+
"""Parse a subagent markdown file."""
|
|
30
|
+
try:
|
|
31
|
+
content = path.read_text(encoding="utf-8")
|
|
32
|
+
except OSError as e:
|
|
33
|
+
return Customization(
|
|
34
|
+
name=path.stem,
|
|
35
|
+
type=CustomizationType.AGENT,
|
|
36
|
+
level=level,
|
|
37
|
+
path=path,
|
|
38
|
+
error=f"Failed to read file: {e}",
|
|
39
|
+
source=ConfigSource.CLAUDE_CODE,
|
|
40
|
+
source_level=source_level,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
frontmatter, _ = parse_frontmatter(content)
|
|
44
|
+
|
|
45
|
+
name = frontmatter.get("name", path.stem)
|
|
46
|
+
description = frontmatter.get("description")
|
|
47
|
+
|
|
48
|
+
return Customization(
|
|
49
|
+
name=name,
|
|
50
|
+
type=CustomizationType.AGENT,
|
|
51
|
+
level=level,
|
|
52
|
+
path=path,
|
|
53
|
+
description=description,
|
|
54
|
+
content=content,
|
|
55
|
+
metadata=frontmatter,
|
|
56
|
+
source=ConfigSource.CLAUDE_CODE,
|
|
57
|
+
source_level=source_level,
|
|
58
|
+
)
|