lazyopencode 0.1.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 +48 -0
- lazyopencode/__main__.py +6 -0
- lazyopencode/_version.py +34 -0
- lazyopencode/app.py +310 -0
- lazyopencode/bindings.py +27 -0
- lazyopencode/mixins/filtering.py +33 -0
- lazyopencode/mixins/help.py +74 -0
- lazyopencode/mixins/navigation.py +184 -0
- lazyopencode/models/__init__.py +17 -0
- lazyopencode/models/customization.py +120 -0
- lazyopencode/services/__init__.py +7 -0
- lazyopencode/services/discovery.py +350 -0
- lazyopencode/services/gitignore_filter.py +123 -0
- lazyopencode/services/parsers/__init__.py +152 -0
- lazyopencode/services/parsers/agent.py +93 -0
- lazyopencode/services/parsers/command.py +94 -0
- lazyopencode/services/parsers/mcp.py +67 -0
- lazyopencode/services/parsers/plugin.py +127 -0
- lazyopencode/services/parsers/rules.py +65 -0
- lazyopencode/services/parsers/skill.py +138 -0
- lazyopencode/services/parsers/tool.py +67 -0
- lazyopencode/styles/app.tcss +173 -0
- lazyopencode/themes.py +30 -0
- lazyopencode/widgets/__init__.py +17 -0
- lazyopencode/widgets/app_footer.py +71 -0
- lazyopencode/widgets/combined_panel.py +345 -0
- lazyopencode/widgets/detail_pane.py +338 -0
- lazyopencode/widgets/filter_input.py +88 -0
- lazyopencode/widgets/helpers/__init__.py +5 -0
- lazyopencode/widgets/helpers/rendering.py +17 -0
- lazyopencode/widgets/status_panel.py +70 -0
- lazyopencode/widgets/type_panel.py +501 -0
- lazyopencode-0.1.0.dist-info/METADATA +118 -0
- lazyopencode-0.1.0.dist-info/RECORD +37 -0
- lazyopencode-0.1.0.dist-info/WHEEL +4 -0
- lazyopencode-0.1.0.dist-info/entry_points.txt +2 -0
- lazyopencode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Gitignore-based file filtering with directory pruning for performance."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pathspec
|
|
6
|
+
|
|
7
|
+
DEFAULT_SKIP_DIRS = {
|
|
8
|
+
".git",
|
|
9
|
+
"node_modules",
|
|
10
|
+
".venv",
|
|
11
|
+
"venv",
|
|
12
|
+
"__pycache__",
|
|
13
|
+
".mypy_cache",
|
|
14
|
+
".pytest_cache",
|
|
15
|
+
"build",
|
|
16
|
+
"dist",
|
|
17
|
+
".eggs",
|
|
18
|
+
".tox",
|
|
19
|
+
".nox",
|
|
20
|
+
"htmlcov",
|
|
21
|
+
".idea",
|
|
22
|
+
".vscode",
|
|
23
|
+
"bin",
|
|
24
|
+
"obj",
|
|
25
|
+
".vs",
|
|
26
|
+
"packages",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
DEFAULT_IGNORE_PATTERNS = [
|
|
30
|
+
".git/",
|
|
31
|
+
"node_modules/",
|
|
32
|
+
".venv/",
|
|
33
|
+
"venv/",
|
|
34
|
+
"__pycache__/",
|
|
35
|
+
".mypy_cache/",
|
|
36
|
+
".pytest_cache/",
|
|
37
|
+
"build/",
|
|
38
|
+
"dist/",
|
|
39
|
+
".eggs/",
|
|
40
|
+
"*.egg-info/",
|
|
41
|
+
".tox/",
|
|
42
|
+
".nox/",
|
|
43
|
+
".coverage",
|
|
44
|
+
"htmlcov/",
|
|
45
|
+
".idea/",
|
|
46
|
+
".vscode/",
|
|
47
|
+
"bin/",
|
|
48
|
+
"obj/",
|
|
49
|
+
".vs/",
|
|
50
|
+
"packages/",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GitignoreFilter:
|
|
55
|
+
"""Filter for respecting gitignore patterns during file traversal."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self, project_root: Path | None = None, use_gitignore: bool = True
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Initialize filter with optional project root for .gitignore loading."""
|
|
61
|
+
self._project_root = project_root
|
|
62
|
+
self._skip_dirs = DEFAULT_SKIP_DIRS.copy()
|
|
63
|
+
self._spec: pathspec.PathSpec | None = None
|
|
64
|
+
|
|
65
|
+
patterns = DEFAULT_IGNORE_PATTERNS.copy()
|
|
66
|
+
|
|
67
|
+
if use_gitignore and project_root:
|
|
68
|
+
gitignore_patterns = self._load_gitignore(project_root)
|
|
69
|
+
patterns.extend(gitignore_patterns)
|
|
70
|
+
|
|
71
|
+
if patterns:
|
|
72
|
+
self._spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
73
|
+
|
|
74
|
+
def _load_gitignore(self, root: Path) -> list[str]:
|
|
75
|
+
"""Load and parse .gitignore file if it exists."""
|
|
76
|
+
gitignore_path = root / ".gitignore"
|
|
77
|
+
if not gitignore_path.is_file():
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with gitignore_path.open("r", encoding="utf-8") as f:
|
|
82
|
+
return [
|
|
83
|
+
line.strip()
|
|
84
|
+
for line in f
|
|
85
|
+
if line.strip() and not line.strip().startswith("#")
|
|
86
|
+
]
|
|
87
|
+
except (OSError, UnicodeDecodeError):
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
def should_skip_dir(self, dirname: str) -> bool:
|
|
91
|
+
"""Fast check if directory name should be skipped."""
|
|
92
|
+
return dirname in self._skip_dirs
|
|
93
|
+
|
|
94
|
+
def is_ignored(self, path: Path) -> bool:
|
|
95
|
+
"""Check if path matches gitignore patterns."""
|
|
96
|
+
if not self._spec:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
if not self._project_root:
|
|
100
|
+
rel_path = path
|
|
101
|
+
else:
|
|
102
|
+
try:
|
|
103
|
+
rel_path = path.relative_to(self._project_root)
|
|
104
|
+
except ValueError:
|
|
105
|
+
rel_path = path
|
|
106
|
+
|
|
107
|
+
return self._spec.match_file(str(rel_path))
|
|
108
|
+
|
|
109
|
+
def is_dir_ignored(self, dir_path: Path) -> bool:
|
|
110
|
+
"""Check if directory path matches gitignore patterns."""
|
|
111
|
+
if not self._spec:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
if not self._project_root:
|
|
115
|
+
rel_path = dir_path
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
rel_path = dir_path.relative_to(self._project_root)
|
|
119
|
+
except ValueError:
|
|
120
|
+
rel_path = dir_path
|
|
121
|
+
|
|
122
|
+
dir_str = str(rel_path) + "/"
|
|
123
|
+
return self._spec.match_file(dir_str)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Parser utilities and interface for customization files."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from lazyopencode.models.customization import ConfigLevel, Customization
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ICustomizationParser(Protocol):
|
|
13
|
+
"""Interface for customization parsers."""
|
|
14
|
+
|
|
15
|
+
def can_parse(self, path: Path) -> bool:
|
|
16
|
+
"""Check if this parser can handle the given path."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def parse(self, path: Path, level: ConfigLevel) -> Customization:
|
|
20
|
+
"""Parse a file into a Customization object."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
|
25
|
+
"""
|
|
26
|
+
Parse YAML frontmatter from markdown content.
|
|
27
|
+
|
|
28
|
+
Frontmatter is delimited by --- at the start and end.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
content: Full file content
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Tuple of (frontmatter_dict, body_content)
|
|
35
|
+
"""
|
|
36
|
+
pattern = r"^---\s*\n(.*?)\n---\s*\n?(.*)$"
|
|
37
|
+
match = re.match(pattern, content, re.DOTALL)
|
|
38
|
+
|
|
39
|
+
if not match:
|
|
40
|
+
return {}, content
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
frontmatter = yaml.safe_load(match.group(1)) or {}
|
|
44
|
+
except yaml.YAMLError:
|
|
45
|
+
frontmatter = {}
|
|
46
|
+
|
|
47
|
+
return frontmatter, match.group(2)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_synthetic_markdown(metadata: dict[str, Any], body: str) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Build a synthetic markdown string with YAML frontmatter.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
metadata: Dictionary of metadata (without the body field)
|
|
56
|
+
body: The body content
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Full markdown string with --- delimiters matching the pattern:
|
|
60
|
+
^---\\s*\n(.*?)\n---\\s*\n(.*)$
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
# Filter out empty or None values to keep it clean
|
|
64
|
+
clean_meta = {k: v for k, v in metadata.items() if v is not None}
|
|
65
|
+
if not clean_meta:
|
|
66
|
+
# Even if empty, we provide the delimiters to ensure separation in UI
|
|
67
|
+
return f"---\n---\n{body}"
|
|
68
|
+
|
|
69
|
+
frontmatter = yaml.dump(clean_meta, sort_keys=False).strip()
|
|
70
|
+
# Ensure it's not just "{}"
|
|
71
|
+
if frontmatter == "{}":
|
|
72
|
+
frontmatter = ""
|
|
73
|
+
|
|
74
|
+
# Match pattern: ^---\s*\n(.*?)\n---\s*\n(.*)$
|
|
75
|
+
# No extra newline after closing ---
|
|
76
|
+
return f"---\n{frontmatter}\n---\n{body}"
|
|
77
|
+
except Exception:
|
|
78
|
+
return body
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def strip_jsonc_comments(content: str) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Strip // comments from JSON content, preserving URLs.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
content: JSON string potentially containing comments
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Cleaned JSON string
|
|
90
|
+
"""
|
|
91
|
+
lines = []
|
|
92
|
+
for line in content.splitlines():
|
|
93
|
+
# Find // that is not part of a URL (not preceded by :)
|
|
94
|
+
pos = 0
|
|
95
|
+
while True:
|
|
96
|
+
idx = line.find("//", pos)
|
|
97
|
+
if idx == -1:
|
|
98
|
+
lines.append(line)
|
|
99
|
+
break
|
|
100
|
+
# Check if preceded by :
|
|
101
|
+
if idx > 0 and line[idx - 1] == ":":
|
|
102
|
+
pos = idx + 2
|
|
103
|
+
continue
|
|
104
|
+
# Found a comment, strip it
|
|
105
|
+
lines.append(line[:idx])
|
|
106
|
+
break
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_file_safe(path: Path) -> tuple[str | None, str | None]:
|
|
111
|
+
"""
|
|
112
|
+
Safely read a file, returning content or error.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
path: Path to file
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Tuple of (content, error) - one will be None
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
content = path.read_text(encoding="utf-8")
|
|
122
|
+
return content, None
|
|
123
|
+
except OSError as e:
|
|
124
|
+
return None, f"Failed to read file: {e}"
|
|
125
|
+
except UnicodeDecodeError as e:
|
|
126
|
+
return None, f"Encoding error: {e}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_file_references(value: str, config_dir: Path) -> str:
|
|
130
|
+
"""
|
|
131
|
+
Resolve {file:./path} patterns to actual file contents.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
value: String potentially containing {file:...} patterns
|
|
135
|
+
config_dir: Directory of the config file (for relative path resolution)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
String with file references replaced by contents or error indicator
|
|
139
|
+
"""
|
|
140
|
+
pattern = r"\{file:([^}]+)\}"
|
|
141
|
+
|
|
142
|
+
def replace(match: re.Match) -> str:
|
|
143
|
+
file_path = match.group(1)
|
|
144
|
+
resolved = (config_dir / file_path).resolve()
|
|
145
|
+
if resolved.exists() and resolved.is_file():
|
|
146
|
+
content, error = read_file_safe(resolved)
|
|
147
|
+
if content and not error:
|
|
148
|
+
return content
|
|
149
|
+
return f"[FILE READ ERROR: {file_path}]"
|
|
150
|
+
return f"[FILE NOT FOUND: {file_path}]"
|
|
151
|
+
|
|
152
|
+
return re.sub(pattern, replace, value)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Parser for Agent customizations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lazyopencode.models.customization import (
|
|
7
|
+
ConfigLevel,
|
|
8
|
+
Customization,
|
|
9
|
+
CustomizationType,
|
|
10
|
+
)
|
|
11
|
+
from lazyopencode.services.parsers import (
|
|
12
|
+
ICustomizationParser,
|
|
13
|
+
build_synthetic_markdown,
|
|
14
|
+
parse_frontmatter,
|
|
15
|
+
read_file_safe,
|
|
16
|
+
resolve_file_references,
|
|
17
|
+
strip_jsonc_comments,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AgentParser(ICustomizationParser):
|
|
22
|
+
"""Parses agent customizations from files or inline config."""
|
|
23
|
+
|
|
24
|
+
def can_parse(self, path: Path) -> bool:
|
|
25
|
+
"""Check if path is an agent markdown file."""
|
|
26
|
+
return path.is_file() and path.suffix == ".md" and path.parent.name == "agent"
|
|
27
|
+
|
|
28
|
+
def parse(self, path: Path, level: ConfigLevel) -> Customization:
|
|
29
|
+
"""Parse agent file."""
|
|
30
|
+
content, error = read_file_safe(path)
|
|
31
|
+
|
|
32
|
+
metadata = {}
|
|
33
|
+
description = None
|
|
34
|
+
|
|
35
|
+
if content and not error:
|
|
36
|
+
frontmatter, _ = parse_frontmatter(content)
|
|
37
|
+
metadata = frontmatter
|
|
38
|
+
description = frontmatter.get("description")
|
|
39
|
+
|
|
40
|
+
return Customization(
|
|
41
|
+
name=path.stem,
|
|
42
|
+
type=CustomizationType.AGENT,
|
|
43
|
+
level=level,
|
|
44
|
+
path=path,
|
|
45
|
+
description=description or f"Agent: {path.stem}",
|
|
46
|
+
metadata=metadata,
|
|
47
|
+
content=content,
|
|
48
|
+
error=error,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def parse_inline_agents(
|
|
52
|
+
self, path: Path, level: ConfigLevel
|
|
53
|
+
) -> list[Customization]:
|
|
54
|
+
"""Parse inline agents from opencode.json."""
|
|
55
|
+
content, error = read_file_safe(path)
|
|
56
|
+
if error or not content:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
customizations = []
|
|
60
|
+
try:
|
|
61
|
+
clean_content = strip_jsonc_comments(content)
|
|
62
|
+
config = json.loads(clean_content)
|
|
63
|
+
|
|
64
|
+
agents = config.get("agent", {})
|
|
65
|
+
for agent_name, agent_config in agents.items():
|
|
66
|
+
if not isinstance(agent_config, dict):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
metadata = agent_config.copy()
|
|
70
|
+
prompt = metadata.pop("prompt", "")
|
|
71
|
+
|
|
72
|
+
# Resolve {file:...} references in prompt
|
|
73
|
+
prompt = resolve_file_references(prompt, path.parent)
|
|
74
|
+
|
|
75
|
+
description = metadata.get("description")
|
|
76
|
+
|
|
77
|
+
markdown_content = build_synthetic_markdown(metadata, prompt)
|
|
78
|
+
|
|
79
|
+
customizations.append(
|
|
80
|
+
Customization(
|
|
81
|
+
name=agent_name,
|
|
82
|
+
type=CustomizationType.AGENT,
|
|
83
|
+
level=level,
|
|
84
|
+
path=path,
|
|
85
|
+
description=description or f"Agent: {agent_name}",
|
|
86
|
+
metadata=metadata,
|
|
87
|
+
content=markdown_content,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
except (json.JSONDecodeError, Exception):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return customizations
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Parser for Command customizations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lazyopencode.models.customization import (
|
|
7
|
+
ConfigLevel,
|
|
8
|
+
Customization,
|
|
9
|
+
CustomizationType,
|
|
10
|
+
)
|
|
11
|
+
from lazyopencode.services.parsers import (
|
|
12
|
+
ICustomizationParser,
|
|
13
|
+
build_synthetic_markdown,
|
|
14
|
+
parse_frontmatter,
|
|
15
|
+
read_file_safe,
|
|
16
|
+
resolve_file_references,
|
|
17
|
+
strip_jsonc_comments,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommandParser(ICustomizationParser):
|
|
22
|
+
"""Parses command customizations from files or inline config."""
|
|
23
|
+
|
|
24
|
+
def can_parse(self, path: Path) -> bool:
|
|
25
|
+
"""Check if path is a command markdown file."""
|
|
26
|
+
return path.is_file() and path.suffix == ".md" and path.parent.name == "command"
|
|
27
|
+
|
|
28
|
+
def parse(self, path: Path, level: ConfigLevel) -> Customization:
|
|
29
|
+
"""Parse command file."""
|
|
30
|
+
content, error = read_file_safe(path)
|
|
31
|
+
|
|
32
|
+
metadata = {}
|
|
33
|
+
description = None
|
|
34
|
+
|
|
35
|
+
if content and not error:
|
|
36
|
+
frontmatter, _ = parse_frontmatter(content)
|
|
37
|
+
metadata = frontmatter
|
|
38
|
+
description = frontmatter.get("description")
|
|
39
|
+
|
|
40
|
+
return Customization(
|
|
41
|
+
name=path.stem,
|
|
42
|
+
type=CustomizationType.COMMAND,
|
|
43
|
+
level=level,
|
|
44
|
+
path=path,
|
|
45
|
+
description=description or f"Command: {path.stem}",
|
|
46
|
+
metadata=metadata,
|
|
47
|
+
content=content,
|
|
48
|
+
error=error,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def parse_inline_commands(
|
|
52
|
+
self, path: Path, level: ConfigLevel
|
|
53
|
+
) -> list[Customization]:
|
|
54
|
+
"""Parse inline commands from opencode.json."""
|
|
55
|
+
content, error = read_file_safe(path)
|
|
56
|
+
if error or not content:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
customizations = []
|
|
60
|
+
try:
|
|
61
|
+
clean_content = strip_jsonc_comments(content)
|
|
62
|
+
config = json.loads(clean_content)
|
|
63
|
+
|
|
64
|
+
commands = config.get("command", {})
|
|
65
|
+
for cmd_name, cmd_config in commands.items():
|
|
66
|
+
if not isinstance(cmd_config, dict):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
# Create a copy to avoid mutating the original
|
|
70
|
+
metadata = cmd_config.copy()
|
|
71
|
+
template = metadata.pop("template", "")
|
|
72
|
+
|
|
73
|
+
# Resolve {file:...} references in template
|
|
74
|
+
template = resolve_file_references(template, path.parent)
|
|
75
|
+
|
|
76
|
+
description = metadata.get("description")
|
|
77
|
+
|
|
78
|
+
markdown_content = build_synthetic_markdown(metadata, template)
|
|
79
|
+
|
|
80
|
+
customizations.append(
|
|
81
|
+
Customization(
|
|
82
|
+
name=cmd_name,
|
|
83
|
+
type=CustomizationType.COMMAND,
|
|
84
|
+
level=level,
|
|
85
|
+
path=path,
|
|
86
|
+
description=description or f"Command: {cmd_name}",
|
|
87
|
+
metadata=metadata,
|
|
88
|
+
content=markdown_content,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
except (json.JSONDecodeError, Exception):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
return customizations
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Parser for MCP customizations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lazyopencode.models.customization import (
|
|
7
|
+
ConfigLevel,
|
|
8
|
+
Customization,
|
|
9
|
+
CustomizationType,
|
|
10
|
+
)
|
|
11
|
+
from lazyopencode.services.parsers import (
|
|
12
|
+
ICustomizationParser,
|
|
13
|
+
read_file_safe,
|
|
14
|
+
strip_jsonc_comments,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MCPParser(ICustomizationParser):
|
|
19
|
+
"""Parses MCP configurations from opencode.json."""
|
|
20
|
+
|
|
21
|
+
def can_parse(self, path: Path) -> bool:
|
|
22
|
+
"""Check if path is opencode.json file."""
|
|
23
|
+
return path.is_file() and path.name == "opencode.json"
|
|
24
|
+
|
|
25
|
+
def parse(self, path: Path, level: ConfigLevel) -> Customization:
|
|
26
|
+
"""
|
|
27
|
+
Parse MCP file.
|
|
28
|
+
Note: This returns a 'container' customization for the file itself.
|
|
29
|
+
Real logic for individual MCPs is in parse_mcps.
|
|
30
|
+
"""
|
|
31
|
+
return Customization(
|
|
32
|
+
name="opencode.json",
|
|
33
|
+
type=CustomizationType.MCP,
|
|
34
|
+
level=level,
|
|
35
|
+
path=path,
|
|
36
|
+
description="MCP Configuration File",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def parse_mcps(self, path: Path, level: ConfigLevel) -> list[Customization]:
|
|
40
|
+
"""Parse opencode.json and return list of MCP customizations."""
|
|
41
|
+
content, error = read_file_safe(path)
|
|
42
|
+
if error or not content:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
customizations = []
|
|
46
|
+
try:
|
|
47
|
+
clean_content = strip_jsonc_comments(content)
|
|
48
|
+
config = json.loads(clean_content)
|
|
49
|
+
|
|
50
|
+
mcps = config.get("mcp", {})
|
|
51
|
+
for mcp_name, mcp_config in mcps.items():
|
|
52
|
+
mcp_type = mcp_config.get("type", "unknown")
|
|
53
|
+
customizations.append(
|
|
54
|
+
Customization(
|
|
55
|
+
name=mcp_name,
|
|
56
|
+
type=CustomizationType.MCP,
|
|
57
|
+
level=level,
|
|
58
|
+
path=path,
|
|
59
|
+
description=f"MCP ({mcp_type})",
|
|
60
|
+
metadata=mcp_config,
|
|
61
|
+
content=json.dumps(mcp_config, indent=2),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
except (json.JSONDecodeError, Exception):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
return customizations
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Parser for Plugin customizations."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lazyopencode.models.customization import (
|
|
7
|
+
ConfigLevel,
|
|
8
|
+
Customization,
|
|
9
|
+
CustomizationType,
|
|
10
|
+
)
|
|
11
|
+
from lazyopencode.services.parsers import (
|
|
12
|
+
ICustomizationParser,
|
|
13
|
+
read_file_safe,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PluginParser(ICustomizationParser):
|
|
18
|
+
"""Parses plugin customizations from TypeScript/JavaScript files.
|
|
19
|
+
|
|
20
|
+
Plugins are JS/TS modules that export plugin functions. Each function
|
|
21
|
+
receives a context object and returns a hooks object to extend OpenCode.
|
|
22
|
+
|
|
23
|
+
Paths:
|
|
24
|
+
- Global: ~/.config/opencode/plugin/*.ts or *.js
|
|
25
|
+
- Project: .opencode/plugin/*.ts or *.js
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
VALID_EXTENSIONS = {".ts", ".js"}
|
|
29
|
+
|
|
30
|
+
def can_parse(self, path: Path) -> bool:
|
|
31
|
+
"""Check if path is a plugin file."""
|
|
32
|
+
return (
|
|
33
|
+
path.is_file()
|
|
34
|
+
and path.suffix in self.VALID_EXTENSIONS
|
|
35
|
+
and path.parent.name == "plugin"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def parse(self, path: Path, level: ConfigLevel) -> Customization:
|
|
39
|
+
"""Parse plugin file - shows source code as preview."""
|
|
40
|
+
content, error = read_file_safe(path)
|
|
41
|
+
|
|
42
|
+
description = None
|
|
43
|
+
exports: list[str] = []
|
|
44
|
+
hooks: list[str] = []
|
|
45
|
+
|
|
46
|
+
if content and not error:
|
|
47
|
+
exports = self._extract_exports(content)
|
|
48
|
+
hooks = self._extract_hooks(content)
|
|
49
|
+
description = self._build_description(exports, hooks)
|
|
50
|
+
|
|
51
|
+
return Customization(
|
|
52
|
+
name=path.stem,
|
|
53
|
+
type=CustomizationType.PLUGIN,
|
|
54
|
+
level=level,
|
|
55
|
+
path=path,
|
|
56
|
+
description=description or f"Plugin: {path.stem}",
|
|
57
|
+
content=content,
|
|
58
|
+
metadata={
|
|
59
|
+
"exports": exports,
|
|
60
|
+
"hooks": hooks,
|
|
61
|
+
},
|
|
62
|
+
error=error,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _extract_exports(self, content: str) -> list[str]:
|
|
66
|
+
"""
|
|
67
|
+
Extract exported plugin names from the file.
|
|
68
|
+
|
|
69
|
+
Looks for patterns like:
|
|
70
|
+
- export const MyPlugin = ...
|
|
71
|
+
- export function MyPlugin ...
|
|
72
|
+
- export { MyPlugin }
|
|
73
|
+
"""
|
|
74
|
+
exports: list[str] = []
|
|
75
|
+
|
|
76
|
+
# Match: export const/let/var Name =
|
|
77
|
+
const_pattern = r"export\s+(?:const|let|var)\s+(\w+)\s*[=:]"
|
|
78
|
+
exports.extend(re.findall(const_pattern, content))
|
|
79
|
+
|
|
80
|
+
# Match: export function Name
|
|
81
|
+
func_pattern = r"export\s+function\s+(\w+)"
|
|
82
|
+
exports.extend(re.findall(func_pattern, content))
|
|
83
|
+
|
|
84
|
+
# Match: export { Name, Name2 }
|
|
85
|
+
named_pattern = r"export\s*\{\s*([^}]+)\s*\}"
|
|
86
|
+
for match in re.findall(named_pattern, content):
|
|
87
|
+
names = [n.strip().split(" as ")[0].strip() for n in match.split(",")]
|
|
88
|
+
exports.extend(n for n in names if n)
|
|
89
|
+
|
|
90
|
+
return list(set(exports))
|
|
91
|
+
|
|
92
|
+
def _extract_hooks(self, content: str) -> list[str]:
|
|
93
|
+
"""
|
|
94
|
+
Extract hook names that the plugin subscribes to.
|
|
95
|
+
|
|
96
|
+
Looks for patterns like:
|
|
97
|
+
- "session.idle": async (...)
|
|
98
|
+
- 'tool.execute.before': (...)
|
|
99
|
+
- event: async ({ event }) => { ... }
|
|
100
|
+
"""
|
|
101
|
+
hooks: list[str] = []
|
|
102
|
+
|
|
103
|
+
# Match quoted hook names like "session.idle" or 'tool.execute.before'
|
|
104
|
+
quoted_pattern = r'["\']([a-z]+(?:\.[a-z]+)+)["\']:\s*(?:async\s*)?\('
|
|
105
|
+
hooks.extend(re.findall(quoted_pattern, content))
|
|
106
|
+
|
|
107
|
+
# Match 'event' handler
|
|
108
|
+
if re.search(r"\bevent\s*:\s*(?:async\s*)?\(", content):
|
|
109
|
+
hooks.append("event")
|
|
110
|
+
|
|
111
|
+
# Match 'tool' object for custom tools
|
|
112
|
+
if re.search(r"\btool\s*:\s*\{", content):
|
|
113
|
+
hooks.append("tool (custom tools)")
|
|
114
|
+
|
|
115
|
+
return list(set(hooks))
|
|
116
|
+
|
|
117
|
+
def _build_description(self, exports: list[str], hooks: list[str]) -> str | None:
|
|
118
|
+
"""Build a description from extracted info."""
|
|
119
|
+
parts: list[str] = []
|
|
120
|
+
|
|
121
|
+
if exports:
|
|
122
|
+
parts.append(f"Exports: {', '.join(sorted(exports))}")
|
|
123
|
+
|
|
124
|
+
if hooks:
|
|
125
|
+
parts.append(f"Hooks: {', '.join(sorted(hooks))}")
|
|
126
|
+
|
|
127
|
+
return " | ".join(parts) if parts else None
|