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
+110 -1
View File
@@ -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