500 lines
18 KiB
Python
500 lines
18 KiB
Python
"""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": "curl -fsSL https://claude.ai/install.sh | bash",
|
|
"requires": "curl and bash",
|
|
"setup": "Run 'claude' to sign in with a subscription or API key.",
|
|
"cost": "Paid subscription or API key",
|
|
"variants": [
|
|
("claude", "claude -p", "Claude (default model)"),
|
|
("claude-haiku", "claude -p --model haiku", "Claude Haiku (fast)"),
|
|
("claude-sonnet", "claude -p --model sonnet", "Claude Sonnet (balanced)"),
|
|
("claude-opus", "claude -p --model opus", "Claude Opus (highest quality)"),
|
|
],
|
|
},
|
|
"codex": {
|
|
"group": "OpenAI Codex",
|
|
"install_cmd": "npm install -g @openai/codex",
|
|
"requires": "Node.js 18+ and npm",
|
|
"setup": "Run 'codex' to sign in.",
|
|
"cost": "Free tier or ChatGPT subscription, subject to account limits",
|
|
"variants": [
|
|
("codex", "codex exec -", "OpenAI Codex"),
|
|
],
|
|
},
|
|
"agy": {
|
|
"group": "Google Antigravity",
|
|
"install_cmd": None,
|
|
"requires": "Google Antigravity desktop application",
|
|
"setup": "Install from https://antigravity.google/download, then run 'agy install' to configure your PATH.",
|
|
"cost": "Free tier available, subject to account limits",
|
|
"variants": [
|
|
("agy", "agy -p", "Google Antigravity (automatic model selection)"),
|
|
],
|
|
},
|
|
"opencode": {
|
|
"group": "OpenCode",
|
|
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
|
|
"requires": "curl, bash",
|
|
"setup": "Run 'opencode' and use /connect to configure providers.",
|
|
"cost": "Free models available; paid providers require credentials",
|
|
"variants": [
|
|
("opencode-pickle", "opencode run --model opencode/big-pickle", "Big Pickle (free)"),
|
|
("opencode-free", "opencode run --model opencode/deepseek-v4-flash-free", "DeepSeek V4 Flash (free)"),
|
|
("opencode-deepseek", "opencode run --model deepseek/deepseek-chat", "DeepSeek Chat (API key required)"),
|
|
("opencode-reasoner", "opencode run --model deepseek/deepseek-reasoner", "DeepSeek Reasoner (API key required)"),
|
|
],
|
|
},
|
|
"crush": {
|
|
"group": "Crush (Charm)",
|
|
"install_cmd": "npm install -g @charmland/crush",
|
|
"requires": "Node.js 18+ and npm",
|
|
"setup": "Run 'crush' to configure Hyper or your provider API keys.",
|
|
"cost": "Limited Hyper credits or your own provider billing",
|
|
"variants": [
|
|
("crush", "crush run --quiet", "Crush multi-model agent"),
|
|
],
|
|
},
|
|
"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.2' to download a model.",
|
|
"cost": "FREE (runs entirely on your machine)",
|
|
"variants": [
|
|
("ollama-llama3", "ollama run llama3.2", "Llama 3.2 (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", "agy", "crush"):
|
|
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
|
|
install_method = info['install_cmd'] or "Manual download"
|
|
details = f"""<b>{info['group']}</b><br><br>
|
|
<b>Cost:</b> {info['cost']}<br>
|
|
<b>Requirements:</b> {info['requires']}<br>
|
|
<b>Install method:</b> <code>{install_method}</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
|
|
if info["install_cmd"]:
|
|
self.btn_install.setText("Reinstall" if is_installed else "Install Selected")
|
|
else:
|
|
self.btn_install.setText("Add Providers" if is_installed else "Setup Instructions")
|
|
|
|
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]
|
|
|
|
if not info["install_cmd"]:
|
|
if self._check_installed(self._selected_provider):
|
|
self._populate_variants()
|
|
self.stack.setCurrentIndex(2)
|
|
else:
|
|
QMessageBox.information(self, "Manual Setup", info["setup"])
|
|
return
|
|
|
|
# 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 new terminal so PATH changes are loaded.<br>"
|
|
f"2. {info['setup']}"
|
|
)
|
|
|
|
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()
|