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 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.1'
32
- __version_tuple__ = version_tuple = (0, 1, 1)
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
- result = [c for c in result if c.level == self._level_filter]
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),
@@ -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
@@ -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
- 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]
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
+ )