"""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"""{info['group']}

Cost: {info['cost']}
Requirements: {info['requires']}
Install method: {install_method}
After install: {info['setup']}

Available models: {', '.join(v[0] for v in info['variants'])}""" if is_installed: details += "

This provider is already installed. Click Install to reinstall or update." 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"Important: Before using these providers, you need to authenticate:

" f"1. Open a new terminal so PATH changes are loaded.
" 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()