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:
rob
2026-01-28 16:31:37 -04:00
co-authored by Claude Opus 4.5
parent 8eff55ed1e
commit 071ade0ffb
15 changed files with 1399 additions and 62 deletions
@@ -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
)