From f442759344d7f823f35bd41ac2c90ad8c26864bf Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 18 Jan 2026 01:14:49 -0400 Subject: [PATCH] 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 --- .../gui/dialogs/provider_install_dialog.py | 480 ++++++++++++++++++ src/cmdforge/gui/main_window.py | 27 +- src/cmdforge/gui/pages/providers_page.py | 14 + 3 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 src/cmdforge/gui/dialogs/provider_install_dialog.py diff --git a/src/cmdforge/gui/dialogs/provider_install_dialog.py b/src/cmdforge/gui/dialogs/provider_install_dialog.py new file mode 100644 index 0000000..20971c7 --- /dev/null +++ b/src/cmdforge/gui/dialogs/provider_install_dialog.py @@ -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"""{info['group']}

+Cost: {info['cost']}
+Requirements: {info['requires']}
+Install command: {info['install_cmd']}
+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 + 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"Important: Before using these providers, you need to authenticate:

" + f"1. Open a terminal
" + f"2. Run: source ~/.bashrc (to update PATH)
" + f"3. Run: {info['setup'].split(' - ')[0].replace('Run ', '')}

" + 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() diff --git a/src/cmdforge/gui/main_window.py b/src/cmdforge/gui/main_window.py index e5e7724..c8475bf 100644 --- a/src/cmdforge/gui/main_window.py +++ b/src/cmdforge/gui/main_window.py @@ -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() diff --git a/src/cmdforge/gui/pages/providers_page.py b/src/cmdforge/gui/pages/providers_page.py index 7f4a0c9..8512bb6 100644 --- a/src/cmdforge/gui/pages/providers_page.py +++ b/src/cmdforge/gui/pages/providers_page.py @@ -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