Add system dependencies support for tools
Tools can now declare system-level package dependencies (apt, brew, pacman, dnf) that get checked and optionally installed when the tool is installed or run. Features: - SystemDependency dataclass with short form (string) and long form (dict) - New system_deps.py module with platform detection and installation - `cmdforge system-deps <tool>` command to check/install system packages - `cmdforge check` now shows both CmdForge and system dependencies - `cmdforge registry install` prompts for system dep installation - GUI: System Dependencies section in Tool Builder with add/edit dialog - Runner warns about missing system deps before execution - Integration with project install (manifest and lock-based) Also includes: - Quote paths in wrapper scripts for spaces support - Tests for value type preservation in code steps - Unskip invalid tool name test Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""System dependency editor dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QPushButton, QHBoxLayout, QLabel, QGroupBox
|
||||
)
|
||||
|
||||
from ...tool import SystemDependency
|
||||
|
||||
|
||||
class SystemDependencyDialog(QDialog):
|
||||
"""Dialog for editing system dependencies."""
|
||||
|
||||
def __init__(self, parent, dep: SystemDependency = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit System Dependency" if dep else "Add System Dependency")
|
||||
self.setMinimumWidth(450)
|
||||
self._dep = dep
|
||||
self._setup_ui()
|
||||
|
||||
if dep:
|
||||
self._load_dep(dep)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Basic info
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("ffmpeg")
|
||||
self.name_input.setToolTip("Package name (used as default for all package managers)")
|
||||
form.addRow("Name:", self.name_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("Audio/video processing (optional)")
|
||||
self.desc_input.setToolTip("Human-readable description of why this package is needed")
|
||||
form.addRow("Description:", self.desc_input)
|
||||
|
||||
self.binaries_input = QLineEdit()
|
||||
self.binaries_input.setPlaceholderText("ffplay, ffmpeg (optional, comma-separated)")
|
||||
self.binaries_input.setToolTip(
|
||||
"Executables to check for. If any exist, dependency is satisfied.\n"
|
||||
"Leave empty to check for the package name itself."
|
||||
)
|
||||
form.addRow("Binaries:", self.binaries_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Package names (advanced)
|
||||
pkg_group = QGroupBox("Package Names (Advanced)")
|
||||
pkg_group.setToolTip("Override package name for specific package managers")
|
||||
pkg_layout = QFormLayout(pkg_group)
|
||||
pkg_layout.setSpacing(8)
|
||||
|
||||
self.apt_input = QLineEdit()
|
||||
self.apt_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("apt:", self.apt_input)
|
||||
|
||||
self.brew_input = QLineEdit()
|
||||
self.brew_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("brew:", self.brew_input)
|
||||
|
||||
self.pacman_input = QLineEdit()
|
||||
self.pacman_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("pacman:", self.pacman_input)
|
||||
|
||||
self.dnf_input = QLineEdit()
|
||||
self.dnf_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("dnf:", self.dnf_input)
|
||||
|
||||
layout.addWidget(pkg_group)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"System dependencies are installed via the system package manager\n"
|
||||
"(apt, brew, pacman, dnf). Users will be prompted to install missing\n"
|
||||
"packages when they install your tool from the registry."
|
||||
)
|
||||
help_text.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("OK")
|
||||
self.btn_ok.clicked.connect(self._validate_and_accept)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_dep(self, dep: SystemDependency):
|
||||
"""Load dependency data into form."""
|
||||
self.name_input.setText(dep.name)
|
||||
self.desc_input.setText(dep.description or "")
|
||||
|
||||
if dep.binaries:
|
||||
self.binaries_input.setText(", ".join(dep.binaries))
|
||||
|
||||
# Package overrides
|
||||
if dep.packages:
|
||||
self.apt_input.setText(dep.packages.get("apt", ""))
|
||||
self.brew_input.setText(dep.packages.get("brew", ""))
|
||||
self.pacman_input.setText(dep.packages.get("pacman", ""))
|
||||
self.dnf_input.setText(dep.packages.get("dnf", ""))
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate input and accept."""
|
||||
name = self.name_input.text().strip()
|
||||
|
||||
if not name:
|
||||
self.name_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_dependency(self) -> SystemDependency:
|
||||
"""Get the dependency from form data."""
|
||||
name = self.name_input.text().strip()
|
||||
description = self.desc_input.text().strip()
|
||||
|
||||
# Parse binaries
|
||||
binaries_text = self.binaries_input.text().strip()
|
||||
binaries = []
|
||||
if binaries_text:
|
||||
binaries = [b.strip() for b in binaries_text.split(",") if b.strip()]
|
||||
|
||||
# Collect package overrides (only non-empty values)
|
||||
packages = {}
|
||||
if self.apt_input.text().strip():
|
||||
packages["apt"] = self.apt_input.text().strip()
|
||||
if self.brew_input.text().strip():
|
||||
packages["brew"] = self.brew_input.text().strip()
|
||||
if self.pacman_input.text().strip():
|
||||
packages["pacman"] = self.pacman_input.text().strip()
|
||||
if self.dnf_input.text().strip():
|
||||
packages["dnf"] = self.dnf_input.text().strip()
|
||||
|
||||
# Determine original format for round-trip serialization
|
||||
# Use short form if only name is provided
|
||||
original_format = "short" if (not description and not binaries and not packages) else "long"
|
||||
|
||||
return SystemDependency(
|
||||
name=name,
|
||||
description=description,
|
||||
binaries=binaries,
|
||||
packages=packages,
|
||||
_original_format=original_format
|
||||
)
|
||||
@@ -177,6 +177,49 @@ class ToolBuilderPage(QWidget):
|
||||
|
||||
left_layout.addWidget(deps_box)
|
||||
|
||||
# System Dependencies group (compact)
|
||||
sys_deps_box = QGroupBox()
|
||||
sys_deps_layout = QVBoxLayout(sys_deps_box)
|
||||
sys_deps_layout.setContentsMargins(9, 9, 9, 9)
|
||||
sys_deps_layout.setSpacing(8)
|
||||
|
||||
sys_deps_label = QLabel("System Dependencies")
|
||||
sys_deps_label.setObjectName("sectionHeading")
|
||||
sys_deps_label.setToolTip(
|
||||
"System packages (apt, brew, pacman, dnf) required by your tool.\n"
|
||||
"Users will be prompted to install missing packages."
|
||||
)
|
||||
sys_deps_layout.addWidget(sys_deps_label)
|
||||
|
||||
self.sys_deps_list = QListWidget()
|
||||
self.sys_deps_list.setMaximumHeight(80)
|
||||
self.sys_deps_list.setMinimumHeight(60)
|
||||
self.sys_deps_list.itemDoubleClicked.connect(self._edit_sys_dep)
|
||||
sys_deps_layout.addWidget(self.sys_deps_list)
|
||||
|
||||
# System dependency buttons
|
||||
sys_deps_btns = QHBoxLayout()
|
||||
sys_deps_btns.setSpacing(6)
|
||||
|
||||
self.btn_add_sys_dep = QPushButton("Add")
|
||||
self.btn_add_sys_dep.clicked.connect(self._add_sys_dep)
|
||||
sys_deps_btns.addWidget(self.btn_add_sys_dep)
|
||||
|
||||
self.btn_edit_sys_dep = QPushButton("Edit")
|
||||
self.btn_edit_sys_dep.setObjectName("secondary")
|
||||
self.btn_edit_sys_dep.clicked.connect(self._edit_sys_dep)
|
||||
sys_deps_btns.addWidget(self.btn_edit_sys_dep)
|
||||
|
||||
self.btn_del_sys_dep = QPushButton("Remove")
|
||||
self.btn_del_sys_dep.setObjectName("danger")
|
||||
self.btn_del_sys_dep.clicked.connect(self._delete_sys_dep)
|
||||
sys_deps_btns.addWidget(self.btn_del_sys_dep)
|
||||
|
||||
sys_deps_btns.addStretch()
|
||||
sys_deps_layout.addLayout(sys_deps_btns)
|
||||
|
||||
left_layout.addWidget(sys_deps_box)
|
||||
|
||||
# Defaults group (collapsible)
|
||||
self.defaults_group = QGroupBox("Defaults (Optional)")
|
||||
self.defaults_group.setCheckable(True)
|
||||
@@ -484,6 +527,9 @@ class ToolBuilderPage(QWidget):
|
||||
# Load dependencies
|
||||
self._refresh_dependencies()
|
||||
|
||||
# Load system dependencies
|
||||
self._refresh_sys_deps()
|
||||
|
||||
# Load steps
|
||||
self._refresh_steps()
|
||||
|
||||
@@ -727,6 +773,68 @@ class ToolBuilderPage(QWidget):
|
||||
for dep in self._tool.dependencies:
|
||||
self.deps_list.addItem(dep)
|
||||
|
||||
def _add_sys_dep(self):
|
||||
"""Add a new system dependency."""
|
||||
from ..dialogs.system_dep_dialog import SystemDependencyDialog
|
||||
dialog = SystemDependencyDialog(self)
|
||||
if dialog.exec():
|
||||
dep = dialog.get_dependency()
|
||||
if not self._tool:
|
||||
from ...tool import Tool
|
||||
self._tool = Tool(name="", description="")
|
||||
if not hasattr(self._tool, 'system_dependencies') or self._tool.system_dependencies is None:
|
||||
self._tool.system_dependencies = []
|
||||
|
||||
# Check if already exists
|
||||
existing_names = [d.name for d in self._tool.system_dependencies]
|
||||
if dep.name in existing_names:
|
||||
self.main_window.show_status(f"'{dep.name}' is already a system dependency")
|
||||
return
|
||||
|
||||
self._tool.system_dependencies.append(dep)
|
||||
self._refresh_sys_deps()
|
||||
self.main_window.show_status(f"Added system dependency: {dep.name}")
|
||||
|
||||
def _edit_sys_dep(self):
|
||||
"""Edit selected system dependency."""
|
||||
items = self.sys_deps_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
idx = self.sys_deps_list.row(items[0])
|
||||
if not self._tool or idx >= len(self._tool.system_dependencies):
|
||||
return
|
||||
|
||||
dep = self._tool.system_dependencies[idx]
|
||||
|
||||
from ..dialogs.system_dep_dialog import SystemDependencyDialog
|
||||
dialog = SystemDependencyDialog(self, dep)
|
||||
if dialog.exec():
|
||||
self._tool.system_dependencies[idx] = dialog.get_dependency()
|
||||
self._refresh_sys_deps()
|
||||
|
||||
def _delete_sys_dep(self):
|
||||
"""Remove selected system dependency."""
|
||||
items = self.sys_deps_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
idx = self.sys_deps_list.row(items[0])
|
||||
if self._tool and self._tool.system_dependencies and idx < len(self._tool.system_dependencies):
|
||||
del self._tool.system_dependencies[idx]
|
||||
self._refresh_sys_deps()
|
||||
|
||||
def _refresh_sys_deps(self):
|
||||
"""Refresh system dependencies list widget."""
|
||||
self.sys_deps_list.clear()
|
||||
if self._tool and self._tool.system_dependencies:
|
||||
for dep in self._tool.system_dependencies:
|
||||
# Show name and description if available
|
||||
text = dep.name
|
||||
if dep.description:
|
||||
text += f" - {dep.description}"
|
||||
self.sys_deps_list.addItem(text)
|
||||
|
||||
def _add_tool_dependency(self, tool_ref: str):
|
||||
"""Add a tool reference to the dependencies list if not already present.
|
||||
|
||||
@@ -979,7 +1087,8 @@ class ToolBuilderPage(QWidget):
|
||||
arguments=self._tool.arguments if self._tool else [],
|
||||
steps=self._tool.steps if self._tool else [],
|
||||
output=output,
|
||||
dependencies=self._tool.dependencies if self._tool else []
|
||||
dependencies=self._tool.dependencies if self._tool else [],
|
||||
system_dependencies=self._tool.system_dependencies if self._tool else []
|
||||
)
|
||||
|
||||
# Preserve source if editing
|
||||
|
||||
Reference in New Issue
Block a user