Skip to content

Commit d9d650d

Browse files
committed
✨ feat(discovery): support custom component paths from marketplace.json
Add discovery of commands, agents, skills, MCPs, and hooks from custom paths specified in marketplace.json extra_metadata. This enables plugins to define non-standard locations for their components. - Add marketplace_plugin parameter to discover_from_directory - Support both wrapped and unwrapped MCP formats for .mcp.json files - Refactor duplicate discovery logic into _discover_md_files_from_paths
1 parent 1efe569 commit d9d650d

File tree

3 files changed

+191
-4
lines changed

3 files changed

+191
-4
lines changed

src/lazyclaude/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,7 @@ def _enter_plugin_preview(self, plugin: MarketplacePlugin) -> None:
841841
is_enabled=plugin.is_enabled,
842842
)
843843
self._plugin_customizations = self._discovery_service.discover_from_directory(
844-
plugin_dir, plugin_info
844+
plugin_dir, plugin_info, marketplace_plugin=plugin
845845
)
846846
self._previewing_plugin = plugin
847847
self._plugin_preview_mode = True

src/lazyclaude/services/discovery.py

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CustomizationType,
1111
PluginInfo,
1212
)
13+
from lazyclaude.models.marketplace import MarketplacePlugin
1314
from lazyclaude.services.filesystem_scanner import (
1415
FilesystemScanner,
1516
GlobStrategy,
@@ -97,14 +98,19 @@ def refresh(self) -> list[Customization]:
9798

9899
@abstractmethod
99100
def discover_from_directory(
100-
self, plugin_dir: Path, plugin_info: PluginInfo | None = None
101+
self,
102+
plugin_dir: Path,
103+
plugin_info: PluginInfo | None = None,
104+
marketplace_plugin: MarketplacePlugin | None = None,
101105
) -> list[Customization]:
102106
"""
103107
Discover customizations from a specific directory (for plugin preview).
104108
105109
Args:
106110
plugin_dir: The directory to scan for customizations.
107111
plugin_info: Optional plugin info to attach to customizations.
112+
marketplace_plugin: Optional marketplace plugin with extra metadata
113+
containing skill paths etc.
108114
109115
Returns:
110116
List of customizations found in the directory.
@@ -194,7 +200,10 @@ def get_active_config_path(self) -> Path:
194200
return self.user_config_path
195201

196202
def discover_from_directory(
197-
self, plugin_dir: Path, plugin_info: PluginInfo | None = None
203+
self,
204+
plugin_dir: Path,
205+
plugin_info: PluginInfo | None = None,
206+
marketplace_plugin: MarketplacePlugin | None = None,
198207
) -> list[Customization]:
199208
"""Discover customizations from a specific directory (for plugin preview)."""
200209
customizations: list[Customization] = []
@@ -208,6 +217,14 @@ def discover_from_directory(
208217
plugin_scanner.scan_directory(plugin_dir, config, level, plugin_info)
209218
)
210219

220+
if marketplace_plugin:
221+
seen_paths = {c.path.resolve() for c in customizations if c.path}
222+
customizations.extend(
223+
self._discover_marketplace_components(
224+
plugin_dir, marketplace_plugin, plugin_info, seen_paths
225+
)
226+
)
227+
211228
if plugin_info:
212229
customizations.extend(self._discover_plugin_mcps(plugin_dir, plugin_info))
213230
customizations.extend(self._discover_plugin_hooks(plugin_dir, plugin_info))
@@ -224,6 +241,168 @@ def _sort_customizations(
224241
key=lambda c: (type_order[c.type], c.name.lower()),
225242
)
226243

244+
def _discover_marketplace_components(
245+
self,
246+
plugin_dir: Path,
247+
marketplace_plugin: MarketplacePlugin,
248+
plugin_info: PluginInfo | None,
249+
seen_paths: set[Path],
250+
) -> list[Customization]:
251+
"""Discover components using custom paths from marketplace.json."""
252+
customizations: list[Customization] = []
253+
extra = marketplace_plugin.extra_metadata
254+
255+
commands_paths = self._normalize_paths(extra.get("commands"))
256+
if commands_paths:
257+
cmd_parser = SlashCommandParser(plugin_dir)
258+
customizations.extend(
259+
self._discover_md_files_from_paths(
260+
cmd_parser, plugin_dir, commands_paths, plugin_info, seen_paths
261+
)
262+
)
263+
264+
agents_paths = self._normalize_paths(extra.get("agents"))
265+
if agents_paths:
266+
agent_parser = SubagentParser(plugin_dir)
267+
customizations.extend(
268+
self._discover_md_files_from_paths(
269+
agent_parser, plugin_dir, agents_paths, plugin_info, seen_paths
270+
)
271+
)
272+
273+
skills_paths = self._normalize_paths(extra.get("skills"))
274+
if skills_paths:
275+
customizations.extend(
276+
self._discover_custom_skills(
277+
plugin_dir, skills_paths, plugin_info, seen_paths
278+
)
279+
)
280+
281+
mcp_servers = extra.get("mcpServers")
282+
if mcp_servers and isinstance(mcp_servers, str):
283+
customizations.extend(
284+
self._discover_custom_mcps(plugin_dir, mcp_servers, plugin_info)
285+
)
286+
287+
hooks = extra.get("hooks")
288+
if hooks and isinstance(hooks, str):
289+
customizations.extend(
290+
self._discover_custom_hooks(plugin_dir, hooks, plugin_info)
291+
)
292+
293+
return customizations
294+
295+
@staticmethod
296+
def _normalize_paths(value: str | list[str] | None) -> list[str]:
297+
"""Normalize path value to list of strings."""
298+
if value is None:
299+
return []
300+
if isinstance(value, str):
301+
return [value]
302+
return value
303+
304+
def _discover_md_files_from_paths(
305+
self,
306+
parser: SlashCommandParser | SubagentParser,
307+
plugin_dir: Path,
308+
paths: list[str],
309+
plugin_info: PluginInfo | None,
310+
seen_paths: set[Path],
311+
) -> list[Customization]:
312+
"""Discover markdown-based customizations from custom paths."""
313+
customizations: list[Customization] = []
314+
315+
for path_str in paths:
316+
target = (plugin_dir / path_str).resolve()
317+
318+
if target.is_file() and target.suffix == ".md":
319+
if target not in seen_paths:
320+
c = parser.parse(target, ConfigLevel.PLUGIN)
321+
if plugin_info:
322+
c.plugin_info = plugin_info
323+
customizations.append(c)
324+
seen_paths.add(target)
325+
elif target.is_dir():
326+
for md_file in target.rglob("*.md"):
327+
resolved = md_file.resolve()
328+
if resolved not in seen_paths:
329+
c = parser.parse(md_file, ConfigLevel.PLUGIN)
330+
if plugin_info:
331+
c.plugin_info = plugin_info
332+
customizations.append(c)
333+
seen_paths.add(resolved)
334+
335+
return customizations
336+
337+
def _discover_custom_skills(
338+
self,
339+
plugin_dir: Path,
340+
paths: list[str],
341+
plugin_info: PluginInfo | None,
342+
seen_paths: set[Path],
343+
) -> list[Customization]:
344+
"""Discover skills from custom paths."""
345+
customizations: list[Customization] = []
346+
parser = SkillParser(plugin_dir)
347+
348+
for path_str in paths:
349+
target = (plugin_dir / path_str).resolve()
350+
351+
if target.is_dir():
352+
skill_file = target / "SKILL.md"
353+
if skill_file.is_file():
354+
resolved = skill_file.resolve()
355+
if resolved not in seen_paths:
356+
c = parser.parse(skill_file, ConfigLevel.PLUGIN)
357+
if plugin_info:
358+
c.plugin_info = plugin_info
359+
customizations.append(c)
360+
seen_paths.add(resolved)
361+
362+
return customizations
363+
364+
def _discover_custom_mcps(
365+
self,
366+
plugin_dir: Path,
367+
mcp_path: str,
368+
plugin_info: PluginInfo | None,
369+
) -> list[Customization]:
370+
"""Discover MCPs from custom path in marketplace.json."""
371+
customizations: list[Customization] = []
372+
mcp_file = (plugin_dir / mcp_path).resolve()
373+
374+
if not mcp_file.is_file():
375+
return customizations
376+
377+
parser = MCPParser()
378+
for customization in parser.parse(mcp_file, ConfigLevel.PLUGIN):
379+
if plugin_info:
380+
customization.plugin_info = plugin_info
381+
customizations.append(customization)
382+
383+
return customizations
384+
385+
def _discover_custom_hooks(
386+
self,
387+
plugin_dir: Path,
388+
hooks_path: str,
389+
plugin_info: PluginInfo | None,
390+
) -> list[Customization]:
391+
"""Discover hooks from custom path in marketplace.json."""
392+
customizations: list[Customization] = []
393+
hooks_file = (plugin_dir / hooks_path).resolve()
394+
395+
if not hooks_file.is_file():
396+
return customizations
397+
398+
parser = HookParser()
399+
for customization in parser.parse(hooks_file, ConfigLevel.PLUGIN):
400+
if plugin_info:
401+
customization.plugin_info = plugin_info
402+
customizations.append(customization)
403+
404+
return customizations
405+
227406
def _discover_memory_files(self) -> list[Customization]:
228407
"""Discover memory files from user and project levels."""
229408
customizations: list[Customization] = []

src/lazyclaude/services/parsers/mcp.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,20 @@ def parse(self, path: Path, level: ConfigLevel) -> list[Customization]: # type:
4848
)
4949
]
5050

51-
mcp_servers = data.get("mcpServers", {})
51+
# .claude.json requires wrapped {"mcpServers": {...}} format
52+
# .mcp.json and plugin configs support both wrapped {"mcpServers": {...}} and unwrapped {...} formats
53+
if path.name == ".claude.json":
54+
mcp_servers = data.get("mcpServers", {})
55+
else:
56+
mcp_servers = data.get("mcpServers", data)
57+
5258
if not mcp_servers:
5359
return []
5460

5561
customizations = []
5662
for server_name, server_config in mcp_servers.items():
63+
if not isinstance(server_config, dict):
64+
continue
5765
customizations.append(
5866
self.parse_server_config(server_name, server_config, path, level)
5967
)

0 commit comments

Comments
 (0)