diff --git a/AGENTS.md b/AGENTS.md index 07f8101..8a3443e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ lola install -a claude-code 2. **Installation**: `lola install ` copies modules to project's `.lola/modules/` and generates assistant-specific files 3. **Updates**: `lola update` regenerates assistant files from source modules 4. **Marketplace Registration**: `lola market add ` fetches marketplace catalogs to `~/.lola/market/` (reference) and `~/.lola/market/cache/` (full catalog) -5. **Module Discovery**: `lola mod search ` searches across enabled marketplace caches; `lola install ` auto-adds from marketplace if not in registry +5. **Module Discovery**: `lola search ` searches both the local module registry and enabled marketplace caches (use `--mod` or `--market` to scope); `lola mod search ` is a deprecated alias for `lola search --mod`; `lola install ` auto-adds from marketplace if not in registry ### Installation Scopes diff --git a/CLAUDE.md b/CLAUDE.md index 1977db7..a559b44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,8 @@ - 001-mod-init-template: Added Python 3.13 + click, rich, pyyaml, python-frontmatter - 003-marketplace: Added complete marketplace feature - `lola market add/ls/update/set/rm` commands for marketplace management - - `lola mod search` for cross-marketplace module discovery + - `lola search ` for unified discovery across the local registry and enabled marketplaces (`--mod` / `--market` to scope) + - `lola mod search ` as a deprecated compatibility alias for `lola search --mod` - Auto-install from marketplaces via `lola install ` - Multi-marketplace conflict resolution with user prompts - Cache recovery on missing cache files diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 7d3a9aa..3f03cdc 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -9,7 +9,6 @@ Complete command reference for the Lola CLI. Use `lola --help` or `lola ` | Add a module from git, folder, zip, or tar | | `lola mod ls` | List registered modules | | `lola mod info ` | Show module details | -| `lola mod search ` | Search across enabled marketplaces | | `lola mod init [name]` | Initialize a new module | | `lola mod update [name]` | Update module(s) from source | | `lola mod rm ` | Remove a module | @@ -25,6 +24,15 @@ Complete command reference for the Lola CLI. Use `lola --help` or `lola ` | Disable a marketplace | | `lola market rm ` | Remove a marketplace | +## Search + +| Command | Description | +| -------------------------------- | ----------------------------------------------------------------- | +| `lola search ` | Search the local registry and enabled marketplaces | +| `lola search --mod` | Search only the local module registry | +| `lola search --market` | Search only enabled marketplaces | +| `lola mod search ` | Deprecated alias for `lola search --mod` | + ## Installation | Command | Description | diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 148d971..f8d5d4b 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -10,7 +10,7 @@ lola market add general https://raw.githubusercontent.com/RedHatProductSecurity/ ```bash # From the marketplace -lola mod search git +lola search git lola install git-workflow # Or from a git repository diff --git a/docs/guides/marketplace.md b/docs/guides/marketplace.md index 27d2c44..08c9093 100644 --- a/docs/guides/marketplace.md +++ b/docs/guides/marketplace.md @@ -13,8 +13,11 @@ lola market add general https://raw.githubusercontent.com/RedHatProductSecurity/ ## Search and Install ```bash -# Search across all enabled marketplaces -lola mod search authentication +# Search the local registry and all enabled marketplaces +lola search authentication + +# Limit to enabled marketplaces only +lola search authentication --market # Install directly from marketplace (auto-adds and installs) lola install git-workflow -a claude-code diff --git a/src/lola/__main__.py b/src/lola/__main__.py index 552d463..4fc4253 100644 --- a/src/lola/__main__.py +++ b/src/lola/__main__.py @@ -16,6 +16,7 @@ ) from lola.cli.market import market from lola.cli.mod import mod +from lola.cli.search import search_cmd from lola.cli.sync import sync_cmd console = Console() @@ -66,6 +67,7 @@ def main(ctx, version): main.add_command(uninstall_cmd) main.add_command(update_cmd) main.add_command(list_installed_cmd) +main.add_command(search_cmd) main.add_command(sync_cmd) main.add_command(completions_cmd) diff --git a/src/lola/cli/mod.py b/src/lola/cli/mod.py index 8b75e05..fd89d57 100644 --- a/src/lola/cli/mod.py +++ b/src/lola/cli/mod.py @@ -79,7 +79,7 @@ def list_registered_modules() -> list[Module]: return sorted(modules, key=lambda m: m.name) -def _count_str(count: int, singular: str) -> str: +def count_str(count: int, singular: str) -> str: """Format count with singular/plural form.""" return f"{count} {singular}" if count == 1 else f"{count} {singular}s" @@ -850,9 +850,9 @@ def list_modules(verbose: bool): for module in modules: console.print(f"[cyan]{module.name}[/cyan]") - skills_str = _count_str(len(module.skills), "skill") - cmds_str = _count_str(len(module.commands), "command") - agents_str = _count_str(len(module.agents), "agent") + skills_str = count_str(len(module.skills), "skill") + cmds_str = count_str(len(module.commands), "command") + agents_str = count_str(len(module.agents), "agent") console.print(f" [dim]{skills_str}, {cmds_str}, {agents_str}[/dim]") if verbose: @@ -1132,10 +1132,10 @@ def update_module_cmd(module_name: str | None): console.print() if updated > 0: - console.print(f"[green]Updated {_count_str(updated, 'module')}[/green]") + console.print(f"[green]Updated {count_str(updated, 'module')}[/green]") if failed > 0: console.print( - f"[yellow]Failed to update {_count_str(failed, 'module')}[/yellow]" + f"[yellow]Failed to update {count_str(failed, 'module')}[/yellow]" ) if updated > 0: @@ -1145,19 +1145,20 @@ def update_module_cmd(module_name: str | None): @mod.command(name="search") @click.argument("query") -def mod_search(query: str): +@click.pass_context +def search_compat_cmd(ctx: click.Context, query: str): """ - Search for modules across all enabled marketplaces. + Search the local module registry (compatibility alias). - QUERY: Search term to match against module name, description, tags + Deprecated: prefer 'lola search --mod'. This forwards to + 'lola search --mod' so existing scripts keep working. - \b - Example: - lola mod search git + QUERY: Search term to match """ - from lola.config import MARKET_DIR, CACHE_DIR - from lola.market.manager import MarketplaceRegistry + from lola.cli.search import search_cmd - ensure_lola_dirs() - registry = MarketplaceRegistry(MARKET_DIR, CACHE_DIR) - registry.search(query) + # Warn on stderr so stdout stays clean for scripts parsing the results. + click.echo( + "'lola mod search' is deprecated; use 'lola search --mod' instead", err=True + ) + ctx.invoke(search_cmd, query=query, mod=True, market=False) diff --git a/src/lola/cli/search.py b/src/lola/cli/search.py new file mode 100644 index 0000000..a53931a --- /dev/null +++ b/src/lola/cli/search.py @@ -0,0 +1,106 @@ +""" +Top-level search command. + +Searches both the local module registry and all enabled marketplace caches. +""" + +import click +from rich.console import Console +from rich.table import Table + +from lola.cli.mod import count_str, list_registered_modules +from lola.config import CACHE_DIR, MARKET_DIR +from lola.market.search import search_market +from lola.models import Module + +console = Console() + + +def _search_local(query_lower: str) -> list[Module]: + results: list[Module] = [] + for module in list_registered_modules(): + haystack = [module.name, *module.skills, *module.commands, *module.agents] + if any(query_lower in item.lower() for item in haystack): + results.append(module) + return results + + +def _print_local(results: list[Module]) -> None: + console.print( + f"[bold]Local registry ({count_str(len(results), 'module')})[/bold]\n" + ) + for module in results: + console.print(f" [cyan]{module.name}[/cyan]") + skills_str = count_str(len(module.skills), "skill") + cmds_str = count_str(len(module.commands), "command") + agents_str = count_str(len(module.agents), "agent") + console.print(f" [dim]{skills_str}, {cmds_str}, {agents_str}[/dim]") + console.print() + + +def _print_marketplace(results: list[dict]) -> None: + console.print(f"[bold]Marketplaces ({count_str(len(results), 'module')})[/bold]\n") + table = Table(show_header=True, header_style="bold") + table.add_column("Module") + table.add_column("Version") + table.add_column("Marketplace") + table.add_column("Description") + for r in results: + table.add_row(r["name"], r["version"], r["marketplace"], r["description"]) + console.print(table) + console.print() + + +@click.command(name="search") +@click.argument("query") +@click.option("--mod", is_flag=True, help="Search only the local module registry") +@click.option("--market", is_flag=True, help="Search only enabled marketplaces") +def search_cmd(query: str, mod: bool, market: bool): + """ + Search modules in the local registry and enabled marketplaces. + + Local matches are by module name, skill name, command name, or agent name. + Marketplace matches are by module name, description, or tag. + + QUERY: Search term to match + + \b + Examples: + lola search git # search both local and marketplaces + lola search git --mod # only the local module registry + lola search git --market # only enabled marketplaces + """ + if mod and market: + click.echo("Error: --mod and --market are mutually exclusive") + raise SystemExit(1) + + query_lower = query.lower() + + show_local = not market + show_remote = not mod + + local_results = _search_local(query_lower) if show_local else [] + market_results = search_market(query, MARKET_DIR, CACHE_DIR) if show_remote else [] + + total = len(local_results) + len(market_results) + if total == 0: + console.print(f"[yellow]No modules found matching '{query}'[/yellow]") + if not show_remote: + console.print( + "[dim]Tip: drop --mod to also search remote marketplaces[/dim]" + ) + elif not show_local: + console.print( + "[dim]Tip: drop --market to also search the local registry[/dim]" + ) + else: + console.print( + "[dim]Tip: check spelling or add a marketplace with 'lola market add'[/dim]" + ) + return + + console.print() + if local_results: + _print_local(local_results) + if market_results: + _print_marketplace(market_results) diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py new file mode 100644 index 0000000..c3694ff --- /dev/null +++ b/tests/test_cli_search.py @@ -0,0 +1,308 @@ +"""Tests for the top-level `lola search` CLI command.""" + +import pytest +import yaml + +from lola.cli.search import search_cmd + + +@pytest.fixture +def search_env(tmp_path, monkeypatch): + """Patch MARKET_DIR/CACHE_DIR for `lola search` and yield the dirs.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + monkeypatch.setattr("lola.cli.search.MARKET_DIR", market_dir) + monkeypatch.setattr("lola.cli.search.CACHE_DIR", cache_dir) + + return market_dir, cache_dir + + +def _write_marketplace(market_dir, cache_dir, name="official", modules=None): + """Create a minimal enabled marketplace with the given modules.""" + if modules is None: + modules = [ + { + "name": "git-tools", + "description": "Git utilities", + "version": "1.0.0", + "repository": "https://github.com/test/git-tools.git", + "tags": ["git", "vcs"], + }, + { + "name": "python-utils", + "description": "Python helpers", + "version": "1.2.0", + "repository": "https://github.com/test/python-utils.git", + "tags": ["python"], + }, + ] + + ref = { + "name": name, + "url": f"https://example.com/{name}.yml", + "enabled": True, + } + cache = { + "name": name.title(), + "description": f"{name} catalog", + "version": "1.0.0", + "url": f"https://example.com/{name}.yml", + "enabled": True, + "modules": modules, + } + + with open(market_dir / f"{name}.yml", "w") as f: + yaml.dump(ref, f) + with open(cache_dir / f"{name}.yml", "w") as f: + yaml.dump(cache, f) + + +class TestSearchHelp: + """Tests for the search command help/registration.""" + + def test_search_help(self, cli_runner): + """Show search help.""" + result = cli_runner.invoke(search_cmd, ["--help"]) + assert result.exit_code == 0 + assert "Search modules" in result.output + assert "--mod" in result.output + assert "--market" in result.output + + def test_search_requires_query(self, cli_runner, mock_lola_home): + """Fail when query argument missing.""" + result = cli_runner.invoke(search_cmd, []) + assert result.exit_code != 0 + assert "Missing argument" in result.output or "Usage" in result.output + + def test_search_registered_on_main(self): + """Top-level `lola search` is wired up via __main__.""" + from lola.__main__ import main + + assert "search" in main.commands + + +class TestSearchLocal: + """Tests for local registry search.""" + + def test_finds_module_by_name( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """Match a local module by its name.""" + result = cli_runner.invoke(search_cmd, ["sample"]) + + assert result.exit_code == 0 + assert "Local registry" in result.output + assert "sample-module" in result.output + assert "1 skill" in result.output + assert "1 command" in result.output + assert "1 agent" in result.output + + def test_finds_module_by_skill_name( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """Match a local module by the name of one of its skills.""" + result = cli_runner.invoke(search_cmd, ["skill1"]) + + assert result.exit_code == 0 + assert "sample-module" in result.output + + def test_finds_module_by_command_name( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """Match a local module by the name of one of its commands.""" + result = cli_runner.invoke(search_cmd, ["cmd1"]) + + assert result.exit_code == 0 + assert "sample-module" in result.output + + def test_finds_module_by_agent_name( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """Match a local module by the name of one of its agents.""" + result = cli_runner.invoke(search_cmd, ["agent1"]) + + assert result.exit_code == 0 + assert "sample-module" in result.output + + def test_local_match_is_case_insensitive( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """Local search matches case-insensitively.""" + result = cli_runner.invoke(search_cmd, ["SAMPLE"]) + + assert result.exit_code == 0 + assert "sample-module" in result.output + + +class TestSearchRemote: + """Tests for marketplace (remote) search.""" + + def test_finds_remote_module(self, cli_runner, mock_lola_home, search_env): + """Match a marketplace module by name.""" + market_dir, cache_dir = search_env + _write_marketplace(market_dir, cache_dir) + + result = cli_runner.invoke(search_cmd, ["git"]) + + assert result.exit_code == 0 + assert "Marketplaces" in result.output + assert "git-tools" in result.output + assert "official" in result.output + + def test_remote_match_by_tag(self, cli_runner, mock_lola_home, search_env): + """Match a marketplace module by tag.""" + market_dir, cache_dir = search_env + _write_marketplace(market_dir, cache_dir) + + result = cli_runner.invoke(search_cmd, ["vcs"]) + + assert result.exit_code == 0 + assert "git-tools" in result.output + + +class TestSearchScopeFlags: + """Tests for --mod and --market flags.""" + + def test_mod_flag_skips_marketplaces( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """--mod restricts search to the local registry.""" + market_dir, cache_dir = search_env + _write_marketplace(market_dir, cache_dir) + + # "git" only matches the marketplace module; --mod must filter it out + result = cli_runner.invoke(search_cmd, ["git", "--mod"]) + + assert result.exit_code == 0 + assert "Marketplaces" not in result.output + assert "git-tools" not in result.output + + def test_market_flag_skips_local( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """--market restricts search to enabled marketplaces.""" + market_dir, cache_dir = search_env + _write_marketplace(market_dir, cache_dir) + + # "sample" only matches the local module; --market must filter it out + result = cli_runner.invoke(search_cmd, ["sample", "--market"]) + + assert result.exit_code == 0 + assert "Local registry" not in result.output + assert "sample-module" not in result.output + + def test_mod_and_market_mutually_exclusive( + self, cli_runner, mock_lola_home, search_env + ): + """--mod and --market together produce an explicit error.""" + result = cli_runner.invoke(search_cmd, ["git", "--mod", "--market"]) + + assert result.exit_code == 1 + assert "mutually exclusive" in result.output + + def test_default_searches_both_scopes( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """With no flag, both local and remote are searched.""" + market_dir, cache_dir = search_env + _write_marketplace( + market_dir, + cache_dir, + modules=[ + { + "name": "sample-remote", + "description": "Sample remote", + "version": "1.0.0", + "repository": "https://example.com/sample.git", + } + ], + ) + + result = cli_runner.invoke(search_cmd, ["sample"]) + + assert result.exit_code == 0 + assert "Local registry" in result.output + assert "sample-module" in result.output + assert "Marketplaces" in result.output + assert "sample-remote" in result.output + + +class TestSearchNoMatches: + """Tests for the empty-results path.""" + + def test_no_match_default_scope(self, cli_runner, mock_lola_home, search_env): + """Show generic tip when neither scope matches.""" + result = cli_runner.invoke(search_cmd, ["definitely-not-a-module"]) + + assert result.exit_code == 0 + assert "No modules found" in result.output + assert "definitely-not-a-module" in result.output + assert "check spelling" in result.output + + def test_no_match_mod_only_tip(self, cli_runner, mock_lola_home, search_env): + """Show 'drop --mod' hint when --mod yields nothing.""" + result = cli_runner.invoke(search_cmd, ["anything", "--mod"]) + + assert result.exit_code == 0 + assert "No modules found" in result.output + assert "drop --mod" in result.output + + def test_no_match_market_only_tip(self, cli_runner, mock_lola_home, search_env): + """Show 'drop --market' hint when --market yields nothing.""" + result = cli_runner.invoke(search_cmd, ["anything", "--market"]) + + assert result.exit_code == 0 + assert "No modules found" in result.output + assert "drop --market" in result.output + + +class TestModSearchCompat: + """Tests for the `lola mod search` compatibility alias.""" + + def test_mod_search_subcommand_registered(self, cli_runner, mock_lola_home): + """`lola mod search` remains registered as a compatibility alias.""" + from lola.cli.mod import mod + + assert "search" in mod.commands + + def test_mod_search_forwards_to_local_scope( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """`lola mod search` searches the local registry only (maps to --mod).""" + from lola.cli.mod import mod + + market_dir, cache_dir = search_env + _write_marketplace(market_dir, cache_dir) + + # "git" only matches the marketplace module; mod search must filter it out + result = cli_runner.invoke(mod, ["search", "git"]) + + assert result.exit_code == 0 + assert "Marketplaces" not in result.output + assert "git-tools" not in result.output + + def test_mod_search_finds_local_module( + self, cli_runner, mock_lola_home, registered_module, search_env + ): + """`lola mod search` returns matching local modules.""" + from lola.cli.mod import mod + + result = cli_runner.invoke(mod, ["search", "sample"]) + + assert result.exit_code == 0 + assert "Local registry" in result.output + assert "sample-module" in result.output + + def test_mod_search_emits_deprecation_notice( + self, cli_runner, mock_lola_home, search_env + ): + """`lola mod search` warns that it is deprecated.""" + from lola.cli.mod import mod + + result = cli_runner.invoke(mod, ["search", "anything"]) + + assert result.exit_code == 0 + assert "deprecated" in result.output