lazyopencode 0.1.1__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.
@@ -0,0 +1,130 @@
1
+ """Parser for Claude Code skill customizations."""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from lazyopencode.models.customization import (
7
+ ConfigLevel,
8
+ ConfigSource,
9
+ Customization,
10
+ CustomizationType,
11
+ SkillFile,
12
+ SkillMetadata,
13
+ )
14
+ from lazyopencode.services.parsers import parse_frontmatter
15
+
16
+ if TYPE_CHECKING:
17
+ from lazyopencode.services.gitignore_filter import GitignoreFilter
18
+
19
+
20
+ def _read_skill_files(
21
+ directory: Path,
22
+ exclude: set[str] | None = None,
23
+ gitignore_filter: "GitignoreFilter | None" = None,
24
+ ) -> list[SkillFile]:
25
+ """Recursively read all files in a skill directory."""
26
+ if exclude is None:
27
+ exclude = set()
28
+
29
+ files: list[SkillFile] = []
30
+ try:
31
+ entries = sorted(
32
+ directory.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
33
+ )
34
+ except OSError:
35
+ return files
36
+
37
+ for entry in entries:
38
+ if entry.name in exclude or entry.name.startswith("."):
39
+ continue
40
+
41
+ if entry.is_dir():
42
+ if gitignore_filter and (
43
+ gitignore_filter.should_skip_dir(entry.name)
44
+ or gitignore_filter.is_dir_ignored(entry)
45
+ ):
46
+ continue
47
+
48
+ children = _read_skill_files(entry, exclude, gitignore_filter)
49
+ files.append(
50
+ SkillFile(
51
+ name=entry.name,
52
+ path=entry,
53
+ is_directory=True,
54
+ children=children,
55
+ )
56
+ )
57
+ elif entry.is_file():
58
+ try:
59
+ content = entry.read_text(encoding="utf-8")
60
+ except (OSError, UnicodeDecodeError):
61
+ content = None
62
+ files.append(
63
+ SkillFile(
64
+ name=entry.name,
65
+ path=entry,
66
+ content=content,
67
+ )
68
+ )
69
+
70
+ return files
71
+
72
+
73
+ class SkillParser:
74
+ """Parser for skill directories.
75
+
76
+ File pattern: skills/*/SKILL.md
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ skills_dir: Path,
82
+ gitignore_filter: "GitignoreFilter | None" = None,
83
+ ) -> None:
84
+ """Initialize with the skills directory path."""
85
+ self.skills_dir = skills_dir
86
+ self._filter = gitignore_filter
87
+
88
+ def can_parse(self, path: Path) -> bool:
89
+ """Check if path is a SKILL.md file in a skill subdirectory."""
90
+ return path.name == "SKILL.md" and path.parent.parent == self.skills_dir
91
+
92
+ def parse(self, path: Path, level: ConfigLevel, source_level: str) -> Customization:
93
+ """Parse a skill SKILL.md file and detect directory contents."""
94
+ skill_dir = path.parent
95
+
96
+ try:
97
+ content = path.read_text(encoding="utf-8")
98
+ except OSError as e:
99
+ return Customization(
100
+ name=skill_dir.name,
101
+ type=CustomizationType.SKILL,
102
+ level=level,
103
+ path=path,
104
+ error=f"Failed to read file: {e}",
105
+ source=ConfigSource.CLAUDE_CODE,
106
+ source_level=source_level,
107
+ )
108
+
109
+ frontmatter, _ = parse_frontmatter(content)
110
+
111
+ name = frontmatter.get("name", skill_dir.name)
112
+ description = frontmatter.get("description")
113
+
114
+ skill_files = _read_skill_files(
115
+ skill_dir, exclude={"SKILL.md"}, gitignore_filter=self._filter
116
+ )
117
+
118
+ metadata = SkillMetadata(files=skill_files)
119
+
120
+ return Customization(
121
+ name=name,
122
+ type=CustomizationType.SKILL,
123
+ level=level,
124
+ path=path,
125
+ description=description,
126
+ content=content,
127
+ metadata=metadata.__dict__,
128
+ source=ConfigSource.CLAUDE_CODE,
129
+ source_level=source_level,
130
+ )
@@ -0,0 +1,164 @@
1
+ """Plugin loading and registry management for Claude Code plugins."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class PluginInstallation:
10
+ """Single installation of a plugin (user or project-scoped)."""
11
+
12
+ scope: str
13
+ install_path: str
14
+ version: str
15
+ is_local: bool = False
16
+ project_path: str | None = None
17
+
18
+
19
+ @dataclass
20
+ class PluginInfo:
21
+ """Information about an installed Claude Code plugin."""
22
+
23
+ plugin_id: str
24
+ short_name: str
25
+ version: str
26
+ install_path: Path
27
+ is_local: bool = False
28
+ scope: str = "user"
29
+ project_path: Path | None = None
30
+
31
+
32
+ @dataclass
33
+ class PluginRegistry:
34
+ """Container for installed plugin information."""
35
+
36
+ installed: dict[str, list[PluginInstallation]]
37
+
38
+
39
+ class PluginLoader:
40
+ """Loads plugin configuration from the Claude Code filesystem."""
41
+
42
+ def __init__(
43
+ self,
44
+ user_config_path: Path,
45
+ project_root: Path | None = None,
46
+ ) -> None:
47
+ self.user_config_path = user_config_path
48
+ self.project_root = project_root
49
+ self._registry: PluginRegistry | None = None
50
+
51
+ def load_registry(self) -> PluginRegistry:
52
+ """Load installed plugins from configuration files."""
53
+ if self._registry is not None:
54
+ return self._registry
55
+
56
+ v2_file = self.user_config_path / "plugins" / "installed_plugins.json"
57
+ installed = self._load_v2_plugins(v2_file) if v2_file.is_file() else {}
58
+
59
+ self._registry = PluginRegistry(installed=installed)
60
+ return self._registry
61
+
62
+ def _load_v2_plugins(self, path: Path) -> dict[str, list[PluginInstallation]]:
63
+ """Parse V2 format where plugins value is a list."""
64
+ if not path.is_file():
65
+ return {}
66
+ try:
67
+ data = json.loads(path.read_text(encoding="utf-8"))
68
+ plugins_data = data.get("plugins", {})
69
+ result: dict[str, list[PluginInstallation]] = {}
70
+
71
+ for plugin_id, installations in plugins_data.items():
72
+ result[plugin_id] = [
73
+ PluginInstallation(
74
+ scope=inst.get("scope", "user"),
75
+ install_path=inst.get("installPath", ""),
76
+ version=inst.get("version", "unknown"),
77
+ is_local=inst.get("isLocal", False),
78
+ project_path=inst.get("projectPath"),
79
+ )
80
+ for inst in installations
81
+ ]
82
+ return result
83
+ except (json.JSONDecodeError, OSError):
84
+ return {}
85
+
86
+ def get_all_plugins(self) -> list[PluginInfo]:
87
+ """Get list of ALL plugin infos with resolved install paths."""
88
+ registry = self.load_registry()
89
+ plugins: list[PluginInfo] = []
90
+
91
+ for plugin_id, installations in registry.installed.items():
92
+ for installation in installations:
93
+ if (
94
+ installation.scope == "user"
95
+ or installation.scope == "project"
96
+ and self._matches_current_project(installation.project_path)
97
+ ):
98
+ plugin_info = self._create_plugin_info(plugin_id, installation)
99
+ if plugin_info and plugin_info.install_path.is_dir():
100
+ plugins.append(plugin_info)
101
+
102
+ return plugins
103
+
104
+ def _matches_current_project(self, project_path: str | None) -> bool:
105
+ """Check if project_path matches current project root."""
106
+ if not project_path or not self.project_root:
107
+ return False
108
+ try:
109
+ return Path(project_path).resolve() == self.project_root.resolve()
110
+ except OSError:
111
+ return False
112
+
113
+ def refresh(self) -> None:
114
+ """Clear cached registry to force reload."""
115
+ self._registry = None
116
+
117
+ def _create_plugin_info(
118
+ self,
119
+ plugin_id: str,
120
+ installation: PluginInstallation,
121
+ ) -> PluginInfo | None:
122
+ """Create PluginInfo from installation data."""
123
+ if not installation.install_path:
124
+ return None
125
+
126
+ short_name = plugin_id.split("@")[0] if "@" in plugin_id else plugin_id
127
+ install_path = Path(installation.install_path)
128
+ version = installation.version
129
+
130
+ if not install_path.is_dir() and install_path.parent.is_dir():
131
+ install_path = self._find_latest_version_dir(install_path.parent)
132
+ version = install_path.name
133
+
134
+ project_path = (
135
+ Path(installation.project_path) if installation.project_path else None
136
+ )
137
+
138
+ return PluginInfo(
139
+ plugin_id=plugin_id,
140
+ short_name=short_name,
141
+ version=version,
142
+ install_path=install_path,
143
+ is_local=installation.is_local,
144
+ scope=installation.scope,
145
+ project_path=project_path,
146
+ )
147
+
148
+ def _find_latest_version_dir(self, parent_dir: Path) -> Path:
149
+ """Find the latest version directory in a plugin parent directory."""
150
+ try:
151
+ subdirs = [d for d in parent_dir.iterdir() if d.is_dir()]
152
+ if subdirs:
153
+ return max(subdirs, key=lambda d: self._parse_version(d.name))
154
+ except OSError:
155
+ pass
156
+ return parent_dir
157
+
158
+ @staticmethod
159
+ def _parse_version(version_str: str) -> tuple[int, ...] | tuple[str]:
160
+ """Parse version string into comparable tuple."""
161
+ try:
162
+ return tuple(int(part) for part in version_str.split("."))
163
+ except ValueError:
164
+ return (version_str,)
@@ -1,7 +1,10 @@
1
1
  """Configuration discovery service."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import json
4
6
  from pathlib import Path
7
+ from typing import TYPE_CHECKING
5
8
 
6
9
  from lazyopencode.models.customization import (
7
10
  ConfigLevel,
@@ -18,6 +21,9 @@ from lazyopencode.services.parsers.rules import RulesParser
18
21
  from lazyopencode.services.parsers.skill import SkillParser
19
22
  from lazyopencode.services.parsers.tool import ToolParser
20
23
 
24
+ if TYPE_CHECKING:
25
+ from lazyopencode.services.claude_code.discovery import ClaudeCodeDiscoveryService
26
+
21
27
 
22
28
  class ConfigDiscoveryService:
23
29
  """Discovers OpenCode customizations from filesystem."""
@@ -26,6 +32,7 @@ class ConfigDiscoveryService:
26
32
  self,
27
33
  project_root: Path | None = None,
28
34
  global_config_path: Path | None = None,
35
+ enable_claude_code: bool = False,
29
36
  ) -> None:
30
37
  """
31
38
  Initialize discovery service.
@@ -33,11 +40,13 @@ class ConfigDiscoveryService:
33
40
  Args:
34
41
  project_root: Project root directory (defaults to cwd)
35
42
  global_config_path: Global config path (defaults to ~/.config/opencode)
43
+ enable_claude_code: Enable Claude Code customization discovery
36
44
  """
37
45
  self.project_root = project_root or Path.cwd()
38
46
  self.global_config_path = global_config_path or (
39
47
  Path.home() / ".config" / "opencode"
40
48
  )
49
+ self._enable_claude_code = enable_claude_code
41
50
  self._gitignore_filter = GitignoreFilter(project_root=self.project_root)
42
51
  self._parsers = {
43
52
  CustomizationType.COMMAND: CommandParser(),
@@ -51,6 +60,13 @@ class ConfigDiscoveryService:
51
60
  CustomizationType.PLUGIN: PluginParser(),
52
61
  }
53
62
  self._cache: list[Customization] | None = None
63
+ self._claude_code_discovery: ClaudeCodeDiscoveryService | None = None
64
+ if enable_claude_code:
65
+ from lazyopencode.services.claude_code.discovery import (
66
+ ClaudeCodeDiscoveryService,
67
+ )
68
+
69
+ self._claude_code_discovery = ClaudeCodeDiscoveryService(self.project_root)
54
70
 
55
71
  @property
56
72
  def project_config_path(self) -> Path:
@@ -75,16 +91,19 @@ class ConfigDiscoveryService:
75
91
  # Discover from project config
76
92
  customizations.extend(self._discover_level(ConfigLevel.PROJECT))
77
93
 
78
- # De-duplicate by (type, name, level, resolved_path) tuple
94
+ # Discover Claude Code customizations (if enabled)
95
+ if self._claude_code_discovery:
96
+ customizations.extend(self._claude_code_discovery.discover_all())
97
+
98
+ # De-duplicate by (type, name, level, source, resolved_path) tuple
79
99
  # This avoids removing items that share a source file (like multiple
80
100
  # inline definitions from the same opencode.json)
81
101
  seen: set[tuple] = set()
82
102
  unique: list[Customization] = []
83
103
  for c in customizations:
84
104
  # Create a unique key for this customization
85
- # For file-based items, use the resolved path
86
- # For inline items, use (type, name, level) to avoid duplicates
87
- key = (c.type, c.name, c.level, str(c.path.resolve()))
105
+ # Include source to allow same-named items from different sources
106
+ key = (c.type, c.name, c.level, c.source, str(c.path.resolve()))
88
107
  if key not in seen:
89
108
  seen.add(key)
90
109
  unique.append(c)
@@ -340,6 +359,8 @@ class ConfigDiscoveryService:
340
359
  def refresh(self) -> None:
341
360
  """Clear cache and force re-discovery."""
342
361
  self._cache = None
362
+ if self._claude_code_discovery:
363
+ self._claude_code_discovery.refresh()
343
364
 
344
365
  def by_type(self, ctype: CustomizationType) -> list[Customization]:
345
366
  """Get customizations filtered by type."""
@@ -0,0 +1,119 @@
1
+ """Service for writing customizations to OpenCode directories."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from lazyopencode.models.customization import (
7
+ ConfigLevel,
8
+ Customization,
9
+ CustomizationType,
10
+ )
11
+
12
+
13
+ class CustomizationWriter:
14
+ """Writes customizations to OpenCode configuration directories."""
15
+
16
+ def __init__(
17
+ self,
18
+ global_config_path: Path,
19
+ project_config_path: Path,
20
+ ) -> None:
21
+ """Initialize writer with config paths."""
22
+ self.global_config_path = global_config_path
23
+ self.project_config_path = project_config_path
24
+
25
+ def copy_customization(
26
+ self,
27
+ customization: Customization,
28
+ target_level: ConfigLevel,
29
+ ) -> tuple[bool, str]:
30
+ """
31
+ Copy customization to target level.
32
+
33
+ Args:
34
+ customization: The customization to copy
35
+ target_level: Target configuration level (GLOBAL or PROJECT)
36
+
37
+ Returns:
38
+ Tuple of (success: bool, message: str)
39
+ """
40
+ try:
41
+ base_path = self._get_target_base_path(target_level)
42
+ target_path = self._build_target_path(customization, base_path)
43
+
44
+ if self._check_conflict(customization, target_path):
45
+ return (
46
+ False,
47
+ f"{customization.type_label} '{customization.name}' already exists at {target_level.label} level",
48
+ )
49
+
50
+ self._ensure_parent_dirs(target_path)
51
+
52
+ if customization.type == CustomizationType.SKILL:
53
+ self._copy_skill_directory(customization.path.parent, target_path)
54
+ else:
55
+ self._write_file(customization.path, target_path)
56
+
57
+ return (
58
+ True,
59
+ f"Copied '{customization.name}' to {target_level.label} level",
60
+ )
61
+
62
+ except PermissionError as e:
63
+ return (False, f"Permission denied writing to {e.filename}")
64
+ except OSError as e:
65
+ return (False, f"Failed to copy '{customization.name}': {e}")
66
+
67
+ def _get_target_base_path(self, level: ConfigLevel) -> Path:
68
+ """Get base path for target configuration level."""
69
+ if level == ConfigLevel.GLOBAL:
70
+ return self.global_config_path
71
+ elif level == ConfigLevel.PROJECT:
72
+ return self.project_config_path
73
+ else:
74
+ raise ValueError(f"Unsupported target level: {level}")
75
+
76
+ def _build_target_path(self, customization: Customization, base_path: Path) -> Path:
77
+ """Construct target file path based on customization type."""
78
+ if customization.type == CustomizationType.COMMAND:
79
+ # Handle nested commands (name:subname -> command/name/subname.md)
80
+ parts = customization.name.split(":")
81
+ if len(parts) > 1:
82
+ nested_path = Path(*parts[:-1])
83
+ filename = f"{parts[-1]}.md"
84
+ return base_path / "command" / nested_path / filename
85
+ else:
86
+ return base_path / "command" / f"{customization.name}.md"
87
+
88
+ elif customization.type == CustomizationType.AGENT:
89
+ return base_path / "agent" / f"{customization.name}.md"
90
+
91
+ elif customization.type == CustomizationType.SKILL:
92
+ return base_path / "skill" / customization.name
93
+
94
+ else:
95
+ raise ValueError(f"Unsupported customization type: {customization.type}")
96
+
97
+ def _check_conflict(self, customization: Customization, target_path: Path) -> bool:
98
+ """Check if target file or directory already exists."""
99
+ if customization.type == CustomizationType.SKILL:
100
+ return target_path.exists() and target_path.is_dir()
101
+ else:
102
+ return target_path.exists() and target_path.is_file()
103
+
104
+ def _ensure_parent_dirs(self, target_path: Path) -> None:
105
+ """Create parent directories if they don't exist."""
106
+ target_path.parent.mkdir(parents=True, exist_ok=True)
107
+
108
+ def _write_file(self, source_path: Path, target_path: Path) -> None:
109
+ """Copy file from source to target."""
110
+ content = source_path.read_text(encoding="utf-8")
111
+ target_path.write_text(content, encoding="utf-8")
112
+
113
+ def _copy_skill_directory(self, source_dir: Path, target_dir: Path) -> None:
114
+ """Copy entire skill directory tree."""
115
+ shutil.copytree(
116
+ source_dir,
117
+ target_dir,
118
+ dirs_exist_ok=False,
119
+ )
@@ -44,7 +44,7 @@ class AppFooter(Widget):
44
44
 
45
45
  return (
46
46
  f"[bold]q[/] Quit [bold]?[/] Help [bold]r[/] Refresh "
47
- f"[bold]e[/] Edit "
47
+ f"[bold]e[/] Edit [bold]c[/] Copy "
48
48
  f"{all_key} {user_key} {project_key} "
49
49
  f"{search_key} │ [bold][$accent]^p[/][/] Palette"
50
50
  )
@@ -0,0 +1,130 @@
1
+ """Level selector bar for copy operations."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.message import Message
6
+ from textual.widget import Widget
7
+ from textual.widgets import Static
8
+
9
+ from lazyopencode.models.customization import ConfigLevel
10
+
11
+
12
+ class LevelSelector(Widget):
13
+ """Bottom bar for selecting target configuration level."""
14
+
15
+ BINDINGS = [
16
+ Binding("1", "select_global", "Global", show=False),
17
+ Binding("2", "select_project", "Project", show=False),
18
+ Binding("escape", "cancel", "Cancel", show=False),
19
+ ]
20
+
21
+ DEFAULT_CSS = """
22
+ LevelSelector {
23
+ dock: bottom;
24
+ height: 3;
25
+ border: solid $accent;
26
+ padding: 0 1;
27
+ margin-bottom: 1;
28
+ display: none;
29
+ background: $surface;
30
+ }
31
+
32
+ LevelSelector.visible {
33
+ display: block;
34
+ }
35
+
36
+ LevelSelector:focus {
37
+ border: double $accent;
38
+ }
39
+
40
+ LevelSelector #prompt {
41
+ width: 100%;
42
+ text-align: center;
43
+ }
44
+
45
+ LevelSelector .key {
46
+ color: $accent;
47
+ text-style: bold;
48
+ }
49
+ """
50
+
51
+ can_focus = True
52
+
53
+ class LevelSelected(Message):
54
+ """Emitted when a level is selected."""
55
+
56
+ def __init__(self, level: ConfigLevel) -> None:
57
+ self.level = level
58
+ super().__init__()
59
+
60
+ class SelectionCancelled(Message):
61
+ """Emitted when selection is cancelled."""
62
+
63
+ pass
64
+
65
+ def __init__(
66
+ self,
67
+ name: str | None = None,
68
+ id: str | None = None,
69
+ classes: str | None = None,
70
+ ) -> None:
71
+ """Initialize LevelSelector."""
72
+ super().__init__(name=name, id=id, classes=classes)
73
+ self._available_levels: list[ConfigLevel] = []
74
+ self._customization_name: str = ""
75
+
76
+ def compose(self) -> ComposeResult:
77
+ """Compose the level selector bar."""
78
+ yield Static("", id="prompt")
79
+
80
+ def show(
81
+ self,
82
+ available_levels: list[ConfigLevel],
83
+ customization_name: str = "",
84
+ ) -> None:
85
+ """Show the level selector and focus it."""
86
+ self._available_levels = available_levels
87
+ self._customization_name = customization_name
88
+ self._update_prompt()
89
+ self.add_class("visible")
90
+ self.focus()
91
+
92
+ def hide(self) -> None:
93
+ """Hide the level selector."""
94
+ self.remove_class("visible")
95
+
96
+ def _update_prompt(self) -> None:
97
+ """Update the prompt text based on available levels."""
98
+ name_part = f'"{self._customization_name}" ' if self._customization_name else ""
99
+
100
+ options = []
101
+ if ConfigLevel.GLOBAL in self._available_levels:
102
+ options.append("\\[1] Global")
103
+ if ConfigLevel.PROJECT in self._available_levels:
104
+ options.append("\\[2] Project")
105
+
106
+ options_text = " ".join(options)
107
+ prompt_widget = self.query_one("#prompt", Static)
108
+ prompt_widget.update(f"Copy {name_part}to: {options_text} \\[Esc] Cancel")
109
+
110
+ def action_select_global(self) -> None:
111
+ """Select global level."""
112
+ if ConfigLevel.GLOBAL in self._available_levels:
113
+ self.hide()
114
+ self.post_message(self.LevelSelected(ConfigLevel.GLOBAL))
115
+
116
+ def action_select_project(self) -> None:
117
+ """Select project level."""
118
+ if ConfigLevel.PROJECT in self._available_levels:
119
+ self.hide()
120
+ self.post_message(self.LevelSelected(ConfigLevel.PROJECT))
121
+
122
+ def action_cancel(self) -> None:
123
+ """Cancel selection."""
124
+ self.hide()
125
+ self.post_message(self.SelectionCancelled())
126
+
127
+ @property
128
+ def is_visible(self) -> bool:
129
+ """Check if the level selector is visible."""
130
+ return self.has_class("visible")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lazyopencode
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: A lazygit-style TUI for visualizing OpenCode customizations
5
5
  Project-URL: Homepage, https://github.com/nikiforovall/lazyopencode
6
6
  Project-URL: Repository, https://github.com/nikiforovall/lazyopencode
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Software Development :: User Interfaces
20
20
  Requires-Python: >=3.11
21
21
  Requires-Dist: pathspec>=0.12.0
22
+ Requires-Dist: pyperclip>=1.9.0
22
23
  Requires-Dist: pyyaml>=6.0
23
24
  Requires-Dist: rich>=13.0.0
24
25
  Requires-Dist: textual>=0.89.0