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.
Files changed (37) hide show
  1. lazyopencode/__init__.py +48 -0
  2. lazyopencode/__main__.py +6 -0
  3. lazyopencode/_version.py +34 -0
  4. lazyopencode/app.py +310 -0
  5. lazyopencode/bindings.py +27 -0
  6. lazyopencode/mixins/filtering.py +33 -0
  7. lazyopencode/mixins/help.py +74 -0
  8. lazyopencode/mixins/navigation.py +184 -0
  9. lazyopencode/models/__init__.py +17 -0
  10. lazyopencode/models/customization.py +120 -0
  11. lazyopencode/services/__init__.py +7 -0
  12. lazyopencode/services/discovery.py +350 -0
  13. lazyopencode/services/gitignore_filter.py +123 -0
  14. lazyopencode/services/parsers/__init__.py +152 -0
  15. lazyopencode/services/parsers/agent.py +93 -0
  16. lazyopencode/services/parsers/command.py +94 -0
  17. lazyopencode/services/parsers/mcp.py +67 -0
  18. lazyopencode/services/parsers/plugin.py +127 -0
  19. lazyopencode/services/parsers/rules.py +65 -0
  20. lazyopencode/services/parsers/skill.py +138 -0
  21. lazyopencode/services/parsers/tool.py +67 -0
  22. lazyopencode/styles/app.tcss +173 -0
  23. lazyopencode/themes.py +30 -0
  24. lazyopencode/widgets/__init__.py +17 -0
  25. lazyopencode/widgets/app_footer.py +71 -0
  26. lazyopencode/widgets/combined_panel.py +345 -0
  27. lazyopencode/widgets/detail_pane.py +338 -0
  28. lazyopencode/widgets/filter_input.py +88 -0
  29. lazyopencode/widgets/helpers/__init__.py +5 -0
  30. lazyopencode/widgets/helpers/rendering.py +17 -0
  31. lazyopencode/widgets/status_panel.py +70 -0
  32. lazyopencode/widgets/type_panel.py +501 -0
  33. lazyopencode-0.1.0.dist-info/METADATA +118 -0
  34. lazyopencode-0.1.0.dist-info/RECORD +37 -0
  35. lazyopencode-0.1.0.dist-info/WHEEL +4 -0
  36. lazyopencode-0.1.0.dist-info/entry_points.txt +2 -0
  37. 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