feat(gui): reconcile provider install dialog with agent-harness-setup helper

This commit is contained in:
rob
2026-09-11 22:55:42 -03:00
parent 60cd44bcfb
commit 4bd0a7cc02
2 changed files with 637 additions and 76 deletions
@@ -1,26 +1,37 @@
"""Provider installation wizard dialog.""" """Provider installation wizard dialog.
Reconciled in Packet S6 to delegate harness installation to agent-harness-setup
via a dedicated terminal window while preserving the GUI configuration and
provider variants management workflows.
"""
import json
import os
import shutil import shutil
import subprocess import subprocess
import tempfile
import uuid
from pathlib import Path from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QListWidget, QListWidgetItem, QTextEdit, QProgressBar, QListWidget, QListWidgetItem, QTextEdit, QProgressBar,
QMessageBox, QGroupBox, QWidget, QStackedWidget QMessageBox, QGroupBox, QWidget, QStackedWidget
) )
from PySide6.QtCore import Qt, QThread, Signal, QProcess from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
from ...providers import load_providers, add_provider, Provider from ...providers import load_providers, add_provider, Provider
from ...cli.provider_commands import _find_setup_helper
# Provider installation info (shared with CLI) # Supported AI provider metadata and variants (installation catalogue retired and
PROVIDER_INSTALL_INFO = { # delegated to agent-harness-setup)
SUPPORTED_PROVIDERS = {
"claude": { "claude": {
"harness_id": "claude-code",
"group": "Anthropic 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.", "setup": "Run 'claude' to sign in with a subscription or API key.",
"cost": "Paid subscription or API key", "cost": "Paid subscription or API key",
"variants": [ "variants": [
@@ -31,29 +42,27 @@ PROVIDER_INSTALL_INFO = {
], ],
}, },
"codex": { "codex": {
"harness_id": "codex",
"group": "OpenAI Codex", "group": "OpenAI Codex",
"install_cmd": "npm install -g @openai/codex", "setup": "Run 'codex' to sign in. ChatGPT Plus/Pro includes Codex access.",
"requires": "Node.js 18+ and npm",
"setup": "Run 'codex' to sign in.",
"cost": "Free tier or ChatGPT subscription, subject to account limits", "cost": "Free tier or ChatGPT subscription, subject to account limits",
"variants": [ "variants": [
("codex", "codex exec -", "OpenAI Codex"), ("codex", "codex exec -", "OpenAI Codex"),
], ],
}, },
"agy": { "agy": {
"harness_id": "agy",
"group": "Google Antigravity", "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.", "setup": "Install from https://antigravity.google/download, then run 'agy install' to configure your PATH.",
"cost": "Free tier available, subject to account limits", "cost": "Free tier available, subject to account limits",
"manual_only": True,
"variants": [ "variants": [
("agy", "agy -p", "Google Antigravity (automatic model selection)"), ("agy", "agy -p", "Google Antigravity (automatic model selection)"),
], ],
}, },
"opencode": { "opencode": {
"harness_id": "opencode",
"group": "OpenCode", "group": "OpenCode",
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
"requires": "curl, bash",
"setup": "Run 'opencode' and use /connect to configure providers.", "setup": "Run 'opencode' and use /connect to configure providers.",
"cost": "Free models available; paid providers require credentials", "cost": "Free models available; paid providers require credentials",
"variants": [ "variants": [
@@ -64,9 +73,8 @@ PROVIDER_INSTALL_INFO = {
], ],
}, },
"crush": { "crush": {
"harness_id": "crush",
"group": "Crush (Charm)", "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.", "setup": "Run 'crush' to configure Hyper or your provider API keys.",
"cost": "Limited Hyper credits or your own provider billing", "cost": "Limited Hyper credits or your own provider billing",
"variants": [ "variants": [
@@ -74,9 +82,8 @@ PROVIDER_INSTALL_INFO = {
], ],
}, },
"ollama": { "ollama": {
"harness_id": "ollama",
"group": "Ollama (Local LLMs)", "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.", "setup": "Run 'ollama pull llama3.2' to download a model.",
"cost": "FREE (runs entirely on your machine)", "cost": "FREE (runs entirely on your machine)",
"variants": [ "variants": [
@@ -88,43 +95,123 @@ PROVIDER_INSTALL_INFO = {
}, },
} }
# Retained alias for backwards compatibility
PROVIDER_INSTALL_INFO = SUPPORTED_PROVIDERS
class InstallWorker(QThread):
"""Background worker for running installation commands.""" def find_terminal_emulator() -> list[str] | None:
"""Find an available graphical terminal emulator command prefix."""
candidates = [
("x-terminal-emulator", ["-e"]),
("gnome-terminal", ["--"]),
("konsole", ["-e"]),
("xfce4-terminal", ["-e"]),
("kitty", []),
("alacritty", ["-e"]),
("xterm", ["-e"]),
]
for term, prefix in candidates:
bin_path = shutil.which(term)
if bin_path:
return [bin_path] + prefix
return None
class SetupTerminalWorker(QThread):
"""Background worker running agent-harness-setup in a dedicated terminal window."""
output = Signal(str) output = Signal(str)
finished = Signal(bool, str) finished = Signal(bool, str, dict)
def __init__(self, command: str): def __init__(
self,
helper_argv: list[str],
harness_id: str,
result_file: Path,
request_id: str,
terminal_launcher: list[str] | None = None,
):
super().__init__() super().__init__()
self.command = command self.helper_argv = helper_argv
self.harness_id = harness_id
self.result_file = result_file
self.request_id = request_id
self.terminal_launcher = terminal_launcher
self._process: Optional[subprocess.Popen] = None
def run(self): def run(self):
helper_cmd = list(self.helper_argv) + [
"--result-file", str(self.result_file),
"--request-id", self.request_id,
"--harness", self.harness_id,
]
if self.terminal_launcher:
full_cmd = list(self.terminal_launcher) + helper_cmd
else:
full_cmd = helper_cmd
try: try:
process = subprocess.Popen( self._process = subprocess.Popen(
self.command, full_cmd,
shell=True, stdout=subprocess.PIPE if not self.terminal_launcher else None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT if not self.terminal_launcher else None,
stderr=subprocess.STDOUT,
text=True, text=True,
bufsize=1
) )
for line in process.stdout: if not self.terminal_launcher and self._process.stdout:
self.output.emit(line) for line in self._process.stdout:
self.output.emit(line)
process.wait() exit_code = self._process.wait()
if process.returncode == 0: result_data = None
self.finished.emit(True, "Installation completed successfully!") if self.result_file.is_file():
try:
result_data = json.loads(self.result_file.read_text(encoding="utf-8"))
except Exception:
result_data = None
# Validate request_id correlation
if result_data and result_data.get("request_id") != self.request_id:
self.finished.emit(False, "Security error: Request ID mismatch in setup result.", {})
return
status = result_data.get("status") if result_data else None
# Coherence validation (Contract §C.5)
if exit_code != 0 and status == "success":
self.finished.emit(False, f"Helper exited with error code {exit_code} despite reporting success.", result_data or {})
return
if exit_code == 0 and status in ("failed", "cancelled"):
self.finished.emit(False, "Setup was cancelled or reported failure.", result_data or {})
return
if exit_code == 0 and status == "success":
self.finished.emit(True, f"Setup for '{self.harness_id}' verified successfully!", result_data or {})
elif exit_code in (10, 130, 143) or status == "cancelled":
self.finished.emit(False, "Setup was cancelled by user.", result_data or {})
else: else:
self.finished.emit(False, f"Installation failed (exit code {process.returncode})") diag = result_data.get("error_diagnostics") if result_data else None
msg = f"Setup failed: {diag}" if diag else f"Setup process exited with code {exit_code}."
self.finished.emit(False, msg, result_data or {})
except Exception as e: except Exception as e:
self.finished.emit(False, f"Error: {e}") self.finished.emit(False, f"Error launching setup helper: {e}", {})
def terminate_process(self):
"""Terminate the active setup process if running."""
if self._process and self._process.poll() is None:
try:
self._process.terminate()
except Exception:
pass
# Alias for backwards compatibility
InstallWorker = SetupTerminalWorker
class ProviderInstallDialog(QDialog): class ProviderInstallDialog(QDialog):
"""Dialog for installing AI provider CLI tools.""" """Dialog for installing AI provider CLI tools via agent-harness-setup."""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -132,8 +219,9 @@ class ProviderInstallDialog(QDialog):
self.setMinimumSize(700, 500) self.setMinimumSize(700, 500)
self.resize(750, 550) self.resize(750, 550)
self._worker = None self._worker: Optional[SetupTerminalWorker] = None
self._selected_provider = None self._selected_provider: Optional[str] = None
self._temp_dir: Optional[str] = None
self._setup_ui() self._setup_ui()
self._load_providers() self._load_providers()
@@ -172,7 +260,8 @@ class ProviderInstallDialog(QDialog):
layout.addWidget(title) layout.addWidget(title)
desc = QLabel( desc = QLabel(
"CmdForge works with various AI providers. Select one below to install it. " "CmdForge works with various AI providers. Select one below to install and configure it. "
"Installation is performed interactively via the Agent Harness Setup helper. "
"Providers marked as [INSTALLED] are already available on your system." "Providers marked as [INSTALLED] are already available on your system."
) )
desc.setWordWrap(True) desc.setWordWrap(True)
@@ -314,11 +403,8 @@ class ProviderInstallDialog(QDialog):
def _load_providers(self): def _load_providers(self):
"""Load and display available providers.""" """Load and display available providers."""
# Get currently configured providers self.provider_list.clear()
configured = {p.name for p in load_providers()} for key, info in SUPPORTED_PROVIDERS.items():
# Check what's installed on the system
for key, info in PROVIDER_INSTALL_INFO.items():
item = QListWidgetItem() item = QListWidgetItem()
# Check if CLI tool is installed # Check if CLI tool is installed
@@ -337,9 +423,6 @@ class ProviderInstallDialog(QDialog):
def _check_installed(self, provider_key: str) -> bool: def _check_installed(self, provider_key: str) -> bool:
"""Check if a provider's CLI tool is installed.""" """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": if provider_key == "opencode":
opencode_path = Path.home() / ".opencode" / "bin" / "opencode" opencode_path = Path.home() / ".opencode" / "bin" / "opencode"
return opencode_path.exists() or shutil.which("opencode") is not None return opencode_path.exists() or shutil.which("opencode") is not None
@@ -354,30 +437,29 @@ class ProviderInstallDialog(QDialog):
"""Handle provider selection.""" """Handle provider selection."""
key = item.data(Qt.UserRole) key = item.data(Qt.UserRole)
is_installed = item.data(Qt.UserRole + 1) is_installed = item.data(Qt.UserRole + 1)
info = PROVIDER_INSTALL_INFO[key] info = SUPPORTED_PROVIDERS[key]
self._selected_provider = key self._selected_provider = key
self.btn_install.setEnabled(True) self.btn_install.setEnabled(True)
# Update details is_manual = info.get("manual_only", False)
install_method = info['install_cmd'] or "Manual download" install_method = "Manual desktop setup" if is_manual else "Agent Harness Setup Helper (terminal)"
details = f"""<b>{info['group']}</b><br><br> details = f"""<b>{info['group']}</b><br><br>
<b>Cost:</b> {info['cost']}<br> <b>Cost:</b> {info['cost']}<br>
<b>Requirements:</b> {info['requires']}<br>
<b>Install method:</b> <code>{install_method}</code><br> <b>Install method:</b> <code>{install_method}</code><br>
<b>After install:</b> {info['setup']}<br><br> <b>After install:</b> {info['setup']}<br><br>
<b>Available models:</b> {', '.join(v[0] for v in info['variants'])}""" <b>Available models:</b> {', '.join(v[0] for v in info['variants'])}"""
if is_installed: if is_installed:
details += "<br><br><span style='color: green;'>This provider is already installed. Click Install to reinstall or update.</span>" details += "<br><br><span style='color: green;'>This provider is already installed. Click Reinstall to run setup again.</span>"
self.details_label.setText(details) self.details_label.setText(details)
# Change button text based on installed status if is_manual:
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") self.btn_install.setText("Add Providers" if is_installed else "Setup Instructions")
else:
self.btn_install.setText("Reinstall" if is_installed else "Install Selected")
def _on_provider_double_clicked(self, item: QListWidgetItem): def _on_provider_double_clicked(self, item: QListWidgetItem):
"""Handle double-click to start install.""" """Handle double-click to start install."""
@@ -389,9 +471,9 @@ class ProviderInstallDialog(QDialog):
if not self._selected_provider: if not self._selected_provider:
return return
info = PROVIDER_INSTALL_INFO[self._selected_provider] info = SUPPORTED_PROVIDERS[self._selected_provider]
if not info["install_cmd"]: if info.get("manual_only"):
if self._check_installed(self._selected_provider): if self._check_installed(self._selected_provider):
self._populate_variants() self._populate_variants()
self.stack.setCurrentIndex(2) self.stack.setCurrentIndex(2)
@@ -399,30 +481,65 @@ class ProviderInstallDialog(QDialog):
QMessageBox.information(self, "Manual Setup", info["setup"]) QMessageBox.information(self, "Manual Setup", info["setup"])
return return
helper_argv = _find_setup_helper()
if not helper_argv:
QMessageBox.warning(
self,
"Setup Helper Missing",
"Agent Harness Setup Helper ('agent-harness-setup') was not found on your PATH or in active environments.\n\n"
"Please install the helper or run manually:\n"
" pip install -e /home/rob/PycharmProjects/agent-harness-setup\n\n"
"Or run directly:\n"
" PYTHONPATH=/home/rob/PycharmProjects/agent-harness-setup/src python3 -m agent_harness_setup"
)
return
# Prepare secure temporary directory and result file
self._temp_dir = tempfile.mkdtemp(prefix="cmdforge-harness-gui-")
try:
os.chmod(self._temp_dir, 0o700)
except OSError:
pass
result_file = Path(self._temp_dir) / "result.json"
request_id = str(uuid.uuid4())
harness_id = info.get("harness_id", self._selected_provider)
# Switch to install page # Switch to install page
self.stack.setCurrentIndex(1) self.stack.setCurrentIndex(1)
self.install_title.setText(f"Installing {info['group']}...") self.install_title.setText(f"Installing {info['group']}...")
self.install_cmd_label.setText(f"Command: {info['install_cmd']}") self.install_cmd_label.setText(f"Helper: agent-harness-setup --harness {harness_id}")
self.output_text.clear() self.output_text.clear()
self.progress.setRange(0, 0) self.progress.setRange(0, 0)
self.btn_back.setEnabled(False) self.btn_back.setEnabled(False)
self.btn_next.setEnabled(False) self.btn_next.setEnabled(False)
# Start installation term_launcher = find_terminal_emulator()
self._worker = InstallWorker(info['install_cmd']) if term_launcher:
self.output_text.append(f"Dedicated terminal window launched ({Path(term_launcher[0]).name}).")
self.output_text.append("Please complete the preflight and approval steps in that window.\n")
else:
self.output_text.append("No graphical terminal emulator detected; running in background process.\n")
self._worker = SetupTerminalWorker(
helper_argv=helper_argv,
harness_id=harness_id,
result_file=result_file,
request_id=request_id,
terminal_launcher=term_launcher,
)
self._worker.output.connect(self._on_install_output) self._worker.output.connect(self._on_install_output)
self._worker.finished.connect(self._on_install_finished) self._worker.finished.connect(self._on_setup_finished)
self._worker.start() self._worker.start()
def _on_install_output(self, text: str): def _on_install_output(self, text: str):
"""Handle installation output.""" """Handle installation output."""
self.output_text.append(text.rstrip()) self.output_text.append(text.rstrip())
# Auto-scroll to bottom
scrollbar = self.output_text.verticalScrollBar() scrollbar = self.output_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum()) scrollbar.setValue(scrollbar.maximum())
def _on_install_finished(self, success: bool, message: str): def _on_setup_finished(self, success: bool, message: str, result_data: dict):
"""Handle installation completion.""" """Handle setup helper process completion."""
self.progress.setRange(0, 1) self.progress.setRange(0, 1)
self.progress.setValue(1 if success else 0) self.progress.setValue(1 if success else 0)
self.btn_back.setEnabled(True) self.btn_back.setEnabled(True)
@@ -430,18 +547,39 @@ class ProviderInstallDialog(QDialog):
if success: if success:
self.install_title.setText("Installation Complete!") self.install_title.setText("Installation Complete!")
self.output_text.append(f"\n{'='*50}\n{message}") self.output_text.append(f"\n{'='*50}\n{message}")
# Advisory PATH updates
path_updates = result_data.get("path_updates") or []
if path_updates:
cur_path = os.environ.get("PATH", "")
new_dirs = [
str(Path(os.path.expanduser(p)))
for p in path_updates
if Path(os.path.expanduser(p)).is_dir() and str(Path(os.path.expanduser(p))) not in cur_path
]
if new_dirs:
os.environ["PATH"] = ":".join(new_dirs) + ":" + cur_path
# Update installed status for provider item in list
for i in range(self.provider_list.count()):
item = self.provider_list.item(i)
if item.data(Qt.UserRole) == self._selected_provider:
item.setData(Qt.UserRole + 1, True)
info = SUPPORTED_PROVIDERS[self._selected_provider]
item.setText(f"{info['group']} [INSTALLED]")
item.setForeground(Qt.darkGreen)
break
self.btn_next.setEnabled(True) self.btn_next.setEnabled(True)
# Populate variants page
self._populate_variants() self._populate_variants()
self.stack.setCurrentIndex(2)
else: else:
self.install_title.setText("Installation Failed") self.install_title.setText("Installation Incomplete")
self.output_text.append(f"\n{'='*50}\nERROR: {message}") self.output_text.append(f"\n{'='*50}\n{message}")
QMessageBox.warning( QMessageBox.warning(
self, "Installation Failed", self,
f"The installation did not complete successfully.\n\n{message}\n\n" "Installation Incomplete",
"You may need to install manually. Check the output for details." f"{message}\n\nYou can inspect the logs or retry.",
) )
def _populate_variants(self): def _populate_variants(self):
@@ -449,7 +587,7 @@ class ProviderInstallDialog(QDialog):
if not self._selected_provider: if not self._selected_provider:
return return
info = PROVIDER_INSTALL_INFO[self._selected_provider] info = SUPPORTED_PROVIDERS[self._selected_provider]
self.variants_list.clear() self.variants_list.clear()
# Get currently configured provider names # Get currently configured provider names
@@ -458,6 +596,7 @@ class ProviderInstallDialog(QDialog):
for name, command, description in info['variants']: for name, command, description in info['variants']:
item = QListWidgetItem(f"{name} - {description}") item = QListWidgetItem(f"{name} - {description}")
item.setData(Qt.UserRole, (name, command, description)) item.setData(Qt.UserRole, (name, command, description))
self.variants_list.addItem(item)
# Pre-select if not already configured # Pre-select if not already configured
if name not in configured: if name not in configured:
@@ -466,8 +605,6 @@ class ProviderInstallDialog(QDialog):
item.setForeground(Qt.gray) item.setForeground(Qt.gray)
item.setText(f"{name} - {description} [already configured]") item.setText(f"{name} - {description} [already configured]")
self.variants_list.addItem(item)
# Update next steps # Update next steps
self.next_steps_label.setText( self.next_steps_label.setText(
f"<b>Important:</b> Before using these providers, you need to authenticate:<br><br>" f"<b>Important:</b> Before using these providers, you need to authenticate:<br><br>"
@@ -497,3 +634,14 @@ class ProviderInstallDialog(QDialog):
) )
self.accept() self.accept()
def closeEvent(self, event):
"""Clean up worker and temporary resources on close."""
if self._worker and self._worker.isRunning():
self._worker.terminate_process()
self._worker.wait(2000)
if self._temp_dir and Path(self._temp_dir).is_dir():
shutil.rmtree(self._temp_dir, ignore_errors=True)
super().closeEvent(event)
+413
View File
@@ -0,0 +1,413 @@
"""Acceptance tests for Packet S6: CmdForge GUI Provider Install Dialog reconciliation."""
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
os.environ["QT_QPA_PLATFORM"] = "offscreen"
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QMessageBox
# Ensure single application instance for tests
_qapp = QApplication.instance()
if _qapp is None:
_qapp = QApplication(["cmdforge-gui-tests", "-platform", "offscreen"])
from cmdforge.gui.dialogs.provider_install_dialog import (
PROVIDER_INSTALL_INFO,
SUPPORTED_PROVIDERS,
ProviderInstallDialog,
SetupTerminalWorker,
find_terminal_emulator,
)
from cmdforge.providers import Provider, get_provider, load_providers, save_providers
@pytest.fixture
def temp_providers_file(tmp_path):
providers_file = tmp_path / ".cmdforge" / "providers.yaml"
with patch("cmdforge.providers.PROVIDERS_FILE", providers_file):
save_providers([])
yield providers_file
def test_supported_providers_catalogue_reconciliation():
"""Verify that duplicate raw shell install commands and requirements are retired."""
assert "claude" in SUPPORTED_PROVIDERS
assert "codex" in SUPPORTED_PROVIDERS
assert "agy" in SUPPORTED_PROVIDERS
assert "opencode" in SUPPORTED_PROVIDERS
assert "crush" in SUPPORTED_PROVIDERS
assert "ollama" in SUPPORTED_PROVIDERS
# Ensure no entry has raw install_cmd shell scripts or requires strings
for name, info in SUPPORTED_PROVIDERS.items():
assert "install_cmd" not in info, f"Provider {name} still contains raw install_cmd"
assert "requires" not in info, f"Provider {name} still contains requires"
assert "group" in info
assert "setup" in info
assert "cost" in info
assert "variants" in info
assert len(info["variants"]) > 0
# Ensure backwards compatibility alias
assert PROVIDER_INSTALL_INFO is SUPPORTED_PROVIDERS
def test_find_terminal_emulator():
"""Verify terminal emulator detection logic."""
with patch("shutil.which", side_effect=lambda cmd: "/usr/bin/gnome-terminal" if cmd == "gnome-terminal" else None):
launcher = find_terminal_emulator()
assert launcher == ["/usr/bin/gnome-terminal", "--"]
with patch("shutil.which", side_effect=lambda cmd: "/usr/bin/x-terminal-emulator" if cmd == "x-terminal-emulator" else None):
launcher = find_terminal_emulator()
assert launcher == ["/usr/bin/x-terminal-emulator", "-e"]
with patch("shutil.which", return_value=None):
launcher = find_terminal_emulator()
assert launcher is None
def test_dialog_init_and_provider_selection(temp_providers_file):
"""Verify dialog initialization and detail rendering for automated and manual providers."""
with patch("shutil.which", return_value=None):
dialog = ProviderInstallDialog()
assert dialog.provider_list.count() == len(SUPPORTED_PROVIDERS)
assert not dialog.btn_install.isEnabled()
# Select Claude (automated setup helper)
claude_item = None
for i in range(dialog.provider_list.count()):
item = dialog.provider_list.item(i)
if item.data(Qt.UserRole) == "claude":
claude_item = item
break
assert claude_item is not None
dialog._on_provider_selected(claude_item)
assert dialog._selected_provider == "claude"
assert dialog.btn_install.isEnabled()
assert dialog.btn_install.text() == "Install Selected"
assert "Agent Harness Setup Helper (terminal)" in dialog.details_label.text()
assert "Anthropic Claude" in dialog.details_label.text()
# Select AGY (manual setup instructions)
agy_item = None
for i in range(dialog.provider_list.count()):
item = dialog.provider_list.item(i)
if item.data(Qt.UserRole) == "agy":
agy_item = item
break
assert agy_item is not None
dialog._on_provider_selected(agy_item)
assert dialog._selected_provider == "agy"
assert dialog.btn_install.isEnabled()
assert dialog.btn_install.text() == "Setup Instructions"
assert "Manual desktop setup" in dialog.details_label.text()
dialog.close()
def test_start_install_missing_helper_shows_warning(temp_providers_file):
"""Verify missing setup helper displays warning and does not advance page."""
with patch("shutil.which", return_value=None):
dialog = ProviderInstallDialog()
dialog._selected_provider = "claude"
with (
patch("cmdforge.gui.dialogs.provider_install_dialog._find_setup_helper", return_value=None),
patch.object(QMessageBox, "warning") as mock_warn,
):
dialog._start_install()
assert mock_warn.called
assert "Agent Harness Setup Helper ('agent-harness-setup') was not found" in mock_warn.call_args[0][2]
# Verify dialog stayed on Page 0 (Selection)
assert dialog.stack.currentIndex() == 0
dialog.close()
def test_setup_terminal_worker_success(tmp_path):
"""Test SetupTerminalWorker success path with valid result correlation."""
result_file = tmp_path / "result.json"
req_id = "test-uuid-1234"
data = {
"status": "success",
"request_id": req_id,
"harness_id": "claude-code",
"path_updates": ["/custom/bin"],
}
class MockProcess:
def __init__(self, *args, **kwargs):
self.stdout = None
result_file.write_text(json.dumps(data), encoding="utf-8")
def wait(self):
return 0
def poll(self):
return 0
worker = SetupTerminalWorker(
helper_argv=["/bin/agent-harness-setup"],
harness_id="claude-code",
result_file=result_file,
request_id=req_id,
terminal_launcher=["/usr/bin/xterm", "-e"],
)
results = []
worker.finished.connect(lambda s, m, d: results.append((s, m, d)))
with patch("subprocess.Popen", side_effect=MockProcess):
worker.run()
assert len(results) == 1
success, msg, res_dict = results[0]
assert success is True
assert "verified successfully" in msg
assert res_dict["path_updates"] == ["/custom/bin"]
def test_setup_terminal_worker_security_mismatch(tmp_path):
"""Test SetupTerminalWorker rejects result with request_id mismatch."""
result_file = tmp_path / "result.json"
data = {
"status": "success",
"request_id": "wrong-uuid",
"harness_id": "claude-code",
}
class MockProcess:
def __init__(self, *args, **kwargs):
self.stdout = None
result_file.write_text(json.dumps(data), encoding="utf-8")
def wait(self):
return 0
worker = SetupTerminalWorker(
helper_argv=["/bin/agent-harness-setup"],
harness_id="claude-code",
result_file=result_file,
request_id="expected-uuid",
)
results = []
worker.finished.connect(lambda s, m, d: results.append((s, m, d)))
with patch("subprocess.Popen", side_effect=MockProcess):
worker.run()
assert len(results) == 1
success, msg, _ = results[0]
assert success is False
assert "Security error" in msg
def test_setup_terminal_worker_incoherent_exit(tmp_path):
"""Test SetupTerminalWorker rejects exit code 1 with status 'success'."""
result_file = tmp_path / "result.json"
req_id = "test-uuid"
data = {
"status": "success",
"request_id": req_id,
}
class MockProcess:
def __init__(self, *args, **kwargs):
self.stdout = None
result_file.write_text(json.dumps(data), encoding="utf-8")
def wait(self):
return 1
worker = SetupTerminalWorker(
helper_argv=["/bin/agent-harness-setup"],
harness_id="claude-code",
result_file=result_file,
request_id=req_id,
)
results = []
worker.finished.connect(lambda s, m, d: results.append((s, m, d)))
with patch("subprocess.Popen", side_effect=MockProcess):
worker.run()
assert len(results) == 1
success, msg, _ = results[0]
assert success is False
assert "error code 1" in msg
def test_setup_terminal_worker_cancelled(tmp_path):
"""Test SetupTerminalWorker handles user cancellation cleanly."""
result_file = tmp_path / "result.json"
req_id = "cancel-uuid"
data = {
"status": "cancelled",
"request_id": req_id,
}
class MockProcess:
def __init__(self, *args, **kwargs):
self.stdout = None
result_file.write_text(json.dumps(data), encoding="utf-8")
def wait(self):
return 10
worker = SetupTerminalWorker(
helper_argv=["/bin/agent-harness-setup"],
harness_id="claude-code",
result_file=result_file,
request_id=req_id,
)
results = []
worker.finished.connect(lambda s, m, d: results.append((s, m, d)))
with patch("subprocess.Popen", side_effect=MockProcess):
worker.run()
assert len(results) == 1
success, msg, _ = results[0]
assert success is False
assert "cancelled" in msg.lower()
def test_setup_terminal_worker_failure_with_diagnostics(tmp_path):
"""Test SetupTerminalWorker handles failure with error diagnostics."""
result_file = tmp_path / "result.json"
req_id = "fail-uuid"
data = {
"status": "failed",
"request_id": req_id,
"error_diagnostics": "Missing npm dependency",
}
class MockProcess:
def __init__(self, *args, **kwargs):
self.stdout = None
result_file.write_text(json.dumps(data), encoding="utf-8")
def wait(self):
return 1
worker = SetupTerminalWorker(
helper_argv=["/bin/agent-harness-setup"],
harness_id="claude-code",
result_file=result_file,
request_id=req_id,
)
results = []
worker.finished.connect(lambda s, m, d: results.append((s, m, d)))
with patch("subprocess.Popen", side_effect=MockProcess):
worker.run()
assert len(results) == 1
success, msg, _ = results[0]
assert success is False
assert "Missing npm dependency" in msg
def test_dialog_full_flow_success_to_variants_and_add(temp_providers_file, tmp_path):
"""Test full dialog flow: install succeeds, advances to variants, registers selected variants."""
with patch("shutil.which", return_value=None):
dialog = ProviderInstallDialog()
dialog._selected_provider = "claude"
mock_worker = MagicMock()
with (
patch("cmdforge.gui.dialogs.provider_install_dialog._find_setup_helper", return_value=["/bin/agent-harness-setup"]),
patch("cmdforge.gui.dialogs.provider_install_dialog.SetupTerminalWorker", return_value=mock_worker),
):
dialog._start_install()
assert dialog.stack.currentIndex() == 1 # Install page
assert "Installing Anthropic Claude" in dialog.install_title.text()
# Create dummy directory for path update test
custom_bin = tmp_path / "bin"
custom_bin.mkdir(parents=True, exist_ok=True)
result_data = {
"status": "success",
"request_id": "any",
"harness_id": "claude-code",
"path_updates": [str(custom_bin)],
}
dialog._on_setup_finished(True, "Setup verified!", result_data)
assert str(custom_bin) in os.environ.get("PATH", "")
assert dialog.stack.currentIndex() == 2
assert dialog.variants_list.count() == len(SUPPORTED_PROVIDERS["claude"]["variants"])
selected_items = [dialog.variants_list.item(i) for i in range(dialog.variants_list.count()) if dialog.variants_list.item(i).isSelected()]
assert len(selected_items) > 0
with patch.object(QMessageBox, "information") as mock_info:
dialog._add_selected_variants()
assert mock_info.called
provider = get_provider("claude")
assert provider is not None
assert provider.command == "claude -p"
dialog.close()
def test_dialog_failure_flow_shows_warning(temp_providers_file):
"""Test dialog failure flow warns user and remains on install page."""
with patch("shutil.which", return_value=None):
dialog = ProviderInstallDialog()
dialog._selected_provider = "claude"
mock_worker = MagicMock()
with (
patch("cmdforge.gui.dialogs.provider_install_dialog._find_setup_helper", return_value=["/bin/agent-harness-setup"]),
patch("cmdforge.gui.dialogs.provider_install_dialog.SetupTerminalWorker", return_value=mock_worker),
):
dialog._start_install()
with patch.object(QMessageBox, "warning") as mock_warn:
dialog._on_setup_finished(False, "Compilation error in installer", {})
assert mock_warn.called
assert "Compilation error in installer" in mock_warn.call_args[0][2]
assert dialog.stack.currentIndex() == 1
assert "Installation Incomplete" in dialog.install_title.text()
assert dialog.btn_back.isEnabled()
dialog.close()
def test_dialog_close_cleans_up_resources(temp_providers_file):
"""Test closing dialog terminates active worker and removes temporary directory."""
with patch("shutil.which", return_value=None):
dialog = ProviderInstallDialog()
temp_dir = tempfile.mkdtemp(prefix="test-cleanup-")
dialog._temp_dir = temp_dir
mock_worker = MagicMock()
mock_worker.isRunning.return_value = True
dialog._worker = mock_worker
dialog.close()
assert mock_worker.terminate_process.called
assert not Path(temp_dir).exists()