diff --git a/src/cmdforge/gui/dialogs/step_dialog.py b/src/cmdforge/gui/dialogs/step_dialog.py
index 7e453ad..c50e70a 100644
--- a/src/cmdforge/gui/dialogs/step_dialog.py
+++ b/src/cmdforge/gui/dialogs/step_dialog.py
@@ -177,7 +177,7 @@ class CodeStepDialog(QDialog):
def __init__(self, parent, step: CodeStep = None, available_vars: list = None):
super().__init__(parent)
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
- self.setMinimumSize(900, 600)
+ self.setMinimumSize(900, 750)
self._step = step
self._available_vars = available_vars or ["input"]
self._worker = None
@@ -254,16 +254,30 @@ class CodeStepDialog(QDialog):
provider_layout.addStretch()
ai_layout.addLayout(provider_layout)
- # Prompt editor
- prompt_label = QLabel("Describe what you want the code to do:")
- ai_layout.addWidget(prompt_label)
+ # User instruction input (always visible)
+ instruction_label = QLabel("Describe what you want the code to do:")
+ ai_layout.addWidget(instruction_label)
- self.ai_prompt_input = QPlainTextEdit()
- self.ai_prompt_input.setPlaceholderText(
+ self.user_instruction_input = QPlainTextEdit()
+ self.user_instruction_input.setPlaceholderText(
"Example: Parse the input as JSON and extract the 'name' field"
)
- # Set default prompt template
+ self.user_instruction_input.setMaximumHeight(100)
+ ai_layout.addWidget(self.user_instruction_input, 1)
+
+ # Collapsible prompt wrapper section
+ self.wrapper_toggle = QPushButton("Edit prompt wrapper ▶")
+ self.wrapper_toggle.setFlat(True)
+ self.wrapper_toggle.setStyleSheet(
+ "QPushButton { color: #4a5568; font-size: 12px; text-align: left; "
+ "padding: 2px 0; } QPushButton:hover { color: #2d3748; }"
+ )
+ self.wrapper_toggle.clicked.connect(self._toggle_wrapper)
+ ai_layout.addWidget(self.wrapper_toggle)
+
+ self.ai_prompt_input = QPlainTextEdit()
self._set_default_prompt()
+ self.ai_prompt_input.hide()
ai_layout.addWidget(self.ai_prompt_input, 2)
# Generate button
@@ -306,6 +320,15 @@ class CodeStepDialog(QDialog):
layout.addLayout(buttons)
+ def _toggle_wrapper(self):
+ """Toggle visibility of the prompt wrapper editor."""
+ if self.ai_prompt_input.isVisible():
+ self.ai_prompt_input.hide()
+ self.wrapper_toggle.setText("Edit prompt wrapper ▶")
+ else:
+ self.ai_prompt_input.show()
+ self.wrapper_toggle.setText("Edit prompt wrapper ▼")
+
def _set_default_prompt(self):
"""Set the default AI prompt template."""
vars_formatted = ', '.join(f'"{{{v}}}"' for v in self._available_vars)
@@ -317,7 +340,7 @@ Example:
my_var = \"\"\"{{input}}\"\"\"
result = my_var.upper()
-INSTRUCTION: [Describe what you want the code to do]
+INSTRUCTION: {{user_instruction}}
CURRENT CODE:
{{code}}
@@ -329,14 +352,20 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
def _generate_code(self):
"""Generate code using AI."""
- prompt_template = self.ai_prompt_input.toPlainText().strip()
- if not prompt_template:
- self.ai_output.setHtml("Please enter a prompt")
+ user_instruction = self.user_instruction_input.toPlainText().strip()
+ if not user_instruction:
+ self.ai_output.setHtml("Please describe what you want the code to do")
return
- # Replace {code} placeholder with current code
+ prompt_template = self.ai_prompt_input.toPlainText().strip()
+ if not prompt_template:
+ self.ai_output.setHtml("Prompt wrapper is empty")
+ return
+
+ # Inject user instruction and current code into the wrapper
current_code = self.code_input.toPlainText().strip() or "# No code yet"
- prompt = prompt_template.replace("{code}", current_code)
+ prompt = prompt_template.replace("{user_instruction}", user_instruction)
+ prompt = prompt.replace("{code}", current_code)
provider = self.ai_provider_combo.currentText()
diff --git a/src/cmdforge/gui/pages/registry_page.py b/src/cmdforge/gui/pages/registry_page.py
index 574039f..1c3f68b 100644
--- a/src/cmdforge/gui/pages/registry_page.py
+++ b/src/cmdforge/gui/pages/registry_page.py
@@ -14,17 +14,24 @@ from ...config import load_config
from ...tool import list_tools, load_tool, get_all_categories
+# Sort fields that should default to ascending order
+ASC_SORTS = {"name", "owner"}
+
+
class SearchWorker(QThread):
"""Background worker for registry search."""
finished = Signal(object) # PaginatedResponse
error = Signal(str)
def __init__(self, query: str = "", category: str = None, sort: str = "downloads",
+ order: str = "desc", prefix: str = None,
tags: list = None, page: int = 1, per_page: int = 20):
super().__init__()
self.query = query
self.category = category
self.sort = sort
+ self.order = order
+ self.prefix = prefix
self.tags = tags
self.page = page
self.per_page = per_page
@@ -40,7 +47,9 @@ class SearchWorker(QThread):
category=category,
page=self.page,
per_page=self.per_page,
- sort=self.sort
+ sort=self.sort,
+ order=self.order,
+ prefix=self.prefix
)
else:
result = client.search_tools(
@@ -50,6 +59,8 @@ class SearchWorker(QThread):
page=self.page,
per_page=self.per_page,
sort=self.sort,
+ order=self.order,
+ prefix=self.prefix,
include_facets=True
)
self.finished.emit(result)
@@ -111,6 +122,7 @@ class RegistryPage(QWidget):
self._current_page = 1
self._total_pages = 1
self._current_tags = []
+ self._current_prefix = None # Active letter filter (None = no filter)
self._installed_tools = {} # name -> version
self._setup_ui()
@@ -152,8 +164,11 @@ class RegistryPage(QWidget):
cat_label = QLabel("Category:")
filters_layout.addWidget(cat_label)
self.category_combo = QComboBox()
+ self.category_combo.setEditable(True)
+ self.category_combo.lineEdit().setReadOnly(True)
self.category_combo.addItems(["All"] + get_all_categories())
self.category_combo.setMinimumWidth(100)
+ self.category_combo.setMinimumHeight(28)
self.category_combo.setToolTip("Filter by tool category")
self.category_combo.currentTextChanged.connect(self._on_filter_changed)
filters_layout.addWidget(self.category_combo)
@@ -162,12 +177,17 @@ class RegistryPage(QWidget):
sort_label = QLabel("Sort:")
filters_layout.addWidget(sort_label)
self.sort_combo = QComboBox()
+ self.sort_combo.setEditable(True)
+ self.sort_combo.lineEdit().setReadOnly(True)
self.sort_combo.addItem("Most Popular", "downloads")
self.sort_combo.addItem("Newest", "published_at")
self.sort_combo.addItem("Name (A-Z)", "name")
+ self.sort_combo.addItem("Owner", "owner")
+ self.sort_combo.addItem("Rating", "average_rating")
self.sort_combo.setMinimumWidth(120)
+ self.sort_combo.setMinimumHeight(28)
self.sort_combo.setToolTip("Change sort order of results")
- self.sort_combo.currentIndexChanged.connect(self._on_filter_changed)
+ self.sort_combo.currentIndexChanged.connect(self._on_sort_changed)
filters_layout.addWidget(self.sort_combo)
# Search button
@@ -190,6 +210,42 @@ class RegistryPage(QWidget):
self.tags_widget.hide()
layout.addWidget(self.tags_widget)
+ # Letter bar (shown only when sorting by name)
+ self.letter_bar_widget = QWidget()
+ self.letter_bar_widget.setMinimumHeight(40)
+ letter_bar_layout = QHBoxLayout(self.letter_bar_widget)
+ letter_bar_layout.setContentsMargins(0, 4, 0, 4)
+ letter_bar_layout.setSpacing(2)
+ self._letter_buttons = {}
+ letters = ["#"] + [chr(c) for c in range(ord("A"), ord("Z") + 1)]
+ for letter in letters:
+ btn = QPushButton(letter)
+ btn.setMinimumSize(26, 26)
+ btn.setMaximumSize(30, 30)
+ btn.setStyleSheet(
+ "QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
+ "font-size: 12px; padding: 2px 0; background: #edf2f7; color: #1a202c; }"
+ "QPushButton:hover { background: #cbd5e0; }"
+ )
+ btn.clicked.connect(lambda checked, l=letter: self._on_letter_clicked(l))
+ letter_bar_layout.addWidget(btn)
+ self._letter_buttons[letter] = btn
+ letter_bar_layout.addStretch()
+ # "All" button to clear letter filter
+ btn_all = QPushButton("All")
+ btn_all.setMinimumHeight(26)
+ btn_all.setMaximumHeight(30)
+ btn_all.setStyleSheet(
+ "QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
+ "font-size: 12px; padding: 2px 8px; background: #edf2f7; color: #1a202c; }"
+ "QPushButton:hover { background: #cbd5e0; }"
+ )
+ btn_all.clicked.connect(self._clear_letter_filter)
+ letter_bar_layout.addWidget(btn_all)
+ self._letter_buttons["All"] = btn_all
+ self.letter_bar_widget.hide()
+ layout.addWidget(self.letter_bar_widget)
+
# Content splitter
splitter = QSplitter(Qt.Horizontal)
@@ -209,6 +265,7 @@ class RegistryPage(QWidget):
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
+ self.results_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
self.results_table.setSelectionMode(QTableWidget.SingleSelection)
self.results_table.verticalHeader().setVisible(False)
@@ -217,10 +274,11 @@ class RegistryPage(QWidget):
# Pagination controls
pagination = QWidget()
+ pagination.setMinimumHeight(44)
pag_layout = QHBoxLayout(pagination)
- pag_layout.setContentsMargins(0, 8, 0, 0)
+ pag_layout.setContentsMargins(0, 6, 0, 6)
- self.btn_prev = QPushButton("← Previous")
+ self.btn_prev = QPushButton("← Prev")
self.btn_prev.setToolTip("Go to previous page of results")
self.btn_prev.clicked.connect(self._prev_page)
self.btn_prev.setEnabled(False)
@@ -228,9 +286,13 @@ class RegistryPage(QWidget):
pag_layout.addStretch()
- self.page_label = QLabel("Page 1 of 1")
- self.page_label.setStyleSheet("color: #718096;")
- pag_layout.addWidget(self.page_label)
+ # Page buttons container (replaces static label)
+ self.page_buttons_widget = QWidget()
+ self.page_buttons_widget.setMinimumHeight(32)
+ self.page_buttons_layout = QHBoxLayout(self.page_buttons_widget)
+ self.page_buttons_layout.setContentsMargins(0, 0, 0, 0)
+ self.page_buttons_layout.setSpacing(4)
+ pag_layout.addWidget(self.page_buttons_widget)
pag_layout.addStretch()
@@ -323,18 +385,79 @@ class RegistryPage(QWidget):
"""Browse all tools (no search query)."""
self.search_input.clear()
self._current_page = 1
+ self._current_prefix = None
self._do_search()
+ def _get_sort_order(self, sort_field: str) -> str:
+ """Get the appropriate sort order for a sort field."""
+ if sort_field in ASC_SORTS:
+ return "asc"
+ return "desc"
+
def _on_filter_changed(self):
- """Handle filter/sort change."""
+ """Handle category filter change."""
+ self._current_page = 1
+ self._current_prefix = None
+ self._do_search()
+
+ def _on_sort_changed(self):
+ """Handle sort change — show/hide letter bar, reset prefix."""
+ sort = self.sort_combo.itemData(self.sort_combo.currentIndex())
+ if sort == "name":
+ self.letter_bar_widget.show()
+ else:
+ self.letter_bar_widget.hide()
+ self._current_prefix = None
+ self._update_letter_bar_highlight()
self._current_page = 1
self._do_search()
+ def _on_letter_clicked(self, letter: str):
+ """Handle letter bar click."""
+ self._current_prefix = letter
+ self._current_page = 1
+ self._update_letter_bar_highlight()
+ self._do_search()
+
+ def _clear_letter_filter(self):
+ """Clear letter filter — show all."""
+ self._current_prefix = None
+ self._current_page = 1
+ self._update_letter_bar_highlight()
+ self._do_search()
+
+ def _update_letter_bar_highlight(self):
+ """Highlight the active letter button."""
+ active_style = (
+ "QPushButton { border: 1px solid #4299e1; border-radius: 4px; "
+ "font-size: 12px; padding: 2px 0; background: #4299e1; color: white; font-weight: bold; }"
+ )
+ normal_style = (
+ "QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
+ "font-size: 12px; padding: 2px 0; background: #edf2f7; color: #1a202c; }"
+ "QPushButton:hover { background: #cbd5e0; }"
+ )
+ normal_all_style = (
+ "QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
+ "font-size: 12px; padding: 2px 8px; background: #edf2f7; color: #1a202c; }"
+ "QPushButton:hover { background: #cbd5e0; }"
+ )
+ active_all_style = (
+ "QPushButton { border: 1px solid #4299e1; border-radius: 4px; "
+ "font-size: 12px; padding: 2px 8px; background: #4299e1; color: white; font-weight: bold; }"
+ )
+ for letter, btn in self._letter_buttons.items():
+ if letter == "All":
+ btn.setStyleSheet(active_all_style if self._current_prefix is None else normal_all_style)
+ else:
+ btn.setStyleSheet(active_style if letter == self._current_prefix else normal_style)
+
def _do_search(self):
"""Perform search with current filters."""
query = self.search_input.text().strip()
category = self.category_combo.currentText()
- sort = self.sort_combo.currentData()
+ sort = self.sort_combo.itemData(self.sort_combo.currentIndex())
+ order = self._get_sort_order(sort)
self.btn_search.setEnabled(False)
self.btn_browse.setEnabled(False)
@@ -345,6 +468,8 @@ class RegistryPage(QWidget):
query=query,
category=category,
sort=sort,
+ order=order,
+ prefix=self._current_prefix,
tags=self._current_tags if self._current_tags else None,
page=self._current_page,
per_page=20
@@ -363,11 +488,11 @@ class RegistryPage(QWidget):
total = result.total or len(tools)
self.status_label.setText(f"Found {total} tools")
- self.page_label.setText(f"Page {self._current_page} of {self._total_pages}")
# Update pagination buttons
self.btn_prev.setEnabled(self._current_page > 1)
self.btn_next.setEnabled(self._current_page < self._total_pages)
+ self._update_pagination()
self.results_table.setRowCount(len(tools))
for row, tool in enumerate(tools):
@@ -413,6 +538,83 @@ class RegistryPage(QWidget):
# Version
self.results_table.setItem(row, 5, QTableWidgetItem(tool.get("version", "1.0.0")))
+ def _update_pagination(self):
+ """Rebuild interactive page number buttons."""
+ # Clear existing buttons
+ while self.page_buttons_layout.count():
+ item = self.page_buttons_layout.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+
+ if self._total_pages <= 1:
+ lbl = QLabel("Page 1 of 1")
+ lbl.setStyleSheet("color: #718096;")
+ self.page_buttons_layout.addWidget(lbl)
+ return
+
+ current = self._current_page
+ total = self._total_pages
+
+ # Determine which page numbers to show
+ if total <= 7:
+ pages = list(range(1, total + 1))
+ else:
+ # Sliding window of 5 centered on current page
+ half = 2
+ start = max(1, current - half)
+ end = min(total, current + half)
+ # Adjust if near edges
+ if current - half < 1:
+ end = min(total, end + (1 - (current - half)))
+ if current + half > total:
+ start = max(1, start - (current + half - total))
+ pages = list(range(start, end + 1))
+
+ # Leading ellipsis
+ if pages and pages[0] > 1:
+ self._add_page_button(1)
+ if pages[0] > 2:
+ lbl = QLabel("...")
+ lbl.setStyleSheet("color: #718096; padding: 0 2px;")
+ self.page_buttons_layout.addWidget(lbl)
+
+ for p in pages:
+ self._add_page_button(p)
+
+ # Trailing ellipsis
+ if pages and pages[-1] < total:
+ if pages[-1] < total - 1:
+ lbl = QLabel("...")
+ lbl.setStyleSheet("color: #718096; padding: 0 2px;")
+ self.page_buttons_layout.addWidget(lbl)
+ self._add_page_button(total)
+
+ def _add_page_button(self, page_num: int):
+ """Add a single page number button."""
+ btn = QPushButton(str(page_num))
+ btn.setMinimumSize(32, 28)
+ btn.setMaximumHeight(30)
+ if page_num == self._current_page:
+ btn.setStyleSheet(
+ "QPushButton { background: #4299e1; color: white; border: none; "
+ "border-radius: 4px; font-weight: bold; font-size: 12px; "
+ "padding: 2px 6px; }"
+ )
+ else:
+ btn.setStyleSheet(
+ "QPushButton { background: #edf2f7; border: 1px solid #a0aec0; "
+ "border-radius: 4px; color: #1a202c; font-size: 12px; "
+ "padding: 2px 6px; }"
+ "QPushButton:hover { background: #cbd5e0; }"
+ )
+ btn.clicked.connect(lambda checked, p=page_num: self._go_to_page(p))
+ self.page_buttons_layout.addWidget(btn)
+
+ def _go_to_page(self, page: int):
+ """Navigate to a specific page."""
+ self._current_page = page
+ self._do_search()
+
def _rating_to_stars(self, rating: float) -> str:
"""Convert rating to star display."""
if rating <= 0:
diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py
index a77ef33..b1d967e 100644
--- a/src/cmdforge/registry/app.py
+++ b/src/cmdforge/registry/app.py
@@ -53,8 +53,8 @@ RATE_LIMITS = {
}
ALLOWED_SORT = {
- "/tools": {"downloads", "published_at", "name"},
- "/tools/search": {"relevance", "downloads", "published_at"},
+ "/tools": {"downloads", "published_at", "name", "owner", "average_rating"},
+ "/tools/search": {"relevance", "downloads", "published_at", "name", "owner", "average_rating"},
"/categories": {"name", "tool_count"},
}
@@ -602,8 +602,18 @@ def create_app() -> Flask:
if error:
return error
category = request.args.get("category")
+ prefix = request.args.get("prefix", "").strip()
offset = (page - 1) * per_page
+ # Validate prefix: single letter A-Z or '#'
+ if prefix:
+ if prefix == "#":
+ pass # valid
+ elif len(prefix) == 1 and prefix.isalpha():
+ prefix = prefix.upper()
+ else:
+ prefix = "" # invalid, ignore
+
# Build visibility filter
vis_filter, vis_params = build_visibility_filter()
@@ -612,6 +622,11 @@ def create_app() -> Flask:
if category:
base_where += " AND category = ?"
params.append(category)
+ if prefix == "#":
+ base_where += " AND name NOT GLOB '[A-Za-z]*'"
+ elif prefix:
+ base_where += " AND LOWER(name) LIKE ?"
+ params.append(f"{prefix.lower()}%")
# Add visibility filter
base_where += vis_filter
params.extend(vis_params)
@@ -624,7 +639,13 @@ def create_app() -> Flask:
total = int(count_row["total"]) if count_row else 0
order_dir = "DESC" if order == "desc" else "ASC"
- order_sql = f"{sort} {order_dir}, published_at DESC, id DESC"
+ if sort == "average_rating":
+ order_sql = f"COALESCE(ts.average_rating, 0) {order_dir}, published_at DESC, t.id DESC"
+ else:
+ order_sql = f"{sort} {order_dir}, published_at DESC, id DESC"
+
+ # LEFT JOIN tool_stats for rating data
+ stats_join = "LEFT JOIN tool_stats ts ON ts.tool_id = t.id"
rows = query_all(
g.db,
@@ -641,7 +662,10 @@ def create_app() -> Flask:
{base_where} AND version NOT LIKE '%-%'
GROUP BY owner, name
)
- SELECT t.* FROM tools t
+ SELECT t.*, COALESCE(ts.average_rating, 0) AS average_rating,
+ COALESCE(ts.rating_count, 0) AS rating_count
+ FROM tools t
+ {stats_join}
JOIN (
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
FROM latest_any a
@@ -665,6 +689,8 @@ def create_app() -> Flask:
"tags": json.loads(row["tags"] or "[]"),
"downloads": row["downloads"],
"published_at": row["published_at"],
+ "average_rating": row["average_rating"],
+ "rating_count": row["rating_count"],
})
return jsonify({"data": data, "meta": paginate(page, per_page, total)})
@@ -693,6 +719,16 @@ def create_app() -> Flask:
return error
offset = (page - 1) * per_page
+ # Prefix filter
+ prefix = request.args.get("prefix", "").strip()
+ if prefix:
+ if prefix == "#":
+ pass
+ elif len(prefix) == 1 and prefix.isalpha():
+ prefix = prefix.upper()
+ else:
+ prefix = ""
+
# Parse filter parameters
category = request.args.get("category") # Single category (backward compat)
categories_param = request.args.get("categories", "") # Multi-category (OR logic)
@@ -743,6 +779,12 @@ def create_app() -> Flask:
if not include_deprecated:
where_clauses.append("tools.deprecated = 0")
+ if prefix == "#":
+ where_clauses.append("tools.name NOT GLOB '[A-Za-z]*'")
+ elif prefix:
+ where_clauses.append("LOWER(tools.name) LIKE ?")
+ params.append(f"{prefix.lower()}%")
+
# Add visibility filtering
vis_filter, vis_params = build_visibility_filter("tools")
if vis_filter:
@@ -772,9 +814,13 @@ def create_app() -> Flask:
order_dir = "DESC" if order == "desc" else "ASC"
if sort == "relevance":
- order_sql = f"rank {order_dir}, downloads DESC, published_at DESC, id DESC"
+ order_sql = "rank, downloads DESC, published_at DESC, f.id DESC"
+ elif sort == "average_rating":
+ order_sql = f"COALESCE(ts.average_rating, 0) {order_dir}, downloads DESC, published_at DESC, f.id DESC"
else:
- order_sql = f"{sort} {order_dir}, published_at DESC, id DESC"
+ order_sql = f"{sort} {order_dir}, published_at DESC, f.id DESC"
+
+ stats_join = "LEFT JOIN tool_stats ts ON ts.tool_id = f.id"
rows = query_all(
g.db,
@@ -801,7 +847,10 @@ def create_app() -> Flask:
WHERE version NOT LIKE '%-%'
GROUP BY owner, name
)
- SELECT f.* FROM filtered f
+ SELECT f.*, COALESCE(ts.average_rating, 0) AS average_rating,
+ COALESCE(ts.rating_count, 0) AS rating_count
+ FROM filtered f
+ {stats_join}
JOIN (
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
FROM latest_any a
@@ -847,6 +896,8 @@ def create_app() -> Flask:
"tags": json.loads(row["tags"] or "[]"),
"downloads": row["downloads"],
"published_at": row["published_at"],
+ "average_rating": row["average_rating"],
+ "rating_count": row["rating_count"],
"score": score,
})
diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py
index e85327e..44d8106 100644
--- a/src/cmdforge/registry_client.py
+++ b/src/cmdforge/registry_client.py
@@ -290,7 +290,8 @@ class RegistryClient:
page: int = 1,
per_page: int = 20,
sort: str = "downloads",
- order: str = "desc"
+ order: str = "desc",
+ prefix: Optional[str] = None
) -> PaginatedResponse:
"""
List tools from the registry.
@@ -299,8 +300,9 @@ class RegistryClient:
category: Filter by category
page: Page number (1-indexed)
per_page: Items per page (max 100)
- sort: Sort field (downloads, published_at, name)
+ sort: Sort field (downloads, published_at, name, owner, average_rating)
order: Sort order (asc, desc)
+ prefix: Letter prefix filter (A-Z or '#' for non-alpha)
Returns:
PaginatedResponse with tool data
@@ -313,6 +315,8 @@ class RegistryClient:
}
if category:
params["category"] = category
+ if prefix:
+ params["prefix"] = prefix
response = self._request("GET", "/tools", params=params)
@@ -345,7 +349,9 @@ class RegistryClient:
include_facets: bool = False,
page: int = 1,
per_page: int = 20,
- sort: str = "relevance"
+ sort: str = "relevance",
+ order: str = "desc",
+ prefix: Optional[str] = None
) -> PaginatedResponse:
"""
Search for tools in the registry.
@@ -364,7 +370,9 @@ class RegistryClient:
include_facets: Include category/tag/owner counts in response
page: Page number
per_page: Items per page
- sort: Sort field (relevance, downloads, published_at)
+ sort: Sort field (relevance, downloads, published_at, name, owner, average_rating)
+ order: Sort order (asc, desc)
+ prefix: Letter prefix filter (A-Z or '#' for non-alpha)
Returns:
PaginatedResponse with matching tools (and facets if requested)
@@ -373,7 +381,8 @@ class RegistryClient:
"q": query,
"page": page,
"per_page": min(per_page, 100),
- "sort": sort
+ "sort": sort,
+ "order": order
}
if category:
params["category"] = category
@@ -395,6 +404,8 @@ class RegistryClient:
params["deprecated"] = "true"
if include_facets:
params["include_facets"] = "true"
+ if prefix:
+ params["prefix"] = prefix
response = self._request("GET", "/tools/search", params=params)
diff --git a/src/cmdforge/runner.py b/src/cmdforge/runner.py
index 58df4b6..b0aee71 100644
--- a/src/cmdforge/runner.py
+++ b/src/cmdforge/runner.py
@@ -209,8 +209,8 @@ def execute_prompt_step(
# Prepend system prompt to user prompt
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
- # Call provider
- provider = provider_override or step.provider
+ # Call provider (support variable substitution in provider name, e.g. {settings.provider})
+ provider = provider_override or substitute_variables(step.provider, variables, warn_non_scalar=verbose)
if provider.lower() == "mock":
result = mock_provider(prompt)