From 2098a2a3a3486f2139e4d9f22a50990647acecf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 12:17:17 +0000 Subject: [PATCH 1/7] refactor(cli): split search into `mod search` and `market search` `lola mod search` previously searched remote marketplaces, which was a confusing name given the command lives under `mod`. Move that behavior to `lola market search` (where it belongs) and repurpose `lola mod search` to search the local module registry by name, skill, command, or agent. Breaking change: scripts using `lola mod search` to search marketplaces must switch to `lola market search`. https://claude.ai/code/session_01EkBsZCC4Uo5MCLyay9mmMn --- AGENTS.md | 2 +- CLAUDE.md | 4 ++-- docs/cli-reference/index.md | 3 ++- docs/getting-started/quick-start.md | 2 +- docs/guides/marketplace.md | 2 +- src/lola/cli/market.py | 16 +++++++++++++ src/lola/cli/mod.py | 37 +++++++++++++++++++++++------ 7 files changed, 53 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 07f8101..2a296ec 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 market search ` searches across enabled marketplace caches; `lola mod search ` searches the local module registry; `lola install ` auto-adds from marketplace if not in registry ### Installation Scopes diff --git a/CLAUDE.md b/CLAUDE.md index 1977db7..2cfbb24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,8 @@ ## Recent Changes - 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 market add/ls/update/set/rm/search` commands for marketplace management + - `lola market search` for cross-marketplace module discovery; `lola mod search` for local registry lookup - 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..06b0a57 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -9,7 +9,7 @@ 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 search ` | Search registered modules in the local registry | | `lola mod init [name]` | Initialize a new module | | `lola mod update [name]` | Update module(s) from source | | `lola mod rm ` | Remove a module | @@ -20,6 +20,7 @@ Complete command reference for the Lola CLI. Use `lola --help` or `lola ` | Register a marketplace | | `lola market ls` | List registered marketplaces | +| `lola market search ` | Search across enabled marketplaces | | `lola market update [name]` | Update marketplace cache | | `lola market set --enable ` | Enable a marketplace | | `lola market set --disable ` | Disable a marketplace | diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 148d971..6b0fc3a 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 market 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..139c63d 100644 --- a/docs/guides/marketplace.md +++ b/docs/guides/marketplace.md @@ -14,7 +14,7 @@ lola market add general https://raw.githubusercontent.com/RedHatProductSecurity/ ```bash # Search across all enabled marketplaces -lola mod search authentication +lola market search authentication # Install directly from marketplace (auto-adds and installs) lola install git-workflow -a claude-code diff --git a/src/lola/cli/market.py b/src/lola/cli/market.py index aacccc1..0c9bd84 100644 --- a/src/lola/cli/market.py +++ b/src/lola/cli/market.py @@ -151,3 +151,19 @@ def market_update(name: str, update_all: bool): return registry.update() + + +@market.command(name="search") +@click.argument("query") +def market_search(query: str): + """ + Search for modules across all enabled marketplaces. + + QUERY: Search term to match against module name, description, tags + + \b + Example: + lola market search git + """ + registry = MarketplaceRegistry(MARKET_DIR, CACHE_DIR) + registry.search(query) diff --git a/src/lola/cli/mod.py b/src/lola/cli/mod.py index 8b75e05..8b3dcaa 100644 --- a/src/lola/cli/mod.py +++ b/src/lola/cli/mod.py @@ -1147,17 +1147,40 @@ def update_module_cmd(module_name: str | None): @click.argument("query") def mod_search(query: str): """ - Search for modules across all enabled marketplaces. + Search registered modules in the local registry. - QUERY: Search term to match against module name, description, tags + Matches QUERY against module name, skill names, command names, and + agent names. Use 'lola market search' to search remote marketplaces. + + QUERY: Search term to match \b Example: lola mod search git """ - from lola.config import MARKET_DIR, CACHE_DIR - from lola.market.manager import MarketplaceRegistry - ensure_lola_dirs() - registry = MarketplaceRegistry(MARKET_DIR, CACHE_DIR) - registry.search(query) + query_lower = query.lower() + + 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) + + if not results: + console.print(f"[yellow]No modules found matching '{query}'[/yellow]") + console.print( + "[dim]Tip: try 'lola market search' to search remote marketplaces[/dim]" + ) + return + + plural = "s" if len(results) != 1 else "" + console.print(f"\n[bold]Found {len(results)} module{plural}[/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() From 522b4ec433880d5d6d5697f5c47e0a42bcb1bf75 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 12:40:47 +0000 Subject: [PATCH 2/7] refactor(cli): unify search as top-level `lola search` Replace `lola mod search` and `lola market search` with a single top-level `lola search ` that searches both the local registry and all enabled marketplaces, with optional `--local` / `--remote` flags to scope. Local matches use module name, skill name, command name, and agent name. Marketplace matches use module name, description, and tags. Results are rendered in two clearly-labelled sections. Breaking change: scripts using `lola mod search` or `lola market search` must switch to `lola search`. https://claude.ai/code/session_01EkBsZCC4Uo5MCLyay9mmMn --- AGENTS.md | 2 +- CLAUDE.md | 4 +- docs/cli-reference/index.md | 10 ++- docs/getting-started/quick-start.md | 2 +- docs/guides/marketplace.md | 7 +- src/lola/__main__.py | 2 + src/lola/cli/market.py | 16 ---- src/lola/cli/mod.py | 43 ----------- src/lola/cli/search.py | 114 ++++++++++++++++++++++++++++ 9 files changed, 133 insertions(+), 67 deletions(-) create mode 100644 src/lola/cli/search.py diff --git a/AGENTS.md b/AGENTS.md index 2a296ec..1aa823f 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 market search ` searches across enabled marketplace caches; `lola mod search ` searches the local module registry; `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 `--local` or `--remote` to scope); `lola install ` auto-adds from marketplace if not in registry ### Installation Scopes diff --git a/CLAUDE.md b/CLAUDE.md index 2cfbb24..e14344a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,8 @@ ## Recent Changes - 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/search` commands for marketplace management - - `lola market search` for cross-marketplace module discovery; `lola mod search` for local registry lookup + - `lola market add/ls/update/set/rm` commands for marketplace management + - `lola search ` for unified discovery across the local registry and enabled marketplaces (`--local` / `--remote` to scope) - 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 06b0a57..ca65bc9 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 registered modules in the local registry | | `lola mod init [name]` | Initialize a new module | | `lola mod update [name]` | Update module(s) from source | | `lola mod rm ` | Remove a module | @@ -20,12 +19,19 @@ Complete command reference for the Lola CLI. Use `lola --help` or `lola ` | Register a marketplace | | `lola market ls` | List registered marketplaces | -| `lola market search ` | Search across enabled marketplaces | | `lola market update [name]` | Update marketplace cache | | `lola market set --enable ` | Enable a marketplace | | `lola market set --disable ` | Disable a marketplace | | `lola market rm ` | Remove a marketplace | +## Search + +| Command | Description | +| -------------------------------- | ----------------------------------------------------------------- | +| `lola search ` | Search the local registry and enabled marketplaces | +| `lola search --local` | Search only the local registry | +| `lola search --remote` | Search only enabled marketplaces | + ## Installation | Command | Description | diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 6b0fc3a..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 market 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 139c63d..421a425 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 market search authentication +# Search the local registry and all enabled marketplaces +lola search authentication + +# Limit to enabled marketplaces only +lola search authentication --remote # 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/market.py b/src/lola/cli/market.py index 0c9bd84..aacccc1 100644 --- a/src/lola/cli/market.py +++ b/src/lola/cli/market.py @@ -151,19 +151,3 @@ def market_update(name: str, update_all: bool): return registry.update() - - -@market.command(name="search") -@click.argument("query") -def market_search(query: str): - """ - Search for modules across all enabled marketplaces. - - QUERY: Search term to match against module name, description, tags - - \b - Example: - lola market search git - """ - registry = MarketplaceRegistry(MARKET_DIR, CACHE_DIR) - registry.search(query) diff --git a/src/lola/cli/mod.py b/src/lola/cli/mod.py index 8b3dcaa..34aa30a 100644 --- a/src/lola/cli/mod.py +++ b/src/lola/cli/mod.py @@ -1141,46 +1141,3 @@ def update_module_cmd(module_name: str | None): if updated > 0: console.print() console.print("[dim]Run 'lola update' to regenerate assistant files[/dim]") - - -@mod.command(name="search") -@click.argument("query") -def mod_search(query: str): - """ - Search registered modules in the local registry. - - Matches QUERY against module name, skill names, command names, and - agent names. Use 'lola market search' to search remote marketplaces. - - QUERY: Search term to match - - \b - Example: - lola mod search git - """ - ensure_lola_dirs() - query_lower = query.lower() - - 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) - - if not results: - console.print(f"[yellow]No modules found matching '{query}'[/yellow]") - console.print( - "[dim]Tip: try 'lola market search' to search remote marketplaces[/dim]" - ) - return - - plural = "s" if len(results) != 1 else "" - console.print(f"\n[bold]Found {len(results)} module{plural}[/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() diff --git a/src/lola/cli/search.py b/src/lola/cli/search.py new file mode 100644 index 0000000..4f2c2f5 --- /dev/null +++ b/src/lola/cli/search.py @@ -0,0 +1,114 @@ +""" +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 list_registered_modules +from lola.config import CACHE_DIR, MARKET_DIR +from lola.market.search import search_market +from lola.models import Module +from lola.utils import ensure_lola_dirs + +console = Console() + + +def _count_str(count: int, singular: str) -> str: + return f"{count} {singular}" if count == 1 else f"{count} {singular}s" + + +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: + plural = "s" if len(results) != 1 else "" + console.print(f"[bold]Local registry ({len(results)} module{plural})[/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: + plural = "s" if len(results) != 1 else "" + console.print(f"[bold]Marketplaces ({len(results)} module{plural})[/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( + "--local", "scope", flag_value="local", help="Search only the local registry" +) +@click.option( + "--remote", "scope", flag_value="remote", help="Search only enabled marketplaces" +) +def search_cmd(query: str, scope: str | None): + """ + 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 remote + lola search git --local # only the local registry + lola search git --remote # only enabled marketplaces + """ + ensure_lola_dirs() + query_lower = query.lower() + + show_local = scope != "remote" + show_remote = scope != "local" + + 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 --local to also search remote marketplaces[/dim]" + ) + elif not show_local: + console.print( + "[dim]Tip: drop --remote 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) From 681142bba3a7072f4ca50b8bb7c595162a49055e Mon Sep 17 00:00:00 2001 From: Katie Mulliken Date: Wed, 13 May 2026 09:16:19 -0400 Subject: [PATCH 3/7] style: apply ruff format to search.py Co-Authored-By: Claude Opus 4.7 --- src/lola/cli/search.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lola/cli/search.py b/src/lola/cli/search.py index 4f2c2f5..00482b5 100644 --- a/src/lola/cli/search.py +++ b/src/lola/cli/search.py @@ -86,9 +86,7 @@ def search_cmd(query: str, scope: str | None): show_remote = scope != "local" local_results = _search_local(query_lower) if show_local else [] - market_results = ( - search_market(query, MARKET_DIR, CACHE_DIR) if show_remote else [] - ) + market_results = search_market(query, MARKET_DIR, CACHE_DIR) if show_remote else [] total = len(local_results) + len(market_results) if total == 0: From fb0575ca20ba7cd46412ee4e05a25fa5debaab89 Mon Sep 17 00:00:00 2001 From: Katie Mulliken Date: Wed, 13 May 2026 14:49:03 -0400 Subject: [PATCH 4/7] test(cli): add tests for top-level `lola search` command Covers help/registration, local matching (by module/skill/command/agent name, case-insensitive), remote matching (by name and tag), --local / --remote scope flags, the three empty-result tips, and that `lola mod search` is no longer registered. Also drops the redundant ensure_lola_dirs() call in search_cmd; list_registered_modules() already handles it on the local path. Co-Authored-By: Claude Opus 4.7 --- src/lola/cli/search.py | 2 - tests/test_cli_search.py | 352 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_search.py diff --git a/src/lola/cli/search.py b/src/lola/cli/search.py index 00482b5..6fdda0d 100644 --- a/src/lola/cli/search.py +++ b/src/lola/cli/search.py @@ -12,7 +12,6 @@ from lola.config import CACHE_DIR, MARKET_DIR from lola.market.search import search_market from lola.models import Module -from lola.utils import ensure_lola_dirs console = Console() @@ -79,7 +78,6 @@ def search_cmd(query: str, scope: str | None): lola search git --local # only the local registry lola search git --remote # only enabled marketplaces """ - ensure_lola_dirs() query_lower = query.lower() show_local = scope != "remote" diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py new file mode 100644 index 0000000..19babb0 --- /dev/null +++ b/tests/test_cli_search.py @@ -0,0 +1,352 @@ +"""Tests for the top-level `lola search` CLI command.""" + +from unittest.mock import patch + +import yaml + +from lola.cli.search import search_cmd + + +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"], + }, + ] + + market_dir.mkdir(parents=True, exist_ok=True) + cache_dir.mkdir(parents=True, exist_ok=True) + + 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 "--local" in result.output + assert "--remote" 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, tmp_path + ): + """Match a local module by its name.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + result = cli_runner.invoke(search_cmd, ["sample"]) + + assert result.exit_code == 0 + assert "Local registry" in result.output + assert "sample-module" in result.output + # Counts line shows skills/commands/agents + 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, tmp_path + ): + """Match a local module by the name of one of its skills.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + 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, tmp_path + ): + """Match a local module by the name of one of its commands.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + 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, tmp_path + ): + """Match a local module by the name of one of its agents.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + 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, tmp_path + ): + """Local search matches case-insensitively.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + 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, tmp_path): + """Match a marketplace module by name.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + _write_marketplace(market_dir, cache_dir) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_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, tmp_path): + """Match a marketplace module by tag.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + _write_marketplace(market_dir, cache_dir) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_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 --local and --remote flags.""" + + def test_local_flag_skips_marketplaces( + self, cli_runner, mock_lola_home, registered_module, tmp_path + ): + """--local restricts search to the local registry.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + _write_marketplace(market_dir, cache_dir) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + # "git" only matches the marketplace module; --local must filter it out + result = cli_runner.invoke(search_cmd, ["git", "--local"]) + + assert result.exit_code == 0 + assert "Marketplaces" not in result.output + assert "git-tools" not in result.output + + def test_remote_flag_skips_local( + self, cli_runner, mock_lola_home, registered_module, tmp_path + ): + """--remote restricts search to enabled marketplaces.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + _write_marketplace(market_dir, cache_dir) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + # "sample" only matches the local module; --remote must filter it out + result = cli_runner.invoke(search_cmd, ["sample", "--remote"]) + + assert result.exit_code == 0 + assert "Local registry" not in result.output + assert "sample-module" not in result.output + + def test_default_searches_both_scopes( + self, cli_runner, mock_lola_home, registered_module, tmp_path + ): + """With no flag, both local and remote are searched.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + _write_marketplace( + market_dir, + cache_dir, + modules=[ + { + "name": "sample-remote", + "description": "Sample remote", + "version": "1.0.0", + "repository": "https://example.com/sample.git", + } + ], + ) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + 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, tmp_path): + """Show generic tip when neither scope matches.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + 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_local_only_tip(self, cli_runner, mock_lola_home, tmp_path): + """Show 'drop --local' hint when --local yields nothing.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + result = cli_runner.invoke(search_cmd, ["anything", "--local"]) + + assert result.exit_code == 0 + assert "No modules found" in result.output + assert "drop --local" in result.output + + def test_no_match_remote_only_tip(self, cli_runner, mock_lola_home, tmp_path): + """Show 'drop --remote' hint when --remote yields nothing.""" + market_dir = tmp_path / "market" + cache_dir = market_dir / "cache" + market_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + + with ( + patch("lola.cli.search.MARKET_DIR", market_dir), + patch("lola.cli.search.CACHE_DIR", cache_dir), + ): + result = cli_runner.invoke(search_cmd, ["anything", "--remote"]) + + assert result.exit_code == 0 + assert "No modules found" in result.output + assert "drop --remote" in result.output + + +class TestSearchRemoved: + """Tests for behavior that the refactor removed.""" + + def test_mod_search_subcommand_removed(self, cli_runner, mock_lola_home): + """`lola mod search` is no longer a registered subcommand.""" + from lola.cli.mod import mod + + assert "search" not in mod.commands + + result = cli_runner.invoke(mod, ["search", "anything"]) + # Click prints an error when an unknown subcommand is invoked + assert result.exit_code != 0 From 463b83984d84bb950b1a814f311a89fd263ce121 Mon Sep 17 00:00:00 2001 From: Katie Mulliken Date: Wed, 13 May 2026 15:29:45 -0400 Subject: [PATCH 5/7] refactor(search): dedupe _count_str and collapse test boilerplate - Import _count_str from lola.cli.mod instead of redefining it in search.py; use it for the section headers in _print_local / _print_marketplace instead of recomputing `plural` inline. - Add a `search_env` fixture in test_cli_search.py that creates market_dir/cache_dir and patches MARKET_DIR/CACHE_DIR via monkeypatch, replacing ~6 lines of repeated setup in each test. Co-Authored-By: Claude Opus 4.7 --- src/lola/cli/search.py | 14 ++- tests/test_cli_search.py | 185 ++++++++++----------------------------- 2 files changed, 53 insertions(+), 146 deletions(-) diff --git a/src/lola/cli/search.py b/src/lola/cli/search.py index 6fdda0d..d25ed6c 100644 --- a/src/lola/cli/search.py +++ b/src/lola/cli/search.py @@ -8,7 +8,7 @@ from rich.console import Console from rich.table import Table -from lola.cli.mod import list_registered_modules +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 @@ -16,10 +16,6 @@ console = Console() -def _count_str(count: int, singular: str) -> str: - return f"{count} {singular}" if count == 1 else f"{count} {singular}s" - - def _search_local(query_lower: str) -> list[Module]: results: list[Module] = [] for module in list_registered_modules(): @@ -30,8 +26,9 @@ def _search_local(query_lower: str) -> list[Module]: def _print_local(results: list[Module]) -> None: - plural = "s" if len(results) != 1 else "" - console.print(f"[bold]Local registry ({len(results)} module{plural})[/bold]\n") + 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") @@ -42,8 +39,7 @@ def _print_local(results: list[Module]) -> None: def _print_marketplace(results: list[dict]) -> None: - plural = "s" if len(results) != 1 else "" - console.print(f"[bold]Marketplaces ({len(results)} module{plural})[/bold]\n") + 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") diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py index 19babb0..6a9c244 100644 --- a/tests/test_cli_search.py +++ b/tests/test_cli_search.py @@ -1,12 +1,25 @@ """Tests for the top-level `lola search` CLI command.""" -from unittest.mock import patch - +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: @@ -27,9 +40,6 @@ def _write_marketplace(market_dir, cache_dir, name="official", modules=None): }, ] - market_dir.mkdir(parents=True, exist_ok=True) - cache_dir.mkdir(parents=True, exist_ok=True) - ref = { "name": name, "url": f"https://example.com/{name}.yml", @@ -78,96 +88,50 @@ class TestSearchLocal: """Tests for local registry search.""" def test_finds_module_by_name( - self, cli_runner, mock_lola_home, registered_module, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """Match a local module by its name.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["sample"]) + result = cli_runner.invoke(search_cmd, ["sample"]) assert result.exit_code == 0 assert "Local registry" in result.output assert "sample-module" in result.output - # Counts line shows skills/commands/agents 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, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """Match a local module by the name of one of its skills.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["skill1"]) + 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, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """Match a local module by the name of one of its commands.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["cmd1"]) + 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, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """Match a local module by the name of one of its agents.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["agent1"]) + 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, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """Local search matches case-insensitively.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["SAMPLE"]) + result = cli_runner.invoke(search_cmd, ["SAMPLE"]) assert result.exit_code == 0 assert "sample-module" in result.output @@ -176,34 +140,24 @@ def test_local_match_is_case_insensitive( class TestSearchRemote: """Tests for marketplace (remote) search.""" - def test_finds_remote_module(self, cli_runner, mock_lola_home, tmp_path): + def test_finds_remote_module(self, cli_runner, mock_lola_home, search_env): """Match a marketplace module by name.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" + market_dir, cache_dir = search_env _write_marketplace(market_dir, cache_dir) - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["git"]) + 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, tmp_path): + def test_remote_match_by_tag(self, cli_runner, mock_lola_home, search_env): """Match a marketplace module by tag.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" + market_dir, cache_dir = search_env _write_marketplace(market_dir, cache_dir) - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["vcs"]) + result = cli_runner.invoke(search_cmd, ["vcs"]) assert result.exit_code == 0 assert "git-tools" in result.output @@ -213,49 +167,38 @@ class TestSearchScopeFlags: """Tests for --local and --remote flags.""" def test_local_flag_skips_marketplaces( - self, cli_runner, mock_lola_home, registered_module, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """--local restricts search to the local registry.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" + market_dir, cache_dir = search_env _write_marketplace(market_dir, cache_dir) - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - # "git" only matches the marketplace module; --local must filter it out - result = cli_runner.invoke(search_cmd, ["git", "--local"]) + # "git" only matches the marketplace module; --local must filter it out + result = cli_runner.invoke(search_cmd, ["git", "--local"]) assert result.exit_code == 0 assert "Marketplaces" not in result.output assert "git-tools" not in result.output def test_remote_flag_skips_local( - self, cli_runner, mock_lola_home, registered_module, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """--remote restricts search to enabled marketplaces.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" + market_dir, cache_dir = search_env _write_marketplace(market_dir, cache_dir) - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - # "sample" only matches the local module; --remote must filter it out - result = cli_runner.invoke(search_cmd, ["sample", "--remote"]) + # "sample" only matches the local module; --remote must filter it out + result = cli_runner.invoke(search_cmd, ["sample", "--remote"]) assert result.exit_code == 0 assert "Local registry" not in result.output assert "sample-module" not in result.output def test_default_searches_both_scopes( - self, cli_runner, mock_lola_home, registered_module, tmp_path + self, cli_runner, mock_lola_home, registered_module, search_env ): """With no flag, both local and remote are searched.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" + market_dir, cache_dir = search_env _write_marketplace( market_dir, cache_dir, @@ -269,11 +212,7 @@ def test_default_searches_both_scopes( ], ) - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["sample"]) + result = cli_runner.invoke(search_cmd, ["sample"]) assert result.exit_code == 0 assert "Local registry" in result.output @@ -285,53 +224,26 @@ def test_default_searches_both_scopes( class TestSearchNoMatches: """Tests for the empty-results path.""" - def test_no_match_default_scope(self, cli_runner, mock_lola_home, tmp_path): + def test_no_match_default_scope(self, cli_runner, mock_lola_home, search_env): """Show generic tip when neither scope matches.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["definitely-not-a-module"]) + 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_local_only_tip(self, cli_runner, mock_lola_home, tmp_path): + def test_no_match_local_only_tip(self, cli_runner, mock_lola_home, search_env): """Show 'drop --local' hint when --local yields nothing.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["anything", "--local"]) + result = cli_runner.invoke(search_cmd, ["anything", "--local"]) assert result.exit_code == 0 assert "No modules found" in result.output assert "drop --local" in result.output - def test_no_match_remote_only_tip(self, cli_runner, mock_lola_home, tmp_path): + def test_no_match_remote_only_tip(self, cli_runner, mock_lola_home, search_env): """Show 'drop --remote' hint when --remote yields nothing.""" - market_dir = tmp_path / "market" - cache_dir = market_dir / "cache" - market_dir.mkdir(parents=True) - cache_dir.mkdir(parents=True) - - with ( - patch("lola.cli.search.MARKET_DIR", market_dir), - patch("lola.cli.search.CACHE_DIR", cache_dir), - ): - result = cli_runner.invoke(search_cmd, ["anything", "--remote"]) + result = cli_runner.invoke(search_cmd, ["anything", "--remote"]) assert result.exit_code == 0 assert "No modules found" in result.output @@ -348,5 +260,4 @@ def test_mod_search_subcommand_removed(self, cli_runner, mock_lola_home): assert "search" not in mod.commands result = cli_runner.invoke(mod, ["search", "anything"]) - # Click prints an error when an unknown subcommand is invoked assert result.exit_code != 0 From a71b4011a14423f35abf66d9be0589869c2dff50 Mon Sep 17 00:00:00 2001 From: Katie Mulliken Date: Thu, 14 May 2026 19:46:08 -0400 Subject: [PATCH 6/7] fix(search): make count_str public and add mutual-exclusivity for --local/--remote --- src/lola/cli/mod.py | 12 ++++++------ src/lola/cli/search.py | 30 +++++++++++++++--------------- tests/test_cli_search.py | 9 +++++++++ 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/lola/cli/mod.py b/src/lola/cli/mod.py index 34aa30a..8d52750 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: diff --git a/src/lola/cli/search.py b/src/lola/cli/search.py index d25ed6c..2726db2 100644 --- a/src/lola/cli/search.py +++ b/src/lola/cli/search.py @@ -8,7 +8,7 @@ from rich.console import Console from rich.table import Table -from lola.cli.mod import _count_str, list_registered_modules +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 @@ -27,19 +27,19 @@ def _search_local(query_lower: str) -> list[Module]: def _print_local(results: list[Module]) -> None: console.print( - f"[bold]Local registry ({_count_str(len(results), 'module')})[/bold]\n" + 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") + 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") + 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") @@ -53,13 +53,9 @@ def _print_marketplace(results: list[dict]) -> None: @click.command(name="search") @click.argument("query") -@click.option( - "--local", "scope", flag_value="local", help="Search only the local registry" -) -@click.option( - "--remote", "scope", flag_value="remote", help="Search only enabled marketplaces" -) -def search_cmd(query: str, scope: str | None): +@click.option("--local", is_flag=True, help="Search only the local registry") +@click.option("--remote", is_flag=True, help="Search only enabled marketplaces") +def search_cmd(query: str, local: bool, remote: bool): """ Search modules in the local registry and enabled marketplaces. @@ -74,10 +70,14 @@ def search_cmd(query: str, scope: str | None): lola search git --local # only the local registry lola search git --remote # only enabled marketplaces """ + if local and remote: + click.echo("Error: --local and --remote are mutually exclusive") + raise SystemExit(1) + query_lower = query.lower() - show_local = scope != "remote" - show_remote = scope != "local" + show_local = not remote + show_remote = not local local_results = _search_local(query_lower) if show_local else [] market_results = search_market(query, MARKET_DIR, CACHE_DIR) if show_remote else [] diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py index 6a9c244..fe8c747 100644 --- a/tests/test_cli_search.py +++ b/tests/test_cli_search.py @@ -194,6 +194,15 @@ def test_remote_flag_skips_local( assert "Local registry" not in result.output assert "sample-module" not in result.output + def test_local_and_remote_mutually_exclusive( + self, cli_runner, mock_lola_home, search_env + ): + """--local and --remote together produce an explicit error.""" + result = cli_runner.invoke(search_cmd, ["git", "--local", "--remote"]) + + 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 ): From b1c85c24d774023594a4bf077b38bc2d348b80c5 Mon Sep 17 00:00:00 2001 From: Katie Mulliken Date: Fri, 5 Jun 2026 11:39:48 -0400 Subject: [PATCH 7/7] feat(search): use --mod/--market flags and keep `lola mod search` alias Address PR review feedback: - Rename the `lola search` scope flags from `--local`/`--remote` to `--mod`/`--market` for clearer registry-vs-marketplace naming. - Restore `lola mod search` as a thin compatibility alias that forwards to `lola search --mod`, so existing scripts keep working. The deprecation notice is written to stderr so stdout stays byte-identical to `lola search --mod` for scripts parsing the results. - Keep the mutual-exclusivity guard so `--mod --market` errors clearly. - Update docs (CLI reference, marketplace guide), AGENTS.md, CLAUDE.md, and tests to match. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- CLAUDE.md | 3 +- docs/cli-reference/index.md | 5 +- docs/guides/marketplace.md | 2 +- src/lola/cli/mod.py | 21 +++++++++ src/lola/cli/search.py | 24 +++++----- tests/test_cli_search.py | 92 ++++++++++++++++++++++++++----------- 7 files changed, 104 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1aa823f..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 search ` searches both the local module registry and enabled marketplace caches (use `--local` or `--remote` to scope); `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 e14344a..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 search ` for unified discovery across the local registry and enabled marketplaces (`--local` / `--remote` to scope) + - `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 ca65bc9..3f03cdc 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -29,8 +29,9 @@ Complete command reference for the Lola CLI. Use `lola --help` or `lola ` | Search the local registry and enabled marketplaces | -| `lola search --local` | Search only the local registry | -| `lola search --remote` | Search only 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 diff --git a/docs/guides/marketplace.md b/docs/guides/marketplace.md index 421a425..08c9093 100644 --- a/docs/guides/marketplace.md +++ b/docs/guides/marketplace.md @@ -17,7 +17,7 @@ lola market add general https://raw.githubusercontent.com/RedHatProductSecurity/ lola search authentication # Limit to enabled marketplaces only -lola search authentication --remote +lola search authentication --market # Install directly from marketplace (auto-adds and installs) lola install git-workflow -a claude-code diff --git a/src/lola/cli/mod.py b/src/lola/cli/mod.py index 8d52750..fd89d57 100644 --- a/src/lola/cli/mod.py +++ b/src/lola/cli/mod.py @@ -1141,3 +1141,24 @@ def update_module_cmd(module_name: str | None): if updated > 0: console.print() console.print("[dim]Run 'lola update' to regenerate assistant files[/dim]") + + +@mod.command(name="search") +@click.argument("query") +@click.pass_context +def search_compat_cmd(ctx: click.Context, query: str): + """ + Search the local module registry (compatibility alias). + + Deprecated: prefer 'lola search --mod'. This forwards to + 'lola search --mod' so existing scripts keep working. + + QUERY: Search term to match + """ + from lola.cli.search import search_cmd + + # 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 index 2726db2..a53931a 100644 --- a/src/lola/cli/search.py +++ b/src/lola/cli/search.py @@ -53,9 +53,9 @@ def _print_marketplace(results: list[dict]) -> None: @click.command(name="search") @click.argument("query") -@click.option("--local", is_flag=True, help="Search only the local registry") -@click.option("--remote", is_flag=True, help="Search only enabled marketplaces") -def search_cmd(query: str, local: bool, remote: bool): +@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. @@ -66,18 +66,18 @@ def search_cmd(query: str, local: bool, remote: bool): \b Examples: - lola search git # search both local and remote - lola search git --local # only the local registry - lola search git --remote # only enabled marketplaces + 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 local and remote: - click.echo("Error: --local and --remote are mutually exclusive") + if mod and market: + click.echo("Error: --mod and --market are mutually exclusive") raise SystemExit(1) query_lower = query.lower() - show_local = not remote - show_remote = not local + 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 [] @@ -87,11 +87,11 @@ def search_cmd(query: str, local: bool, remote: bool): console.print(f"[yellow]No modules found matching '{query}'[/yellow]") if not show_remote: console.print( - "[dim]Tip: drop --local to also search remote marketplaces[/dim]" + "[dim]Tip: drop --mod to also search remote marketplaces[/dim]" ) elif not show_local: console.print( - "[dim]Tip: drop --remote to also search the local registry[/dim]" + "[dim]Tip: drop --market to also search the local registry[/dim]" ) else: console.print( diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py index fe8c747..c3694ff 100644 --- a/tests/test_cli_search.py +++ b/tests/test_cli_search.py @@ -68,8 +68,8 @@ def test_search_help(self, cli_runner): result = cli_runner.invoke(search_cmd, ["--help"]) assert result.exit_code == 0 assert "Search modules" in result.output - assert "--local" in result.output - assert "--remote" 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.""" @@ -164,41 +164,41 @@ def test_remote_match_by_tag(self, cli_runner, mock_lola_home, search_env): class TestSearchScopeFlags: - """Tests for --local and --remote flags.""" + """Tests for --mod and --market flags.""" - def test_local_flag_skips_marketplaces( + def test_mod_flag_skips_marketplaces( self, cli_runner, mock_lola_home, registered_module, search_env ): - """--local restricts search to the local registry.""" + """--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; --local must filter it out - result = cli_runner.invoke(search_cmd, ["git", "--local"]) + # "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_remote_flag_skips_local( + def test_market_flag_skips_local( self, cli_runner, mock_lola_home, registered_module, search_env ): - """--remote restricts search to enabled marketplaces.""" + """--market restricts search to enabled marketplaces.""" market_dir, cache_dir = search_env _write_marketplace(market_dir, cache_dir) - # "sample" only matches the local module; --remote must filter it out - result = cli_runner.invoke(search_cmd, ["sample", "--remote"]) + # "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_local_and_remote_mutually_exclusive( + def test_mod_and_market_mutually_exclusive( self, cli_runner, mock_lola_home, search_env ): - """--local and --remote together produce an explicit error.""" - result = cli_runner.invoke(search_cmd, ["git", "--local", "--remote"]) + """--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 @@ -242,31 +242,67 @@ def test_no_match_default_scope(self, cli_runner, mock_lola_home, search_env): assert "definitely-not-a-module" in result.output assert "check spelling" in result.output - def test_no_match_local_only_tip(self, cli_runner, mock_lola_home, search_env): - """Show 'drop --local' hint when --local yields nothing.""" - result = cli_runner.invoke(search_cmd, ["anything", "--local"]) + 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 --local" in result.output + assert "drop --mod" in result.output - def test_no_match_remote_only_tip(self, cli_runner, mock_lola_home, search_env): - """Show 'drop --remote' hint when --remote yields nothing.""" - result = cli_runner.invoke(search_cmd, ["anything", "--remote"]) + 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 --remote" in result.output + assert "drop --market" in result.output -class TestSearchRemoved: - """Tests for behavior that the refactor removed.""" +class TestModSearchCompat: + """Tests for the `lola mod search` compatibility alias.""" - def test_mod_search_subcommand_removed(self, cli_runner, mock_lola_home): - """`lola mod search` is no longer a registered subcommand.""" + 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" not in mod.commands + 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 result.exit_code == 0 + assert "deprecated" in result.output