Add Provider Install wizard to GUI

- Create ProviderInstallDialog for installing AI provider CLI tools
- Shows available providers (Claude, Codex, Gemini, OpenCode, Ollama)
- Detects which providers are already installed
- Runs installation commands with live output
- Offers to add provider variants after installation
- Add "Tools" menu with "Install AI Provider..." option
- Add "Install Provider..." button to Providers page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-18 01:14:49 -04:00
parent f0d175f3c6
commit f442759344
3 changed files with 520 additions and 1 deletions

View File

@ -0,0 +1,480 @@
"""Provider installation wizard dialog."""
import shutil
import subprocess
from pathlib import Path
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QListWidget, QListWidgetItem, QTextEdit, QProgressBar,
QMessageBox, QGroupBox, QWidget, QStackedWidget
)
from PySide6.QtCore import Qt, QThread, Signal, QProcess
from PySide6.QtGui import QFont
from ...providers import load_providers, add_provider, Provider
# Provider installation info (shared with CLI)
PROVIDER_INSTALL_INFO = {
"claude": {
"group": "Anthropic Claude",
"install_cmd": "npm install -g @anthropic-ai/claude-code",
"requires": "Node.js 18+ and npm",
"setup": "Run 'claude' - opens browser for sign-in",
"cost": "Pay-per-use (billed to your Anthropic account)",
"variants": [
("claude", "claude -p", "Claude (default model)"),
("claude-haiku", "claude -p --model claude-3-haiku-20240307", "Claude Haiku (fast, cheap)"),
("claude-sonnet", "claude -p --model claude-3-5-sonnet-20241022", "Claude Sonnet (balanced)"),
("claude-opus", "claude -p --model claude-3-opus-20240229", "Claude Opus (most capable)"),
],
},
"codex": {
"group": "OpenAI Codex",
"install_cmd": "npm install -g @openai/codex",
"requires": "Node.js 18+ and npm",
"setup": "Run 'codex' - opens browser for sign-in",
"cost": "Pay-per-use (billed to your OpenAI account)",
"variants": [
("codex", "codex", "OpenAI Codex"),
],
},
"gemini": {
"group": "Google Gemini",
"install_cmd": "npm install -g @google/gemini-cli",
"requires": "Node.js 18+ and npm",
"setup": "Run 'gemini' - opens browser for Google sign-in",
"cost": "Free tier available, pay-per-use for more",
"variants": [
("gemini", "gemini", "Gemini Pro (default)"),
("gemini-flash", "gemini --model gemini-1.5-flash", "Gemini Flash (faster)"),
],
},
"opencode": {
"group": "OpenCode (75+ providers)",
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
"requires": "curl, bash",
"setup": "Run 'opencode' - opens browser to connect providers",
"cost": "4 FREE models included, 75+ more available",
"variants": [
("opencode-pickle", "$HOME/.opencode/bin/opencode -p big-pickle", "Big Pickle (FREE, high quality)"),
("opencode-deepseek", "$HOME/.opencode/bin/opencode -p deepseek", "DeepSeek (fast, cheap)"),
("opencode-reasoner", "$HOME/.opencode/bin/opencode -p deepseek-reasoner", "DeepSeek Reasoner (complex tasks)"),
("opencode-grok", "$HOME/.opencode/bin/opencode -p grok", "Grok (xAI)"),
],
},
"ollama": {
"group": "Ollama (Local LLMs)",
"install_cmd": "curl -fsSL https://ollama.ai/install.sh | bash",
"requires": "curl, bash, 8GB+ RAM (GPU recommended)",
"setup": "Run 'ollama pull llama3' to download a model",
"cost": "FREE (runs entirely on your machine)",
"variants": [
("ollama-llama3", "ollama run llama3", "Llama 3 (general purpose)"),
("ollama-codellama", "ollama run codellama", "CodeLlama (coding)"),
("ollama-mistral", "ollama run mistral", "Mistral (fast)"),
],
"custom": True,
},
}
class InstallWorker(QThread):
"""Background worker for running installation commands."""
output = Signal(str)
finished = Signal(bool, str)
def __init__(self, command: str):
super().__init__()
self.command = command
def run(self):
try:
process = subprocess.Popen(
self.command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
for line in process.stdout:
self.output.emit(line)
process.wait()
if process.returncode == 0:
self.finished.emit(True, "Installation completed successfully!")
else:
self.finished.emit(False, f"Installation failed (exit code {process.returncode})")
except Exception as e:
self.finished.emit(False, f"Error: {e}")
class ProviderInstallDialog(QDialog):
"""Dialog for installing AI provider CLI tools."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Install AI Provider")
self.setMinimumSize(700, 500)
self.resize(750, 550)
self._worker = None
self._selected_provider = None
self._setup_ui()
self._load_providers()
def _setup_ui(self):
"""Set up the dialog UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Stacked widget for pages
self.stack = QStackedWidget()
layout.addWidget(self.stack, 1)
# Page 1: Provider selection
self._setup_selection_page()
# Page 2: Installation
self._setup_install_page()
# Page 3: Add variants
self._setup_variants_page()
def _setup_selection_page(self):
"""Set up the provider selection page."""
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(0, 0, 0, 0)
# Title
title = QLabel("Select an AI Provider to Install")
title.setObjectName("heading")
font = QFont()
font.setPointSize(14)
font.setBold(True)
title.setFont(font)
layout.addWidget(title)
desc = QLabel(
"CmdForge works with various AI providers. Select one below to install it. "
"Providers marked as [INSTALLED] are already available on your system."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #666; margin-bottom: 12px;")
layout.addWidget(desc)
# Provider list
self.provider_list = QListWidget()
self.provider_list.setSpacing(4)
self.provider_list.itemClicked.connect(self._on_provider_selected)
self.provider_list.itemDoubleClicked.connect(self._on_provider_double_clicked)
layout.addWidget(self.provider_list, 1)
# Details panel
self.details_group = QGroupBox("Provider Details")
details_layout = QVBoxLayout(self.details_group)
self.details_label = QLabel("Select a provider to see details")
self.details_label.setWordWrap(True)
details_layout.addWidget(self.details_label)
layout.addWidget(self.details_group)
# Buttons
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.clicked.connect(self.reject)
btn_layout.addWidget(self.btn_cancel)
self.btn_install = QPushButton("Install Selected")
self.btn_install.setEnabled(False)
self.btn_install.clicked.connect(self._start_install)
btn_layout.addWidget(self.btn_install)
layout.addLayout(btn_layout)
self.stack.addWidget(page)
def _setup_install_page(self):
"""Set up the installation progress page."""
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(0, 0, 0, 0)
# Title
self.install_title = QLabel("Installing...")
font = QFont()
font.setPointSize(14)
font.setBold(True)
self.install_title.setFont(font)
layout.addWidget(self.install_title)
# Command being run
self.install_cmd_label = QLabel()
self.install_cmd_label.setStyleSheet("color: #666; font-family: monospace;")
layout.addWidget(self.install_cmd_label)
# Progress
self.progress = QProgressBar()
self.progress.setRange(0, 0) # Indeterminate
layout.addWidget(self.progress)
# Output log
self.output_text = QTextEdit()
self.output_text.setReadOnly(True)
self.output_text.setStyleSheet("font-family: monospace; font-size: 11px;")
layout.addWidget(self.output_text, 1)
# Buttons
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.btn_back = QPushButton("Back")
self.btn_back.setEnabled(False)
self.btn_back.clicked.connect(lambda: self.stack.setCurrentIndex(0))
btn_layout.addWidget(self.btn_back)
self.btn_next = QPushButton("Next: Add Providers")
self.btn_next.setEnabled(False)
self.btn_next.clicked.connect(lambda: self.stack.setCurrentIndex(2))
btn_layout.addWidget(self.btn_next)
layout.addLayout(btn_layout)
self.stack.addWidget(page)
def _setup_variants_page(self):
"""Set up the variants/add providers page."""
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(0, 0, 0, 0)
# Title
title = QLabel("Add Provider Variants")
font = QFont()
font.setPointSize(14)
font.setBold(True)
title.setFont(font)
layout.addWidget(title)
desc = QLabel(
"Select which provider variants to add to CmdForge. "
"Each variant uses the same CLI tool but with different models or settings."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #666; margin-bottom: 12px;")
layout.addWidget(desc)
# Variants list
self.variants_list = QListWidget()
self.variants_list.setSelectionMode(QListWidget.MultiSelection)
layout.addWidget(self.variants_list, 1)
# Next steps
self.next_steps_group = QGroupBox("Next Steps")
next_steps_layout = QVBoxLayout(self.next_steps_group)
self.next_steps_label = QLabel()
self.next_steps_label.setWordWrap(True)
next_steps_layout.addWidget(self.next_steps_label)
layout.addWidget(self.next_steps_group)
# Buttons
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.btn_skip = QPushButton("Skip")
self.btn_skip.clicked.connect(self.accept)
btn_layout.addWidget(self.btn_skip)
self.btn_add = QPushButton("Add Selected Providers")
self.btn_add.clicked.connect(self._add_selected_variants)
btn_layout.addWidget(self.btn_add)
layout.addLayout(btn_layout)
self.stack.addWidget(page)
def _load_providers(self):
"""Load and display available providers."""
# Get currently configured providers
configured = {p.name for p in load_providers()}
# Check what's installed on the system
for key, info in PROVIDER_INSTALL_INFO.items():
item = QListWidgetItem()
# Check if CLI tool is installed
is_installed = self._check_installed(key)
status = " [INSTALLED]" if is_installed else ""
item.setText(f"{info['group']}{status}")
item.setData(Qt.UserRole, key)
item.setData(Qt.UserRole + 1, is_installed)
# Style installed items differently
if is_installed:
item.setForeground(Qt.darkGreen)
self.provider_list.addItem(item)
def _check_installed(self, provider_key: str) -> bool:
"""Check if a provider's CLI tool is installed."""
info = PROVIDER_INSTALL_INFO[provider_key]
# Check common locations based on provider
if provider_key == "opencode":
opencode_path = Path.home() / ".opencode" / "bin" / "opencode"
return opencode_path.exists() or shutil.which("opencode") is not None
elif provider_key == "ollama":
return shutil.which("ollama") is not None
elif provider_key in ("claude", "codex", "gemini"):
return shutil.which(provider_key) is not None
return False
def _on_provider_selected(self, item: QListWidgetItem):
"""Handle provider selection."""
key = item.data(Qt.UserRole)
is_installed = item.data(Qt.UserRole + 1)
info = PROVIDER_INSTALL_INFO[key]
self._selected_provider = key
self.btn_install.setEnabled(True)
# Update details
details = f"""<b>{info['group']}</b><br><br>
<b>Cost:</b> {info['cost']}<br>
<b>Requirements:</b> {info['requires']}<br>
<b>Install command:</b> <code>{info['install_cmd']}</code><br>
<b>After install:</b> {info['setup']}<br><br>
<b>Available models:</b> {', '.join(v[0] for v in info['variants'])}"""
if is_installed:
details += "<br><br><span style='color: green;'>This provider is already installed. Click Install to reinstall or update.</span>"
self.details_label.setText(details)
# Change button text based on installed status
self.btn_install.setText("Reinstall" if is_installed else "Install Selected")
def _on_provider_double_clicked(self, item: QListWidgetItem):
"""Handle double-click to start install."""
self._on_provider_selected(item)
self._start_install()
def _start_install(self):
"""Start the installation process."""
if not self._selected_provider:
return
info = PROVIDER_INSTALL_INFO[self._selected_provider]
# Switch to install page
self.stack.setCurrentIndex(1)
self.install_title.setText(f"Installing {info['group']}...")
self.install_cmd_label.setText(f"Command: {info['install_cmd']}")
self.output_text.clear()
self.progress.setRange(0, 0)
self.btn_back.setEnabled(False)
self.btn_next.setEnabled(False)
# Start installation
self._worker = InstallWorker(info['install_cmd'])
self._worker.output.connect(self._on_install_output)
self._worker.finished.connect(self._on_install_finished)
self._worker.start()
def _on_install_output(self, text: str):
"""Handle installation output."""
self.output_text.append(text.rstrip())
# Auto-scroll to bottom
scrollbar = self.output_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def _on_install_finished(self, success: bool, message: str):
"""Handle installation completion."""
self.progress.setRange(0, 1)
self.progress.setValue(1 if success else 0)
self.btn_back.setEnabled(True)
if success:
self.install_title.setText("Installation Complete!")
self.output_text.append(f"\n{'='*50}\n{message}")
self.btn_next.setEnabled(True)
# Populate variants page
self._populate_variants()
else:
self.install_title.setText("Installation Failed")
self.output_text.append(f"\n{'='*50}\nERROR: {message}")
QMessageBox.warning(
self, "Installation Failed",
f"The installation did not complete successfully.\n\n{message}\n\n"
"You may need to install manually. Check the output for details."
)
def _populate_variants(self):
"""Populate the variants list for the selected provider."""
if not self._selected_provider:
return
info = PROVIDER_INSTALL_INFO[self._selected_provider]
self.variants_list.clear()
# Get currently configured provider names
configured = {p.name for p in load_providers()}
for name, command, description in info['variants']:
item = QListWidgetItem(f"{name} - {description}")
item.setData(Qt.UserRole, (name, command, description))
# Pre-select if not already configured
if name not in configured:
item.setSelected(True)
else:
item.setForeground(Qt.gray)
item.setText(f"{name} - {description} [already configured]")
self.variants_list.addItem(item)
# Update next steps
self.next_steps_label.setText(
f"<b>Important:</b> Before using these providers, you need to authenticate:<br><br>"
f"1. Open a terminal<br>"
f"2. Run: <code>source ~/.bashrc</code> (to update PATH)<br>"
f"3. Run: <code>{info['setup'].split(' - ')[0].replace('Run ', '')}</code><br><br>"
f"This will open your browser to sign in."
)
def _add_selected_variants(self):
"""Add selected variants as providers."""
added = []
for i in range(self.variants_list.count()):
item = self.variants_list.item(i)
if item.isSelected():
data = item.data(Qt.UserRole)
if data:
name, command, description = data
provider = Provider(name, command, description)
add_provider(provider)
added.append(name)
if added:
QMessageBox.information(
self, "Providers Added",
f"Added {len(added)} provider(s):\n\n" + "\n".join(f" - {name}" for name in added) +
"\n\nRemember to authenticate before using them!"
)
self.accept()

View File

@ -244,9 +244,25 @@ class MainWindow(QMainWindow):
self._settings.setValue("theme", theme_name) self._settings.setValue("theme", theme_name)
def _setup_menu_bar(self): def _setup_menu_bar(self):
"""Set up the menu bar with View and Help menus.""" """Set up the menu bar with Tools, View and Help menus."""
menubar = self.menuBar() menubar = self.menuBar()
# Tools menu
tools_menu = menubar.addMenu("&Tools")
# Install Provider
action_install_provider = QAction("&Install AI Provider...", self)
action_install_provider.triggered.connect(self._show_provider_install)
tools_menu.addAction(action_install_provider)
tools_menu.addSeparator()
# New Tool
action_new_tool = QAction("&New Tool", self)
action_new_tool.setShortcut("Ctrl+N")
action_new_tool.triggered.connect(self._shortcut_new_tool)
tools_menu.addAction(action_new_tool)
# View menu # View menu
view_menu = menubar.addMenu("&View") view_menu = menubar.addMenu("&View")
@ -358,6 +374,15 @@ class MainWindow(QMainWindow):
dialog = AboutDialog(self) dialog = AboutDialog(self)
dialog.exec() dialog.exec()
def _show_provider_install(self):
"""Show Provider Install dialog."""
from .dialogs.provider_install_dialog import ProviderInstallDialog
dialog = ProviderInstallDialog(self)
if dialog.exec():
# Refresh providers page if we added any
self.providers_page.refresh()
self.show_status("Providers updated")
def _shortcut_new_tool(self): def _shortcut_new_tool(self):
"""Handle Ctrl+N: Create new tool.""" """Handle Ctrl+N: Create new tool."""
self.open_tool_builder() self.open_tool_builder()

View File

@ -35,7 +35,13 @@ class ProvidersPage(QWidget):
header_layout.addStretch() header_layout.addStretch()
self.btn_install = QPushButton("Install Provider...")
self.btn_install.setToolTip("Install a new AI provider CLI tool")
self.btn_install.clicked.connect(self._install_provider)
header_layout.addWidget(self.btn_install)
self.btn_add = QPushButton("Add Provider") self.btn_add = QPushButton("Add Provider")
self.btn_add.setToolTip("Manually add a provider configuration")
self.btn_add.clicked.connect(self._add_provider) self.btn_add.clicked.connect(self._add_provider)
header_layout.addWidget(self.btn_add) header_layout.addWidget(self.btn_add)
@ -121,6 +127,14 @@ class ProvidersPage(QWidget):
name_item = self.table.item(row, 0) name_item = self.table.item(row, 0)
return name_item.text(), name_item.data(Qt.UserRole) return name_item.text(), name_item.data(Qt.UserRole)
def _install_provider(self):
"""Open the provider installation wizard."""
from ..dialogs.provider_install_dialog import ProviderInstallDialog
dialog = ProviderInstallDialog(self)
if dialog.exec():
self.refresh()
self.main_window.show_status("Providers updated")
def _add_provider(self): def _add_provider(self):
"""Add a new provider.""" """Add a new provider."""
from ..dialogs.provider_dialog import ProviderDialog from ..dialogs.provider_dialog import ProviderDialog