Add local collections support with GUI, CLI, and publishing workflow

Features:
- Local collection definitions stored in ~/.cmdforge/collections/
- CLI commands: create, show, add, remove, delete, publish, status
- GUI Collections page with local and registry tabs
- Collection publishing with tool resolution and visibility checks
- New API endpoints: GET /api/v1/me, GET /api/v1/tools/.../approved,
  POST /api/v1/collections
- RegistryClient methods: get_me(), has_approved_public_tool(),
  publish_collection()

Implementation:
- collection.py: Collection dataclass, resolve_tool_references(),
  classify_tool_reference(), ToolResolutionResult
- collections_page.py: GUI with background workers for install/publish
- collections_commands.py: Full CLI command implementations
- registry/app.py: New authenticated endpoints with validation

Tests:
- test_collection.py: 27 unit tests for collection module
- test_collection_api.py: 20 tests (8 client, 12 API with Flask skip)

Documentation updated: README, CHANGELOG, CLAUDE.md, tests/README

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-26 16:50:19 -04:00
co-authored by Claude Opus 4.5
parent 37e74bf339
commit 29869ef59f
14 changed files with 2917 additions and 27 deletions
+18 -7
View File
@@ -104,9 +104,10 @@ class MainWindow(QMainWindow):
items = [
("CmdForge", "Welcome page - Quick actions and getting started (Ctrl+1)"),
("Tools", "View, edit, delete, and test your local tools (Ctrl+2)"),
("Registry", "Browse and install tools from the community (Ctrl+3)"),
("Providers", "Configure AI backends like Claude, GPT, etc. (Ctrl+4)"),
("Profiles", "AI persona profiles for customizing tool behavior"),
("Collections", "Manage and publish tool collections (Ctrl+3)"),
("Registry", "Browse and install tools from the community (Ctrl+4)"),
("Providers", "Configure AI backends like Claude, GPT, etc. (Ctrl+5)"),
("Profiles", "AI persona profiles for customizing tool behavior (Ctrl+6)"),
]
font = QFont()
@@ -129,18 +130,21 @@ class MainWindow(QMainWindow):
# Import pages here to avoid circular imports
from .pages.welcome_page import WelcomePage
from .pages.tools_page import ToolsPage
from .pages.collections_page import CollectionsPage
from .pages.registry_page import RegistryPage
from .pages.providers_page import ProvidersPage
from .pages.profiles_page import ProfilesPage
self.welcome_page = WelcomePage(self)
self.tools_page = ToolsPage(self)
self.collections_page = CollectionsPage(self)
self.registry_page = RegistryPage(self)
self.providers_page = ProvidersPage(self)
self.profiles_page = ProfilesPage(self)
self.pages.addWidget(self.welcome_page)
self.pages.addWidget(self.tools_page)
self.pages.addWidget(self.collections_page)
self.pages.addWidget(self.registry_page)
self.pages.addWidget(self.providers_page)
self.pages.addWidget(self.profiles_page)
@@ -164,9 +168,10 @@ class MainWindow(QMainWindow):
"welcome": 0,
"cmdforge": 0,
"tools": 1,
"registry": 2,
"providers": 3,
"profiles": 4,
"collections": 2,
"registry": 3,
"providers": 4,
"profiles": 5,
}
if page_name.lower() in page_map:
self.sidebar.setCurrentRow(page_map[page_name.lower()])
@@ -211,7 +216,7 @@ class MainWindow(QMainWindow):
shortcut_escape = QShortcut(QKeySequence("Escape"), self)
shortcut_escape.activated.connect(self._shortcut_escape)
# Ctrl+1/2/3/4: Navigate pages
# Ctrl+1/2/3/4/5/6: Navigate pages
shortcut_page1 = QShortcut(QKeySequence("Ctrl+1"), self)
shortcut_page1.activated.connect(lambda: self.sidebar.setCurrentRow(0))
@@ -224,6 +229,12 @@ class MainWindow(QMainWindow):
shortcut_page4 = QShortcut(QKeySequence("Ctrl+4"), self)
shortcut_page4.activated.connect(lambda: self.sidebar.setCurrentRow(3))
shortcut_page5 = QShortcut(QKeySequence("Ctrl+5"), self)
shortcut_page5.activated.connect(lambda: self.sidebar.setCurrentRow(4))
shortcut_page6 = QShortcut(QKeySequence("Ctrl+6"), self)
shortcut_page6.activated.connect(lambda: self.sidebar.setCurrentRow(5))
# Ctrl+R: Refresh current page
shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self)
shortcut_refresh.activated.connect(self._shortcut_refresh)
+932
View File
@@ -0,0 +1,932 @@
"""Collections page - manage local collections and browse registry collections."""
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
QListWidget, QListWidgetItem, QTextEdit, QLabel,
QPushButton, QGroupBox, QMessageBox, QFrame,
QLineEdit, QTabWidget, QDialog, QFormLayout,
QDialogButtonBox, QComboBox, QCheckBox
)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont
from ...collection import (
Collection, list_collections, get_collection, COLLECTIONS_DIR,
classify_tool_reference, resolve_tool_references
)
from ...tool import list_tools
class CollectionCreateDialog(QDialog):
"""Dialog for creating a new collection."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Create Collection")
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
# Form
form = QFormLayout()
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("my-collection (lowercase, hyphens ok)")
form.addRow("Name:", self.name_edit)
self.display_name_edit = QLineEdit()
self.display_name_edit.setPlaceholderText("My Collection")
form.addRow("Display Name:", self.display_name_edit)
self.description_edit = QTextEdit()
self.description_edit.setMaximumHeight(80)
self.description_edit.setPlaceholderText("Description of your collection...")
form.addRow("Description:", self.description_edit)
self.tags_edit = QLineEdit()
self.tags_edit.setPlaceholderText("tag1, tag2, tag3")
form.addRow("Tags:", self.tags_edit)
layout.addLayout(form)
# Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
# Auto-fill display name from name
self.name_edit.textChanged.connect(self._update_display_name)
def _update_display_name(self, name: str):
if not self.display_name_edit.text():
display = name.replace("-", " ").title()
self.display_name_edit.setPlaceholderText(display or "My Collection")
def get_data(self) -> dict:
name = self.name_edit.text().strip().lower()
display_name = self.display_name_edit.text().strip()
if not display_name:
display_name = name.replace("-", " ").title()
tags_text = self.tags_edit.text().strip()
tags = [t.strip() for t in tags_text.split(",") if t.strip()]
return {
"name": name,
"display_name": display_name,
"description": self.description_edit.toPlainText().strip(),
"tags": tags,
}
class AddToolDialog(QDialog):
"""Dialog for adding a tool to a collection."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Add Tool")
self.setMinimumWidth(350)
layout = QVBoxLayout(self)
# Tool selection
form = QFormLayout()
self.tool_combo = QComboBox()
self.tool_combo.setEditable(True)
self.tool_combo.setPlaceholderText("Select or enter tool name...")
# Populate with local tools
local_tools = list_tools()
self.tool_combo.addItems(local_tools)
form.addRow("Tool:", self.tool_combo)
self.version_edit = QLineEdit()
self.version_edit.setPlaceholderText("Optional: ^1.0.0 or specific version")
form.addRow("Pin Version:", self.version_edit)
layout.addLayout(form)
# Hint
hint = QLabel("For registry tools, use format: owner/name")
hint.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(hint)
# Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def get_data(self) -> dict:
return {
"tool": self.tool_combo.currentText().strip(),
"version": self.version_edit.text().strip() or None,
}
class InstallWorker(QThread):
"""Background worker for installing registry collections."""
finished = Signal(bool, str, int, int) # success, message, installed, failed
progress = Signal(str) # status message
def __init__(self, collection_name: str):
super().__init__()
self.collection_name = collection_name
def run(self):
from ...registry_client import get_client, RegistryError
from ...resolver import install_from_registry
installed = 0
failed = 0
try:
client = get_client()
self.progress.emit(f"Fetching collection '{self.collection_name}'...")
coll = client.get_collection(self.collection_name)
tools = coll.get("tools", [])
if not tools:
self.finished.emit(True, "Collection has no tools", 0, 0)
return
total = len(tools)
for i, tool in enumerate(tools, 1):
if isinstance(tool, dict):
tool_ref = f"{tool['owner']}/{tool['name']}"
else:
tool_ref = tool
self.progress.emit(f"Installing {tool_ref} ({i}/{total})...")
try:
install_from_registry(tool_ref)
installed += 1
except Exception as e:
failed += 1
if failed:
self.finished.emit(False, f"Installed {installed} tools, {failed} failed", installed, failed)
else:
self.finished.emit(True, f"Installed {installed} tools from '{self.collection_name}'", installed, failed)
except RegistryError as e:
self.finished.emit(False, f"Registry error: {e.message}", installed, failed)
except Exception as e:
self.finished.emit(False, f"Error: {str(e)}", installed, failed)
class PublishAnalysisWorker(QThread):
"""Background worker to analyze collection before publishing."""
finished = Signal(dict) # result dict with analysis
def __init__(self, collection: Collection):
super().__init__()
self.collection = collection
def run(self):
from ...registry_client import get_client, RegistryError
result = {
"success": False,
"error": None,
"user_slug": None,
"resolution": None,
}
try:
client = get_client()
# Validate token
is_valid, error = client.validate_token()
if not is_valid:
result["error"] = f"Not authenticated: {error}"
self.finished.emit(result)
return
# Get user slug
user_info = client.get_me()
user_slug = user_info.get("slug", "")
if not user_slug:
result["error"] = "Could not determine registry username"
self.finished.emit(result)
return
result["user_slug"] = user_slug
# Resolve tool references
resolution = resolve_tool_references(
self.collection.tools,
self.collection.pinned,
user_slug,
client
)
result["resolution"] = resolution
result["success"] = True
self.finished.emit(result)
except RegistryError as e:
result["error"] = f"Registry error: {e.message}"
self.finished.emit(result)
except Exception as e:
result["error"] = f"Error: {str(e)}"
self.finished.emit(result)
class PublishWorker(QThread):
"""Background worker for publishing collections."""
finished = Signal(bool, str) # success, message
progress = Signal(str) # status message
def __init__(self, collection: Collection, user_slug: str, registry_refs: list,
transformed_pinned: dict, skip_unpublished: list = None,
publish_unpublished: list = None):
super().__init__()
self.collection = collection
self.user_slug = user_slug
self.registry_refs = registry_refs
self.transformed_pinned = transformed_pinned
self.skip_unpublished = skip_unpublished or []
self.publish_unpublished = publish_unpublished or []
def run(self):
from ...registry_client import get_client, RegistryError
try:
client = get_client()
# Publish unpublished local tools first if requested
if self.publish_unpublished:
from ...cli.collections_commands import _publish_single_tool
pending_tools = []
for tool_name in self.publish_unpublished:
self.progress.emit(f"Publishing tool: {tool_name}...")
result = _publish_single_tool(tool_name, client)
if not result.get("success"):
self.finished.emit(False, f"Failed to publish {tool_name}: {result.get('error')}")
return
if result.get("pending"):
pending_tools.append(tool_name)
if pending_tools:
# Save pending state and exit without publishing collection yet
self.collection.pending_approval = True
self.collection.pending_tools = pending_tools
self.collection.maintainer = self.user_slug
self.collection.save()
self.finished.emit(True,
"Tools submitted for review. Collection will publish once approved.")
return
# Filter out skipped tools
if self.skip_unpublished:
unpublished_full_refs = {f"{self.user_slug}/{u}" for u in self.skip_unpublished}
self.registry_refs = [ref for ref in self.registry_refs if ref not in unpublished_full_refs]
for ref in unpublished_full_refs:
self.transformed_pinned.pop(ref, None)
if not self.registry_refs:
self.finished.emit(False, "No tools remaining after filtering")
return
# Publish collection
self.progress.emit("Publishing collection...")
payload = {
"name": self.collection.name,
"display_name": self.collection.display_name,
"description": self.collection.description,
"maintainer": self.user_slug,
"tools": self.registry_refs,
"pinned": self.transformed_pinned,
"tags": self.collection.tags,
}
client.publish_collection(payload)
# Update local state
self.collection.published = True
self.collection.registry_name = self.collection.name
self.collection.maintainer = self.user_slug
self.collection.pending_approval = False
self.collection.pending_tools = []
# If we skipped tools, update the local collection to match
if self.skip_unpublished:
self.collection.tools = [t for t in self.collection.tools if t not in self.skip_unpublished]
for u in self.skip_unpublished:
self.collection.pinned.pop(u, None)
self.collection.save()
self.finished.emit(True, f"Collection published: {self.collection.name}")
except RegistryError as e:
self.finished.emit(False, f"Registry error: {e.message}")
except Exception as e:
self.finished.emit(False, f"Error: {str(e)}")
class CollectionsPage(QWidget):
"""Page for managing collections."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._setup_ui()
self._publish_worker = None
self._analysis_worker = None
self._install_worker = None
self._pending_analysis = None # Stores analysis result for publish flow
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
# Header
header = QHBoxLayout()
title = QLabel("Collections")
title.setFont(QFont("", 18, QFont.Bold))
header.addWidget(title)
header.addStretch()
self.btn_create = QPushButton("New Collection")
self.btn_create.clicked.connect(self._create_collection)
header.addWidget(self.btn_create)
self.btn_refresh = QPushButton("Refresh")
self.btn_refresh.clicked.connect(self.refresh)
header.addWidget(self.btn_refresh)
layout.addLayout(header)
# Tabs for local vs registry
self.tabs = QTabWidget()
# Local collections tab
local_widget = QWidget()
local_layout = QHBoxLayout(local_widget)
# Splitter for list and details
splitter = QSplitter(Qt.Horizontal)
# Collection list
list_frame = QFrame()
list_layout = QVBoxLayout(list_frame)
list_layout.setContentsMargins(0, 0, 0, 0)
self.collection_list = QListWidget()
self.collection_list.currentItemChanged.connect(self._on_selection_changed)
list_layout.addWidget(self.collection_list)
splitter.addWidget(list_frame)
# Details panel
details_frame = QFrame()
details_layout = QVBoxLayout(details_frame)
self.details_title = QLabel("Select a collection")
self.details_title.setFont(QFont("", 14, QFont.Bold))
details_layout.addWidget(self.details_title)
self.details_description = QLabel("")
self.details_description.setWordWrap(True)
details_layout.addWidget(self.details_description)
self.details_status = QLabel("")
details_layout.addWidget(self.details_status)
# Tools list
tools_group = QGroupBox("Tools")
tools_layout = QVBoxLayout(tools_group)
self.tools_list = QListWidget()
tools_layout.addWidget(self.tools_list)
# Tool action buttons
tool_buttons = QHBoxLayout()
self.btn_add_tool = QPushButton("Add Tool")
self.btn_add_tool.clicked.connect(self._add_tool)
self.btn_add_tool.setEnabled(False)
tool_buttons.addWidget(self.btn_add_tool)
self.btn_remove_tool = QPushButton("Remove Tool")
self.btn_remove_tool.clicked.connect(self._remove_tool)
self.btn_remove_tool.setEnabled(False)
tool_buttons.addWidget(self.btn_remove_tool)
tool_buttons.addStretch()
tools_layout.addLayout(tool_buttons)
details_layout.addWidget(tools_group)
# Collection action buttons
action_buttons = QHBoxLayout()
self.btn_publish = QPushButton("Publish")
self.btn_publish.clicked.connect(self._publish_collection)
self.btn_publish.setEnabled(False)
action_buttons.addWidget(self.btn_publish)
self.btn_delete = QPushButton("Delete")
self.btn_delete.clicked.connect(self._delete_collection)
self.btn_delete.setEnabled(False)
action_buttons.addWidget(self.btn_delete)
action_buttons.addStretch()
details_layout.addLayout(action_buttons)
details_layout.addStretch()
splitter.addWidget(details_frame)
splitter.setSizes([250, 450])
local_layout.addWidget(splitter)
self.tabs.addTab(local_widget, "Local Collections")
# Registry collections tab
registry_widget = QWidget()
registry_layout = QVBoxLayout(registry_widget)
self.registry_list = QListWidget()
self.registry_list.itemDoubleClicked.connect(self._install_registry_collection)
registry_layout.addWidget(self.registry_list)
registry_hint = QLabel("Double-click to install a collection")
registry_hint.setStyleSheet("color: gray;")
registry_layout.addWidget(registry_hint)
self.tabs.addTab(registry_widget, "Registry Collections")
layout.addWidget(self.tabs)
def refresh(self):
"""Refresh both local and registry collections."""
self._load_local_collections()
self._load_registry_collections()
def _load_local_collections(self):
"""Load local collections into the list."""
self.collection_list.clear()
for name in list_collections():
coll = get_collection(name)
if coll:
item = QListWidgetItem(coll.display_name)
item.setData(Qt.UserRole, name)
# Show status indicator
if coll.pending_approval:
item.setText(f"{coll.display_name} (pending)")
elif coll.published:
item.setText(f"{coll.display_name} (published)")
self.collection_list.addItem(item)
if self.collection_list.count() == 0:
item = QListWidgetItem("No local collections")
item.setFlags(Qt.NoItemFlags)
self.collection_list.addItem(item)
def _load_registry_collections(self):
"""Load registry collections in background."""
self.registry_list.clear()
self.registry_list.addItem("Loading...")
# Load in background to avoid blocking UI
QTimer.singleShot(100, self._fetch_registry_collections)
def _fetch_registry_collections(self):
"""Actually fetch registry collections."""
from ...registry_client import get_client, RegistryError
self.registry_list.clear()
try:
client = get_client()
collections = client.get_collections()
if not collections:
self.registry_list.addItem("No registry collections available")
return
for coll in collections:
name = coll.get("name", "")
display_name = coll.get("display_name", name)
tool_count = coll.get("tool_count", 0)
maintainer = coll.get("maintainer", "")
item = QListWidgetItem(f"{display_name} ({tool_count} tools) - {maintainer}")
item.setData(Qt.UserRole, name)
self.registry_list.addItem(item)
except RegistryError as e:
self.registry_list.addItem(f"Error: {e.message}")
except Exception as e:
self.registry_list.addItem(f"Error: {str(e)}")
def _on_selection_changed(self, current, previous):
"""Handle collection selection change."""
if not current:
self._clear_details()
return
name = current.data(Qt.UserRole)
if not name:
self._clear_details()
return
coll = get_collection(name)
if not coll:
self._clear_details()
return
self._show_collection_details(coll)
def _clear_details(self):
"""Clear the details panel."""
self.details_title.setText("Select a collection")
self.details_description.setText("")
self.details_status.setText("")
self.tools_list.clear()
self.btn_add_tool.setEnabled(False)
self.btn_remove_tool.setEnabled(False)
self.btn_publish.setEnabled(False)
self.btn_delete.setEnabled(False)
def _show_collection_details(self, coll: Collection):
"""Show collection details in the panel."""
self.details_title.setText(coll.display_name)
self.details_description.setText(coll.description or "(No description)")
# Status
if coll.pending_approval:
self.details_status.setText("Status: Pending tool approvals")
self.details_status.setStyleSheet("color: orange;")
elif coll.published:
self.details_status.setText(f"Status: Published as '{coll.registry_name}'")
self.details_status.setStyleSheet("color: green;")
else:
self.details_status.setText("Status: Local only")
self.details_status.setStyleSheet("color: gray;")
# Tools list
self.tools_list.clear()
local_tools = set(list_tools())
for tool_ref in coll.tools:
owner, name, is_local = classify_tool_reference(tool_ref)
pinned = coll.pinned.get(tool_ref, "")
version_str = f" @ {pinned}" if pinned else ""
if is_local:
exists = name in local_tools
status = "" if exists else " (not found)"
item = QListWidgetItem(f"{name}{version_str} [local]{status}")
else:
item = QListWidgetItem(f"{tool_ref}{version_str} [registry]")
item.setData(Qt.UserRole, tool_ref)
self.tools_list.addItem(item)
# Enable buttons
self.btn_add_tool.setEnabled(True)
self.btn_remove_tool.setEnabled(True)
self.btn_publish.setEnabled(not coll.published)
self.btn_delete.setEnabled(True)
def _create_collection(self):
"""Create a new collection."""
dialog = CollectionCreateDialog(self)
if dialog.exec() != QDialog.Accepted:
return
data = dialog.get_data()
name = data["name"]
if not name:
QMessageBox.warning(self, "Error", "Collection name is required")
return
# Validate name format
import re
if not re.match(r'^[a-z0-9-]+$', name):
QMessageBox.warning(self, "Error",
"Collection name must be lowercase alphanumeric with hyphens only")
return
# Check if exists
if name in list_collections():
QMessageBox.warning(self, "Error", f"Collection '{name}' already exists")
return
# Create collection
coll = Collection(
name=name,
display_name=data["display_name"],
description=data["description"],
tags=data["tags"],
)
coll.save()
self._load_local_collections()
self.main_window.show_status(f"Created collection: {name}")
# Select the new collection
for i in range(self.collection_list.count()):
item = self.collection_list.item(i)
if item.data(Qt.UserRole) == name:
self.collection_list.setCurrentItem(item)
break
def _add_tool(self):
"""Add a tool to the selected collection."""
current = self.collection_list.currentItem()
if not current:
return
name = current.data(Qt.UserRole)
coll = get_collection(name)
if not coll:
return
dialog = AddToolDialog(self)
if dialog.exec() != QDialog.Accepted:
return
data = dialog.get_data()
tool_ref = data["tool"]
if not tool_ref:
QMessageBox.warning(self, "Error", "Tool name is required")
return
if tool_ref in coll.tools:
QMessageBox.information(self, "Info", f"Tool '{tool_ref}' already in collection")
return
coll.tools.append(tool_ref)
if data["version"]:
coll.pinned[tool_ref] = data["version"]
coll.save()
self._show_collection_details(coll)
self.main_window.show_status(f"Added '{tool_ref}' to collection")
def _remove_tool(self):
"""Remove selected tool from collection."""
current = self.collection_list.currentItem()
if not current:
return
name = current.data(Qt.UserRole)
coll = get_collection(name)
if not coll:
return
tool_item = self.tools_list.currentItem()
if not tool_item:
QMessageBox.warning(self, "Error", "Select a tool to remove")
return
tool_ref = tool_item.data(Qt.UserRole)
reply = QMessageBox.question(
self, "Remove Tool",
f"Remove '{tool_ref}' from collection?",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
coll.tools.remove(tool_ref)
coll.pinned.pop(tool_ref, None)
coll.save()
self._show_collection_details(coll)
self.main_window.show_status(f"Removed '{tool_ref}' from collection")
def _publish_collection(self):
"""Publish the selected collection."""
current = self.collection_list.currentItem()
if not current:
return
name = current.data(Qt.UserRole)
coll = get_collection(name)
if not coll:
return
if not coll.tools:
QMessageBox.warning(self, "Error", "Collection has no tools")
return
# Start analysis first
self.btn_publish.setEnabled(False)
self.btn_publish.setText("Analyzing...")
self.main_window.show_status("Analyzing collection...")
self._analysis_worker = PublishAnalysisWorker(coll)
self._analysis_worker.finished.connect(self._on_analysis_finished)
self._analysis_worker.start()
def _on_analysis_finished(self, result: dict):
"""Handle publish analysis completion."""
self.btn_publish.setText("Publish")
self.btn_publish.setEnabled(True)
if not result["success"]:
QMessageBox.warning(self, "Analysis Failed", result["error"])
return
resolution = result["resolution"]
user_slug = result["user_slug"]
# Get current collection again
current = self.collection_list.currentItem()
if not current:
return
coll = get_collection(current.data(Qt.UserRole))
if not coll:
return
# Check for blocking visibility issues
if resolution.visibility_issues:
tools = ", ".join(t[0] for t in resolution.visibility_issues)
QMessageBox.warning(
self, "Cannot Publish",
f"The following tools are not public:\n\n{tools}\n\n"
"Collections can only include PUBLIC tools.\n"
"Update these tools' visibility to 'public' and republish them first."
)
return
# Check for registry tool issues
if resolution.registry_tool_issues:
issues = "\n".join(f" - {t[0]}: {t[1]}" for t in resolution.registry_tool_issues)
QMessageBox.warning(
self, "Cannot Publish",
f"The following registry tools have issues:\n\n{issues}\n\n"
"Collections can only include approved public tools."
)
return
# Handle unpublished local tools
publish_list = []
skip_list = []
if resolution.local_unpublished:
tools_list = "\n".join(f" - {t}" for t in resolution.local_unpublished)
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Warning)
dialog.setWindowTitle("Unpublished Tools")
dialog.setText(
"Some local tools are not published yet.\n\n"
f"{tools_list}\n\n"
"Choose how to proceed:"
)
btn_publish = dialog.addButton("Publish Tools First", QMessageBox.AcceptRole)
btn_skip = dialog.addButton("Skip Unpublished", QMessageBox.DestructiveRole)
btn_cancel = dialog.addButton("Cancel", QMessageBox.RejectRole)
dialog.setDefaultButton(btn_publish)
dialog.exec()
clicked = dialog.clickedButton()
if clicked == btn_publish:
publish_list = resolution.local_unpublished
elif clicked == btn_skip:
skip_list = resolution.local_unpublished
else:
return
# Confirm publish
remaining = len(resolution.registry_refs) - len(skip_list)
if remaining == 0:
QMessageBox.warning(self, "Error", "No tools remaining after filtering")
return
reply = QMessageBox.question(
self, "Confirm Publish",
f"Publish '{coll.display_name}' with {remaining} tools?",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
# Start actual publish
self.btn_publish.setEnabled(False)
self.btn_publish.setText("Publishing...")
self._publish_worker = PublishWorker(
coll, user_slug,
resolution.registry_refs,
resolution.transformed_pinned,
skip_unpublished=skip_list,
publish_unpublished=publish_list
)
self._publish_worker.progress.connect(self._on_publish_progress)
self._publish_worker.finished.connect(self._on_publish_finished)
self._publish_worker.start()
def _on_publish_progress(self, message: str):
"""Handle publish progress updates."""
self.main_window.show_status(message)
def _on_publish_finished(self, success: bool, message: str):
"""Handle publish completion."""
self.btn_publish.setText("Publish")
if success:
QMessageBox.information(self, "Published", message)
self._load_local_collections()
else:
QMessageBox.warning(self, "Publish Failed", message)
self.btn_publish.setEnabled(True)
self.main_window.show_status(message)
def _delete_collection(self):
"""Delete the selected collection."""
current = self.collection_list.currentItem()
if not current:
return
name = current.data(Qt.UserRole)
coll = get_collection(name)
if not coll:
return
reply = QMessageBox.question(
self, "Delete Collection",
f"Delete collection '{coll.display_name}'?\n\n"
"This only removes the local collection file.",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
coll.delete()
self._load_local_collections()
self._clear_details()
self.main_window.show_status(f"Deleted collection: {name}")
def _install_registry_collection(self, item: QListWidgetItem):
"""Install a registry collection."""
name = item.data(Qt.UserRole)
if not name:
return
reply = QMessageBox.question(
self, "Install Collection",
f"Install all tools from collection '{name}'?",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
# Disable double-click during install
self.registry_list.setEnabled(False)
self.main_window.show_status(f"Installing collection '{name}'...")
self._install_worker = InstallWorker(name)
self._install_worker.progress.connect(self._on_install_progress)
self._install_worker.finished.connect(self._on_install_finished)
self._install_worker.start()
def _on_install_progress(self, message: str):
"""Handle install progress updates."""
self.main_window.show_status(message)
def _on_install_finished(self, success: bool, message: str, installed: int, failed: int):
"""Handle install completion."""
self.registry_list.setEnabled(True)
if success:
if installed == 0 and failed == 0:
QMessageBox.information(self, "Info", message)
else:
QMessageBox.information(self, "Success", message)
else:
if installed > 0:
QMessageBox.warning(self, "Partial Success", message)
else:
QMessageBox.warning(self, "Error", message)
self.main_window.show_status(message)