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.
- lazyopencode/__init__.py +12 -1
- lazyopencode/_version.py +2 -2
- lazyopencode/app.py +132 -2
- lazyopencode/bindings.py +2 -0
- lazyopencode/mixins/help.py +2 -0
- lazyopencode/models/customization.py +35 -4
- lazyopencode/services/claude_code/__init__.py +9 -0
- lazyopencode/services/claude_code/discovery.py +158 -0
- lazyopencode/services/claude_code/parsers/__init__.py +7 -0
- lazyopencode/services/claude_code/parsers/agent.py +58 -0
- lazyopencode/services/claude_code/parsers/command.py +75 -0
- lazyopencode/services/claude_code/parsers/skill.py +130 -0
- lazyopencode/services/claude_code/plugin_loader.py +164 -0
- lazyopencode/services/discovery.py +25 -4
- lazyopencode/services/writer.py +119 -0
- lazyopencode/widgets/app_footer.py +1 -1
- lazyopencode/widgets/level_selector.py +130 -0
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.0.dist-info}/METADATA +2 -1
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.0.dist-info}/RECORD +22 -13
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.0.dist-info}/WHEEL +0 -0
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.0.dist-info}/entry_points.txt +0 -0
- {lazyopencode-0.1.1.dist-info → lazyopencode-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
86
|
-
|
|
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
|
+
)
|
|
@@ -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.
|
|
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
|