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 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(project_root=project_root, global_config_path=user_config)
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.1.0'
32
- __version_tuple__ = version_tuple = (0, 1, 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]+[2] Combined Panel: Commands, Agents
72
- cp1 = CombinedPanel(
73
- tabs=[
74
- (CustomizationType.COMMAND, 1, "Commands"),
75
- (CustomizationType.AGENT, 2, "Agents"),
76
- ],
77
- id="panel-combined-1",
78
- )
79
- self._panels.append(cp1)
80
- yield cp1
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] Type Panel: Agent Memory (Rules)
89
- tp_rules = TypePanel(CustomizationType.RULES, id="panel-rules")
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-2",
103
+ id="panel-combined",
102
104
  )
103
- self._panels.append(cp2)
104
- yield cp2
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
- result = [c for c in result if c.level == self._level_filter]
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),
@@ -50,6 +50,8 @@ class HelpMixin:
50
50
  Combined: switch tabs
51
51
 
52
52
  [bold]Actions[/]
53
+ c Copy to level (modal)
54
+ C (shift+c) Copy path to clipboard
53
55
  e Open in $EDITOR
54
56
  ctrl+u Open User Config
55
57
  r Refresh from disk
@@ -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 tab in first combined panel."""
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
- panel = app._panels[0]
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 tab in first combined panel."""
89
+ """Focus Agents panel."""
95
90
  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()
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) > 1:
108
- app._panels[1].focus()
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
- if len(app._panels) > 2:
114
- app._panels[2].focus()
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 second combined panel."""
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(0) # MCPs 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 second combined panel."""
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(1) # Tools 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 second combined panel."""
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(2) # Plugins 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
- return self.level.icon
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
- return f"[{self.level_icon}] {self.name}"
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"]