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:
parent
f0d175f3c6
commit
f442759344
|
|
@ -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()
|
||||
|
|
@ -244,9 +244,25 @@ class MainWindow(QMainWindow):
|
|||
self._settings.setValue("theme", theme_name)
|
||||
|
||||
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()
|
||||
|
||||
# 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 = menubar.addMenu("&View")
|
||||
|
||||
|
|
@ -358,6 +374,15 @@ class MainWindow(QMainWindow):
|
|||
dialog = AboutDialog(self)
|
||||
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):
|
||||
"""Handle Ctrl+N: Create new tool."""
|
||||
self.open_tool_builder()
|
||||
|
|
|
|||
|
|
@ -35,7 +35,13 @@ class ProvidersPage(QWidget):
|
|||
|
||||
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.setToolTip("Manually add a provider configuration")
|
||||
self.btn_add.clicked.connect(self._add_provider)
|
||||
header_layout.addWidget(self.btn_add)
|
||||
|
||||
|
|
@ -121,6 +127,14 @@ class ProvidersPage(QWidget):
|
|||
name_item = self.table.item(row, 0)
|
||||
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):
|
||||
"""Add a new provider."""
|
||||
from ..dialogs.provider_dialog import ProviderDialog
|
||||
|
|
|
|||
Loading…
Reference in New Issue