diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py
index 4b55e2e..2c9fc40 100644
--- a/src/cmdforge/cli/__init__.py
+++ b/src/cmdforge/cli/__init__.py
@@ -135,9 +135,27 @@ def main():
p_reg_search = registry_sub.add_parser("search", help="Search for tools")
p_reg_search.add_argument("query", help="Search query")
p_reg_search.add_argument("-c", "--category", help="Filter by category")
+ p_reg_search.add_argument("-t", "--tag", action="append", dest="tags", help="Filter by tag (repeatable, AND logic)")
+ p_reg_search.add_argument("-o", "--owner", help="Filter by publisher/owner")
+ p_reg_search.add_argument("--min-downloads", type=int, help="Minimum downloads")
+ p_reg_search.add_argument("--popular", action="store_true", help="Shortcut for --min-downloads 100")
+ p_reg_search.add_argument("--new", action="store_true", help="Shortcut for --max-downloads 10")
+ p_reg_search.add_argument("--since", help="Published after date (YYYY-MM-DD)")
+ p_reg_search.add_argument("--before", help="Published before date (YYYY-MM-DD)")
+ p_reg_search.add_argument("-s", "--sort", choices=["relevance", "downloads", "published_at", "name"], default="relevance", help="Sort by field")
p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)")
+ p_reg_search.add_argument("--json", action="store_true", help="Output as JSON")
+ p_reg_search.add_argument("--show-facets", action="store_true", help="Show category/tag counts")
+ p_reg_search.add_argument("--deprecated", action="store_true", help="Include deprecated tools")
p_reg_search.set_defaults(func=cmd_registry)
+ # registry tags
+ p_reg_tags = registry_sub.add_parser("tags", help="List available tags")
+ p_reg_tags.add_argument("-c", "--category", help="Filter tags by category")
+ p_reg_tags.add_argument("-l", "--limit", type=int, default=50, help="Max tags to show (default: 50)")
+ p_reg_tags.add_argument("--json", action="store_true", help="Output as JSON")
+ p_reg_tags.set_defaults(func=cmd_registry)
+
# registry install
p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry")
p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)")
diff --git a/src/cmdforge/cli/registry_commands.py b/src/cmdforge/cli/registry_commands.py
index 9a2209b..5feae45 100644
--- a/src/cmdforge/cli/registry_commands.py
+++ b/src/cmdforge/cli/registry_commands.py
@@ -1,5 +1,6 @@
"""Registry commands."""
+import json
import sys
from pathlib import Path
@@ -15,6 +16,8 @@ def cmd_registry(args):
if args.registry_cmd == "search":
return _cmd_registry_search(args)
+ elif args.registry_cmd == "tags":
+ return _cmd_registry_tags(args)
elif args.registry_cmd == "install":
return _cmd_registry_install(args)
elif args.registry_cmd == "uninstall":
@@ -33,6 +36,7 @@ def cmd_registry(args):
# Default: show registry help
print("Registry commands:")
print(" search Search for tools")
+ print(" tags List available tags")
print(" install Install a tool")
print(" uninstall Uninstall a tool")
print(" info Show tool information")
@@ -49,27 +53,73 @@ def _cmd_registry_search(args):
try:
client = get_client()
+
+ # Handle shortcut flags
+ min_downloads = getattr(args, 'min_downloads', None)
+ max_downloads = None
+ if getattr(args, 'popular', False):
+ min_downloads = 100
+ if getattr(args, 'new', False):
+ max_downloads = 10
+
results = client.search_tools(
query=args.query,
- category=args.category,
- per_page=args.limit or 20
+ category=getattr(args, 'category', None),
+ tags=getattr(args, 'tags', None),
+ owner=getattr(args, 'owner', None),
+ min_downloads=min_downloads,
+ max_downloads=max_downloads,
+ published_after=getattr(args, 'since', None),
+ published_before=getattr(args, 'before', None),
+ include_deprecated=getattr(args, 'deprecated', False),
+ include_facets=getattr(args, 'show_facets', False),
+ per_page=args.limit or 20,
+ sort=getattr(args, 'sort', 'relevance')
)
+ # JSON output
+ if getattr(args, 'json', False):
+ output = {
+ "query": args.query,
+ "total": results.total,
+ "results": results.data
+ }
+ if results.facets:
+ output["facets"] = results.facets
+ print(json.dumps(output, indent=2))
+ return 0
+
if not results.data:
print(f"No tools found matching '{args.query}'")
return 0
- print(f"Found {results.total} tools:\n")
+ print(f"Found {results.total} tools matching \"{args.query}\":")
+
+ # Show facets summary if requested
+ if results.facets:
+ cats = results.facets.get("categories", [])[:5]
+ tags = results.facets.get("tags", [])[:5]
+ if cats:
+ cat_str = ", ".join(f"{c['name']} ({c['count']})" for c in cats)
+ print(f"\nCategories: {cat_str}")
+ if tags:
+ tag_str = ", ".join(f"{t['name']} ({t['count']})" for t in tags)
+ print(f"Top Tags: {tag_str}")
+
+ print()
for tool in results.data:
owner = tool.get("owner", "")
name = tool.get("name", "")
version = tool.get("version", "")
desc = tool.get("description", "")
downloads = tool.get("downloads", 0)
+ tags = tool.get("tags", [])
print(f" {owner}/{name} v{version}")
print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}")
- print(f" Downloads: {downloads}")
+ if tags:
+ print(f" Tags: {', '.join(tags[:5])}")
+ print(f" Downloads: {downloads:,}")
print()
if results.total_pages > 1:
@@ -92,6 +142,43 @@ def _cmd_registry_search(args):
return 0
+def _cmd_registry_tags(args):
+ """List available tags."""
+ from ..registry_client import RegistryError, get_client
+
+ try:
+ client = get_client()
+ tags = client.get_tags(
+ category=getattr(args, 'category', None),
+ limit=getattr(args, 'limit', 50)
+ )
+
+ # JSON output
+ if getattr(args, 'json', False):
+ print(json.dumps({"tags": tags}, indent=2))
+ return 0
+
+ if not tags:
+ print("No tags found")
+ return 0
+
+ print(f"Available tags ({len(tags)}):\n")
+ for tag in tags:
+ print(f" {tag['name']:20} ({tag['count']} tools)")
+
+ except RegistryError as e:
+ if e.code == "CONNECTION_ERROR":
+ print("Could not connect to the registry.", file=sys.stderr)
+ else:
+ print(f"Error: {e.message}", file=sys.stderr)
+ return 1
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
+ return 0
+
+
def _cmd_registry_install(args):
"""Install a tool from the registry."""
from ..registry_client import RegistryError
diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py
index abaf77b..637ad1e 100644
--- a/src/cmdforge/registry/app.py
+++ b/src/cmdforge/registry/app.py
@@ -482,14 +482,77 @@ def create_app() -> Flask:
page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads")
if error:
return error
- category = request.args.get("category")
offset = (page - 1) * per_page
- where_clause = "WHERE tools_fts MATCH ?"
+ # Parse filter parameters
+ category = request.args.get("category") # Single category (backward compat)
+ categories_param = request.args.get("categories", "") # Multi-category (OR logic)
+ tags_param = request.args.get("tags", "") # Tags (AND logic)
+ owner_filter = request.args.get("owner")
+ min_downloads = request.args.get("min_downloads", type=int)
+ max_downloads = request.args.get("max_downloads", type=int)
+ published_after = request.args.get("published_after")
+ published_before = request.args.get("published_before")
+ include_deprecated = request.args.get("deprecated", "false").lower() == "true"
+ include_facets = request.args.get("include_facets", "false").lower() == "true"
+
+ # Parse multi-value params
+ categories = [c.strip() for c in categories_param.split(",") if c.strip()]
+ if category and category not in categories:
+ categories.append(category)
+ tags = [t.strip() for t in tags_param.split(",") if t.strip()]
+
+ # Build WHERE clause
+ where_clauses = ["tools_fts MATCH ?"]
params: List[Any] = [query_text]
- if category:
- where_clause += " AND tools.category = ?"
- params.append(category)
+
+ if categories:
+ placeholders = ",".join(["?" for _ in categories])
+ where_clauses.append(f"tools.category IN ({placeholders})")
+ params.extend(categories)
+
+ if owner_filter:
+ where_clauses.append("tools.owner = ?")
+ params.append(owner_filter)
+
+ if min_downloads is not None:
+ where_clauses.append("tools.downloads >= ?")
+ params.append(min_downloads)
+
+ if max_downloads is not None:
+ where_clauses.append("tools.downloads <= ?")
+ params.append(max_downloads)
+
+ if published_after:
+ where_clauses.append("tools.published_at >= ?")
+ params.append(published_after)
+
+ if published_before:
+ where_clauses.append("tools.published_at <= ?")
+ params.append(published_before)
+
+ if not include_deprecated:
+ where_clauses.append("tools.deprecated = 0")
+
+ where_clause = "WHERE " + " AND ".join(where_clauses)
+
+ # Tag filtering CTE (AND logic - must have ALL specified tags)
+ tag_cte = ""
+ tag_join = ""
+ if tags:
+ tag_placeholders = ",".join(["?" for _ in tags])
+ tag_cte = f"""
+ tag_matches AS (
+ SELECT tools.id
+ FROM tools, json_each(tools.tags) AS tag
+ WHERE tag.value IN ({tag_placeholders})
+ GROUP BY tools.id
+ HAVING COUNT(DISTINCT tag.value) = ?
+ ),
+ """
+ tag_join = "JOIN tag_matches tm ON m.id = tm.id"
+ # Prepend tag params
+ params = tags + [len(tags)] + params
order_dir = "DESC" if order == "desc" else "ASC"
if sort == "relevance":
@@ -500,46 +563,57 @@ def create_app() -> Flask:
rows = query_all(
g.db,
f"""
- WITH matches AS (
+ WITH {tag_cte}
+ matches AS (
SELECT tools.*, bm25(tools_fts) AS rank
FROM tools_fts
JOIN tools ON tools_fts.rowid = tools.id
{where_clause}
),
+ filtered AS (
+ SELECT m.* FROM matches m
+ {tag_join}
+ ),
latest_any AS (
SELECT owner, name, MAX(id) AS max_id
- FROM matches
+ FROM filtered
GROUP BY owner, name
),
latest_stable AS (
SELECT owner, name, MAX(id) AS max_id
- FROM matches
+ FROM filtered
WHERE version NOT LIKE '%-%'
GROUP BY owner, name
)
- SELECT m.* FROM matches m
+ SELECT f.* FROM filtered f
JOIN (
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
FROM latest_any a
LEFT JOIN latest_stable s ON s.owner = a.owner AND s.name = a.name
) latest
- ON m.owner = latest.owner AND m.name = latest.name AND m.id = latest.max_id
+ ON f.owner = latest.owner AND f.name = latest.name AND f.id = latest.max_id
ORDER BY {order_sql}
LIMIT ? OFFSET ?
""",
params + [per_page, offset],
)
+ # Count query (reuse same params without pagination)
count_row = query_one(
g.db,
f"""
- WITH matches AS (
+ WITH {tag_cte}
+ matches AS (
SELECT tools.*
FROM tools_fts
JOIN tools ON tools_fts.rowid = tools.id
{where_clause}
+ ),
+ filtered AS (
+ SELECT m.* FROM matches m
+ {tag_join}
)
- SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM matches
+ SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM filtered
""",
params,
)
@@ -560,7 +634,90 @@ def create_app() -> Flask:
"score": score,
})
- return jsonify({"data": data, "meta": paginate(page, per_page, total)})
+ result: dict = {"data": data, "meta": paginate(page, per_page, total)}
+
+ # Compute facets if requested
+ if include_facets:
+ # Category facets
+ cat_rows = query_all(
+ g.db,
+ f"""
+ WITH {tag_cte}
+ matches AS (
+ SELECT tools.*
+ FROM tools_fts
+ JOIN tools ON tools_fts.rowid = tools.id
+ {where_clause}
+ ),
+ filtered AS (
+ SELECT m.* FROM matches m
+ {tag_join}
+ )
+ SELECT category, COUNT(DISTINCT owner || '/' || name) AS count
+ FROM filtered
+ WHERE category IS NOT NULL
+ GROUP BY category
+ ORDER BY count DESC
+ LIMIT 20
+ """,
+ params,
+ )
+
+ # Tag facets
+ tag_rows = query_all(
+ g.db,
+ f"""
+ WITH {tag_cte}
+ matches AS (
+ SELECT tools.*
+ FROM tools_fts
+ JOIN tools ON tools_fts.rowid = tools.id
+ {where_clause}
+ ),
+ filtered AS (
+ SELECT m.* FROM matches m
+ {tag_join}
+ )
+ SELECT tag.value AS name, COUNT(DISTINCT filtered.owner || '/' || filtered.name) AS count
+ FROM filtered, json_each(filtered.tags) AS tag
+ GROUP BY tag.value
+ ORDER BY count DESC
+ LIMIT 30
+ """,
+ params,
+ )
+
+ # Owner facets
+ owner_rows = query_all(
+ g.db,
+ f"""
+ WITH {tag_cte}
+ matches AS (
+ SELECT tools.*
+ FROM tools_fts
+ JOIN tools ON tools_fts.rowid = tools.id
+ {where_clause}
+ ),
+ filtered AS (
+ SELECT m.* FROM matches m
+ {tag_join}
+ )
+ SELECT owner, COUNT(DISTINCT owner || '/' || name) AS count
+ FROM filtered
+ GROUP BY owner
+ ORDER BY count DESC
+ LIMIT 20
+ """,
+ params,
+ )
+
+ result["facets"] = {
+ "categories": [{"name": r["category"], "count": r["count"]} for r in cat_rows],
+ "tags": [{"name": r["name"], "count": r["count"]} for r in tag_rows],
+ "owners": [{"name": r["owner"], "count": r["count"]} for r in owner_rows],
+ }
+
+ return jsonify(result)
@app.route("/api/v1/tools//", methods=["GET"])
def get_tool(owner: str, name: str) -> Response:
@@ -726,6 +883,44 @@ def create_app() -> Flask:
response.headers["Cache-Control"] = "max-age=3600"
return response
+ @app.route("/api/v1/tags", methods=["GET"])
+ def list_tags() -> Response:
+ """List all tags with usage counts."""
+ category = request.args.get("category")
+ limit = min(int(request.args.get("limit", 100)), 500)
+
+ # Build query - extract tags from JSON array and count occurrences
+ if category:
+ rows = query_all(
+ g.db,
+ """
+ SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
+ FROM tools, json_each(tools.tags) AS tag
+ WHERE tools.category = ?
+ GROUP BY tag.value
+ ORDER BY count DESC
+ LIMIT ?
+ """,
+ (category, limit),
+ )
+ else:
+ rows = query_all(
+ g.db,
+ """
+ SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
+ FROM tools, json_each(tools.tags) AS tag
+ GROUP BY tag.value
+ ORDER BY count DESC
+ LIMIT ?
+ """,
+ (limit,),
+ )
+
+ data = [{"name": row["name"], "count": row["count"]} for row in rows]
+ response = jsonify({"data": data, "meta": {"total": len(data)}})
+ response.headers["Cache-Control"] = "max-age=3600"
+ return response
+
# ─── Collections ─────────────────────────────────────────────────────────────
@app.route("/api/v1/collections", methods=["GET"])
diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py
index f4d1b0e..d0d8c11 100644
--- a/src/cmdforge/registry/db.py
+++ b/src/cmdforge/registry/db.py
@@ -120,6 +120,7 @@ CREATE TABLE IF NOT EXISTS web_sessions (
);
CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name);
+CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner);
CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category);
CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC);
diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py
index fadcc91..80aa9e1 100644
--- a/src/cmdforge/registry_client.py
+++ b/src/cmdforge/registry_client.py
@@ -63,6 +63,7 @@ class PaginatedResponse:
per_page: int = 20
total: int = 0
total_pages: int = 0
+ facets: Optional[Dict] = None # Category/tag/owner counts when requested
@dataclass
@@ -329,6 +330,15 @@ class RegistryClient:
self,
query: str,
category: Optional[str] = None,
+ categories: Optional[List[str]] = None,
+ tags: Optional[List[str]] = None,
+ owner: Optional[str] = None,
+ min_downloads: Optional[int] = None,
+ max_downloads: Optional[int] = None,
+ published_after: Optional[str] = None,
+ published_before: Optional[str] = None,
+ include_deprecated: bool = False,
+ include_facets: bool = False,
page: int = 1,
per_page: int = 20,
sort: str = "relevance"
@@ -338,15 +348,24 @@ class RegistryClient:
Args:
query: Search query
- category: Filter by category
+ category: Filter by single category (backward compat)
+ categories: Filter by multiple categories (OR logic)
+ tags: Filter by tags (AND logic - must have all)
+ owner: Filter by publisher/owner
+ min_downloads: Minimum download count
+ max_downloads: Maximum download count
+ published_after: Published after date (ISO format)
+ published_before: Published before date (ISO format)
+ include_deprecated: Include deprecated tools
+ include_facets: Include category/tag/owner counts in response
page: Page number
per_page: Items per page
sort: Sort field (relevance, downloads, published_at)
Returns:
- PaginatedResponse with matching tools
+ PaginatedResponse with matching tools (and facets if requested)
"""
- params = {
+ params: Dict[str, Any] = {
"q": query,
"page": page,
"per_page": min(per_page, 100),
@@ -354,6 +373,24 @@ class RegistryClient:
}
if category:
params["category"] = category
+ if categories:
+ params["categories"] = ",".join(categories)
+ if tags:
+ params["tags"] = ",".join(tags)
+ if owner:
+ params["owner"] = owner
+ if min_downloads is not None:
+ params["min_downloads"] = min_downloads
+ if max_downloads is not None:
+ params["max_downloads"] = max_downloads
+ if published_after:
+ params["published_after"] = published_after
+ if published_before:
+ params["published_before"] = published_before
+ if include_deprecated:
+ params["deprecated"] = "true"
+ if include_facets:
+ params["include_facets"] = "true"
response = self._request("GET", "/tools/search", params=params)
@@ -368,9 +405,37 @@ class RegistryClient:
page=meta.get("page", page),
per_page=meta.get("per_page", per_page),
total=meta.get("total", 0),
- total_pages=meta.get("total_pages", 0)
+ total_pages=meta.get("total_pages", 0),
+ facets=data.get("facets")
)
+ def get_tags(
+ self,
+ category: Optional[str] = None,
+ limit: int = 100
+ ) -> List[Dict]:
+ """
+ Get all tags with usage counts.
+
+ Args:
+ category: Filter tags by category
+ limit: Maximum tags to return
+
+ Returns:
+ List of tag objects with name and count
+ """
+ params: Dict[str, Any] = {"limit": min(limit, 500)}
+ if category:
+ params["category"] = category
+
+ response = self._request("GET", "/tags", params=params)
+
+ if response.status_code != 200:
+ self._handle_error_response(response)
+
+ data = response.json()
+ return data.get("data", [])
+
def get_tool(self, owner: str, name: str) -> ToolInfo:
"""
Get detailed information about a tool.
diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py
index ddfef69..0c4f7d4 100644
--- a/src/cmdforge/web/routes.py
+++ b/src/cmdforge/web/routes.py
@@ -267,22 +267,57 @@ def category(name: str):
def search():
query = request.args.get("q", "").strip()
page = request.args.get("page", 1)
+
+ # Parse filter parameters
category = request.args.get("category")
+ categories_param = request.args.get("categories", "")
+ tags_param = request.args.get("tags", "")
+ owner = request.args.get("owner")
+ min_downloads = request.args.get("min_downloads")
+ sort = request.args.get("sort", "relevance")
+
+ # Parse multi-value params
+ active_categories = [c.strip() for c in categories_param.split(",") if c.strip()]
+ if category and category not in active_categories:
+ active_categories.append(category)
+ active_tags = [t.strip() for t in tags_param.split(",") if t.strip()]
if query:
- params = {"q": query, "page": page, "per_page": 12}
- if category:
- params["category"] = category
+ params = {
+ "q": query,
+ "page": page,
+ "per_page": 12,
+ "sort": sort,
+ "include_facets": "true" # Always get facets for the UI
+ }
+ if active_categories:
+ params["categories"] = ",".join(active_categories)
+ if active_tags:
+ params["tags"] = ",".join(active_tags)
+ if owner:
+ params["owner"] = owner
+ if min_downloads:
+ params["min_downloads"] = min_downloads
+
status, payload = _api_get("/api/v1/tools/search", params=params)
if status != 200:
return render_template("errors/500.html"), 500
+
meta = payload.get("meta", {})
+ facets = payload.get("facets", {})
+
return render_template(
"pages/search.html",
query=query,
results=payload.get("data", []),
pagination=_build_pagination(meta),
popular_categories=[],
+ facets=facets,
+ active_categories=active_categories,
+ active_tags=active_tags,
+ active_owner=owner,
+ active_sort=sort,
+ active_min_downloads=min_downloads,
)
categories = sorted(_load_categories(), key=lambda c: c.count, reverse=True)[:8]
@@ -292,6 +327,12 @@ def search():
results=[],
pagination=None,
popular_categories=categories,
+ facets={},
+ active_categories=[],
+ active_tags=[],
+ active_owner=None,
+ active_sort="relevance",
+ active_min_downloads=None,
)
diff --git a/src/cmdforge/web/templates/components/tool_card.html b/src/cmdforge/web/templates/components/tool_card.html
index 7ed32d6..cfe9c67 100644
--- a/src/cmdforge/web/templates/components/tool_card.html
+++ b/src/cmdforge/web/templates/components/tool_card.html
@@ -1,5 +1,5 @@
{# Tool card macro #}
-{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none) %}
+{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none, tags=none) %}
{% if tool is none %}
{% set tool = {
"owner": owner,
@@ -7,7 +7,8 @@
"description": description,
"category": category,
"downloads": downloads,
- "version": version
+ "version": version,
+ "tags": tags
} %}
{% endif %}
@@ -39,6 +40,23 @@
{{ tool.description or 'No description available.' }}
+
+ {% if tool.tags %}
+
+ {% for tag in tool.tags[:5] %}
+
+ {{ tag }}
+
+ {% endfor %}
+ {% if tool.tags|length > 5 %}
+
+ +{{ tool.tags|length - 5 }} more
+
+ {% endif %}
+
+ {% endif %}
+
diff --git a/src/cmdforge/web/templates/pages/search.html b/src/cmdforge/web/templates/pages/search.html
index 49704ff..b5e0489 100644
--- a/src/cmdforge/web/templates/pages/search.html
+++ b/src/cmdforge/web/templates/pages/search.html
@@ -9,7 +9,7 @@