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