From b00115a52eee23ecfec1cddbbce728bc0ddc5998 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 30 Jan 2026 01:35:03 -0400 Subject: [PATCH] Add registry sort/pagination features and improve code step dialog Registry: - Add owner and rating sort options to both server endpoints - Add letter-based prefix filtering (A-Z, #) with server-side validation - LEFT JOIN tool_stats for average_rating/rating_count in responses - Add interactive numbered page buttons with sliding window and ellipsis - Add letter bar UI (shown when sorting by name) with highlight state - Auto-select asc/desc order based on sort field - Disable cell editing on results table - Client: add order and prefix params to list_tools/search_tools Code step dialog: - Split AI prompt into user instruction input and collapsible wrapper - User types plain instruction, wrapper is hidden by default - Injection of user text into wrapper via {user_instruction} placeholder - Increase dialog minimum height to 750px Runner: - Support variable substitution in prompt step provider name Co-Authored-By: Claude Opus 4.5 --- src/cmdforge/gui/dialogs/step_dialog.py | 55 ++++-- src/cmdforge/gui/pages/registry_page.py | 222 ++++++++++++++++++++++-- src/cmdforge/registry/app.py | 65 ++++++- src/cmdforge/registry_client.py | 21 ++- src/cmdforge/runner.py | 4 +- 5 files changed, 330 insertions(+), 37 deletions(-) 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)