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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-28 16:31:37 -04:00
parent 8eff55ed1e
commit 071ade0ffb
15 changed files with 1399 additions and 62 deletions

View File

@ -6,6 +6,49 @@ All notable changes to CmdForge will be documented in this file.
### Added
#### System Dependencies
- **System package dependencies for tools**: Tools can now declare system-level package dependencies (apt, brew, pacman, dnf)
- `system_dependencies` field in tool config.yaml
- Short form: just package name (e.g., `ffmpeg`)
- Long form: detailed spec with description, binaries to check, and per-manager package names
- Automatic platform detection (Linux/macOS/Windows)
- Package manager detection (apt, brew, pacman, dnf)
- **New dataclass**: `SystemDependency` in tool.py
- `name` - Package identifier
- `description` - Why this dependency is needed
- `binaries` - List of executables to check (defaults to [name])
- `packages` - Package names per manager (defaults to name for all)
- Round-trip serialization preserves short/long form
- **New module**: `src/cmdforge/system_deps.py`
- `detect_os()` - Returns 'linux', 'darwin', or 'windows'
- `detect_package_manager()` - Returns 'apt', 'brew', 'pacman', 'dnf', or None
- `check_system_dep()` - Check if dependency is satisfied (any binary found)
- `install_system_dep()` - Install via system package manager
- `prompt_install_missing()` - Interactive prompt for installing missing packages
- **New CLI command**: `cmdforge system-deps <tool> [install]`
- `cmdforge system-deps <tool>` - Show system dependency status
- `cmdforge system-deps <tool> install` - Install missing system packages
- `--yes` flag for non-interactive installation
- **Enhanced CLI commands**:
- `cmdforge check <tool>` - Now shows both CmdForge and system dependencies
- `cmdforge registry install` - Prompts to install system deps after tool install
- `--yes` flag to auto-install without prompting
- `--no-system-deps` flag to skip system dependency handling
- `cmdforge install` (project) - Handles system deps for manifest and lock-based installs
- **GUI support**:
- Tool Builder: New "System Dependencies" section
- Add/Edit/Remove system dependency dialog
- Shows name and description in list
- **Runtime integration**:
- Runner warns about missing system dependencies before execution
- Uses qualified tool refs (owner/name) for registry-installed tools
#### Tool Settings Files
- **Configurable tool settings**: Tools can now ship with default settings that users can customize
- `defaults.yaml` - Default settings published with the tool (immutable)

View File

@ -15,6 +15,7 @@ from .collections_commands import cmd_collections
from .project_commands import cmd_deps, cmd_deps_tree, cmd_install_deps, cmd_add, cmd_remove, cmd_init, cmd_lock, cmd_verify
from .config_commands import cmd_config
from .settings_commands import cmd_settings
from .system_deps_commands import cmd_system_deps
def main():
@ -163,6 +164,8 @@ def main():
p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry")
p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)")
p_reg_install.add_argument("-v", "--version", help="Version constraint")
p_reg_install.add_argument("-y", "--yes", action="store_true", help="Auto-install system packages without prompting")
p_reg_install.add_argument("--no-system-deps", action="store_true", help="Skip system dependency check")
p_reg_install.set_defaults(func=cmd_registry)
# registry uninstall
@ -397,6 +400,21 @@ def main():
# Default for settings with no subcommand
p_settings.set_defaults(func=cmd_settings)
# -------------------------------------------------------------------------
# System Dependencies Commands
# -------------------------------------------------------------------------
p_sysdeps = subparsers.add_parser("system-deps", help="Manage system package dependencies")
p_sysdeps.add_argument("tool", help="Tool name")
sysdeps_sub = p_sysdeps.add_subparsers(dest="system_deps_cmd")
# system-deps install
p_sysdeps_install = sysdeps_sub.add_parser("install", help="Install missing system packages")
p_sysdeps_install.add_argument("-y", "--yes", action="store_true", help="Install without prompting")
p_sysdeps_install.set_defaults(func=cmd_system_deps)
# Default for system-deps with no subcommand (show status)
p_sysdeps.set_defaults(func=cmd_system_deps)
args = parser.parse_args()
# If no command, launch UI

View File

@ -148,6 +148,7 @@ def cmd_install_deps(args):
from ..resolver import ToolResolver, install_from_registry, ToolNotFoundError
from ..registry_client import RegistryError
from ..lockfile import Lockfile
from ..system_deps import prompt_install_missing
manifest = load_manifest()
@ -252,9 +253,13 @@ def cmd_install_deps(args):
print(f"Installing {node.qualified_name}...", end=" ", flush=True)
spec = node.qualified_name
install_version = node.resolved_version or node.version_constraint
install_from_registry(spec, version=install_version)
resolved = install_from_registry(spec, version=install_version)
print(f"v{node.resolved_version or 'latest'}")
installed += 1
if resolved.tool.system_dependencies:
print()
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
prompt_install_missing(resolved.tool.system_dependencies, tool_ref)
except RegistryError as e:
print(f"FAILED: {e.message}")
failed.append((node.qualified_name, e.message))
@ -278,6 +283,7 @@ def _install_from_lock(lock: "Lockfile", args) -> int:
"""Install exact versions from lock file."""
from ..resolver import ToolResolver, ToolNotFoundError, install_from_registry
from ..registry_client import RegistryError
from ..system_deps import prompt_install_missing
verbose = getattr(args, 'verbose', False)
dry_run = getattr(args, 'dry_run', False)
@ -333,9 +339,13 @@ def _install_from_lock(lock: "Lockfile", args) -> int:
try:
print(f"Installing {pkg.name}@{pkg.version}...", end=" ", flush=True)
# Use correct API: install_from_registry(spec, version)
install_from_registry(pkg.name, version=pkg.version)
resolved = install_from_registry(pkg.name, version=pkg.version)
print("OK")
installed += 1
if resolved.tool.system_dependencies:
print()
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
prompt_install_missing(resolved.tool.system_dependencies, tool_ref)
except RegistryError as e:
print(f"FAILED: {e.message}")
failed.append((pkg.name, e.message))

View File

@ -190,9 +190,12 @@ def _cmd_registry_install(args):
"""Install a tool from the registry."""
from ..registry_client import RegistryError
from ..tool import BIN_DIR
from ..system_deps import prompt_install_missing
tool_spec = args.tool
version = args.version
auto_yes = getattr(args, 'yes', False)
skip_sys_deps = getattr(args, 'no_system_deps', False)
print(f"Installing {tool_spec}...")
@ -211,6 +214,12 @@ def _cmd_registry_install(args):
print(f"\nRun with: {wrapper_name}")
# Check system dependencies unless skipped
if not skip_sys_deps and resolved.tool.system_dependencies:
print()
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
prompt_install_missing(resolved.tool.system_dependencies, tool_ref, auto_yes=auto_yes)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr)

View File

@ -0,0 +1,141 @@
"""System dependencies commands."""
import sys
from ..tool import load_tool
from ..system_deps import (
detect_os, detect_package_manager,
check_and_report, format_dep_status,
install_system_dep, get_install_command_string
)
def cmd_system_deps(args):
"""Handle system-deps subcommands."""
tool_name = args.tool
tool = load_tool(tool_name)
if not tool:
print(f"Error: Tool '{tool_name}' not found.", file=sys.stderr)
return 1
if not tool.system_dependencies:
print(f"Tool '{tool_name}' has no system dependencies declared.")
return 0
subcmd = getattr(args, 'system_deps_cmd', None)
if subcmd == "install":
return _cmd_install(args, tool)
else:
# Default: show status
return _cmd_status(args, tool)
def _cmd_status(args, tool):
"""Show system dependency status for a tool."""
print(f"System Dependencies for '{tool.name}':")
print()
pkg_manager = detect_package_manager()
installed, missing = check_and_report(tool.system_dependencies)
for dep in tool.system_dependencies:
status = format_dep_status(dep, pkg_manager if dep in missing else None)
print(status)
print()
if missing:
if pkg_manager:
print(f"Package manager: {pkg_manager}")
print(f"Install missing with: cmdforge system-deps {tool.name} install")
else:
os_type = detect_os()
if os_type == "windows":
print("Note: Automatic installation not supported on Windows.")
print("Please install the packages manually.")
else:
print("Warning: No supported package manager found.")
return 1
else:
print("All system dependencies satisfied.")
return 0
def _cmd_install(args, tool):
"""Install missing system dependencies."""
os_type = detect_os()
if os_type == "windows":
print("Automatic installation not supported on Windows.", file=sys.stderr)
print()
print("Please install these packages manually:")
for dep in tool.system_dependencies:
print(f" - {dep.name}")
if dep.description:
print(f" {dep.description}")
return 1
pkg_manager = detect_package_manager()
if not pkg_manager:
print("Error: No supported package manager found.", file=sys.stderr)
print("Supported: apt, brew, pacman, dnf", file=sys.stderr)
return 1
installed, missing = check_and_report(tool.system_dependencies)
if not missing:
print("All system dependencies are already installed.")
return 0
print(f"Missing system packages ({len(missing)}):")
for dep in missing:
desc = f" - {dep.description}" if dep.description else ""
print(f" {dep.name}{desc}")
print(f" Install: {get_install_command_string(dep, pkg_manager)}")
print()
# Check for --yes flag
auto_yes = getattr(args, 'yes', False)
if not auto_yes:
try:
confirm = input(f"Install {len(missing)} package(s)? [Y/n] ")
if confirm.lower() not in ('', 'y', 'yes'):
print("Cancelled.")
return 0
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 0
print()
print(f"Installing packages with {pkg_manager}...")
print()
# Track results
succeeded = []
failed = []
for dep in missing:
print(f"Installing {dep.name}...")
success, msg = install_system_dep(dep, pkg_manager)
if success:
print(f" \u2713 {msg}")
succeeded.append(dep)
else:
print(f" \u2717 {msg}")
failed.append((dep, msg))
print()
if failed:
print(f"Installed {len(succeeded)}/{len(missing)} packages.")
print()
print("Failed packages:")
for dep, msg in failed:
print(f" {dep.name}: {msg}")
return 1
else:
print(f"Successfully installed {len(succeeded)} package(s).")
return 0

View File

@ -5,7 +5,7 @@ from pathlib import Path
from ..tool import (
list_tools, load_tool, save_tool, delete_tool, get_tools_dir,
Tool, ToolArgument, PromptStep, CodeStep, ToolStep
Tool, ToolArgument, PromptStep, CodeStep, ToolStep, validate_tool_name
)
from ..gui import run_gui
@ -59,6 +59,12 @@ def cmd_create(args):
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
name = args.name
# Validate tool name
is_valid, error_msg = validate_tool_name(name)
if not is_valid:
print(f"Error: Invalid tool name '{name}': {error_msg}")
return 1
# Check if already exists
existing = load_tool(name)
if existing and not args.force:
@ -223,29 +229,58 @@ def cmd_run(args):
if tool_args and tool_args[0] == '--':
tool_args = tool_args[1:]
def is_flag(s: str) -> bool:
"""Check if string looks like a flag (not a negative number)."""
if not s.startswith('-'):
return False
# Check if it's a negative number (e.g., -5, -3.14)
rest = s[1:] if s.startswith('--') else s[1:]
if s.startswith('--'):
rest = s[2:]
try:
float(rest)
return False # It's a negative number, not a flag
except ValueError:
return True # It's a flag
# Parse tool-specific arguments
i = 0
while i < len(tool_args):
arg = tool_args[i]
if arg.startswith('--'):
flag_key = arg[2:].replace('-', '_')
# Map flag name to variable name (use flag_key as fallback for unknown flags)
var_name = flag_to_var.get(flag_key, flag_key)
if i + 1 < len(tool_args) and not tool_args[i + 1].startswith('--'):
custom_args[var_name] = tool_args[i + 1]
i += 2
else:
custom_args[var_name] = True
# Handle --flag=value syntax
if '=' in arg:
flag_part, value = arg.split('=', 1)
flag_key = flag_part[2:].replace('-', '_')
var_name = flag_to_var.get(flag_key, flag_key)
custom_args[var_name] = value
i += 1
elif arg.startswith('-'):
flag_key = arg[1:].replace('-', '_')
var_name = flag_to_var.get(flag_key, flag_key)
if i + 1 < len(tool_args) and not tool_args[i + 1].startswith('-'):
custom_args[var_name] = tool_args[i + 1]
i += 2
else:
custom_args[var_name] = True
flag_key = arg[2:].replace('-', '_')
var_name = flag_to_var.get(flag_key, flag_key)
if i + 1 < len(tool_args) and not is_flag(tool_args[i + 1]):
custom_args[var_name] = tool_args[i + 1]
i += 2
else:
custom_args[var_name] = True
i += 1
elif arg.startswith('-') and is_flag(arg):
# Handle -f=value syntax
if '=' in arg:
flag_part, value = arg.split('=', 1)
flag_key = flag_part[1:].replace('-', '_')
var_name = flag_to_var.get(flag_key, flag_key)
custom_args[var_name] = value
i += 1
else:
flag_key = arg[1:].replace('-', '_')
var_name = flag_to_var.get(flag_key, flag_key)
if i + 1 < len(tool_args) and not is_flag(tool_args[i + 1]):
custom_args[var_name] = tool_args[i + 1]
i += 2
else:
custom_args[var_name] = True
i += 1
else:
i += 1
@ -365,52 +400,89 @@ echo "input" | {args.name}
def cmd_check(args):
"""Check dependencies for a tool (meta-tools)."""
"""Check dependencies for a tool (meta-tools and system packages)."""
from ..runner import check_dependencies
from ..tool import ToolStep
from ..system_deps import (
check_and_report, detect_package_manager, format_dep_status
)
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
print(f"Tool: {args.name}")
print()
has_issues = False
# Check if this is a meta-tool
has_tool_steps = any(isinstance(s, ToolStep) for s in tool.steps)
has_deps = bool(tool.dependencies)
has_sys_deps = bool(tool.system_dependencies)
if not has_tool_steps and not has_deps:
print(f"Tool '{args.name}' has no dependencies (not a meta-tool).")
# CmdForge Dependencies section
if has_deps or has_tool_steps:
print("CmdForge Dependencies:")
# Show declared dependencies
if tool.dependencies:
missing = check_dependencies(tool)
for dep in tool.dependencies:
if dep in missing:
print(f" \u2717 {dep} (not installed)")
else:
print(f" \u2713 {dep} (installed)")
# Show tool steps
if has_tool_steps:
for i, step in enumerate(tool.steps):
if isinstance(step, ToolStep):
# Check if already shown as explicit dependency
if step.tool not in (tool.dependencies or []):
from ..resolver import resolve_tool, ToolNotFoundError
try:
resolve_tool(step.tool)
print(f" \u2713 {step.tool} (step {i+1}, installed)")
except ToolNotFoundError:
print(f" \u2717 {step.tool} (step {i+1}, not installed)")
# Check which are missing
missing = check_dependencies(tool)
if missing:
has_issues = True
print()
print(f" Install missing tools with:")
print(f" cmdforge install {' '.join(missing)}")
print()
# System Dependencies section
if has_sys_deps:
print("System Dependencies:")
pkg_manager = detect_package_manager()
installed, missing = check_and_report(tool.system_dependencies)
for dep in tool.system_dependencies:
status = format_dep_status(dep, pkg_manager if dep in missing else None)
print(status)
if missing:
has_issues = True
print()
print(f" Install missing packages with:")
print(f" cmdforge system-deps {args.name} install")
print()
if not has_deps and not has_tool_steps and not has_sys_deps:
print("No dependencies declared.")
return 0
print(f"Checking dependencies for '{args.name}'...")
print()
# Show declared dependencies
if tool.dependencies:
print("Declared dependencies:")
for dep in tool.dependencies:
print(f" - {dep}")
print()
# Show tool steps
if has_tool_steps:
print("Tool steps (implicit dependencies):")
for i, step in enumerate(tool.steps):
if isinstance(step, ToolStep):
print(f" Step {i+1}: {step.tool}")
print()
# Check which are missing
missing = check_dependencies(tool)
if missing:
print(f"Missing dependencies ({len(missing)}):")
for dep in missing:
print(f" - {dep}")
print()
print("Install with:")
print(f" cmdforge install {' '.join(missing)}")
if has_issues:
return 1
else:
print("All dependencies are satisfied.")
print("All dependencies satisfied.")
return 0

View File

@ -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
)

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

View File

@ -467,7 +467,7 @@ class ToolResolver:
script = f"""#!/bin/bash
# CmdForge wrapper for '{owner}/{name}'
# Auto-generated - do not edit
exec {python_path} -m cmdforge.runner {owner}/{name} "$@"
exec "{python_path}" -m cmdforge.runner "{owner}/{name}" "$@"
"""
wrapper_path.write_text(script)

View File

@ -17,6 +17,24 @@ from .profiles import load_profile
MAX_TOOL_DEPTH = 10
def check_system_dependencies(tool: Tool) -> list:
"""
Check if system dependencies are satisfied.
Args:
tool: Tool to check system dependencies for
Returns:
List of missing SystemDependency objects
"""
from .system_deps import check_system_dep
if not tool.system_dependencies:
return []
return [d for d in tool.system_dependencies if not check_system_dep(d)]
def auto_install_dependencies(tool: Tool, verbose: bool = False) -> list[str]:
"""
Automatically install missing dependencies for a tool.
@ -155,7 +173,8 @@ def substitute_variables(template: str, variables: dict, warn_non_scalar: bool =
for name, value in variables.items():
if name == "settings":
continue # Already handled above
result = result.replace(f"{{{name}}}", str(value) if value else "")
# Use 'is not None' to preserve falsey values like 0 and False
result = result.replace(f"{{{name}}}", "" if value is None else str(value))
# Finally, restore escaped braces as single braces
result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}")
@ -236,7 +255,9 @@ def execute_code_step(
output_vars = [v.strip() for v in step.output_var.split(',')]
outputs = {}
for var in output_vars:
outputs[var] = str(local_vars.get(var, ""))
# Preserve original types for code→code workflows
# Template substitution will convert to string when needed
outputs[var] = local_vars.get(var)
return outputs, True
@ -388,6 +409,17 @@ def run_tool(
Returns:
Tuple of (output_text, exit_code)
"""
def _format_tool_ref(current_tool: Tool) -> str:
"""Best-effort tool reference for messages (owner/name if available)."""
if current_tool.path:
tool_dir = current_tool.path.parent
# Handle both global and local ~/.cmdforge/<owner>/<name> layouts
if tool_dir.parent.name == ".cmdforge":
return tool_dir.name
if tool_dir.parent.parent.name == ".cmdforge":
return f"{tool_dir.parent.name}/{tool_dir.name}"
return current_tool.name
# Initialize call stack
if _call_stack is None:
_call_stack = []
@ -412,6 +444,15 @@ def run_tool(
print(f"Or use --auto-install to install automatically", file=sys.stderr)
# Continue anyway - the actual step execution will fail with a better error
# Check system dependencies on first level only
if _depth == 0 and tool.system_dependencies:
missing_sys = check_system_dependencies(tool)
if missing_sys:
names = ", ".join(d.name for d in missing_sys)
print(f"Warning: Missing system packages: {names}", file=sys.stderr)
print(f"Run: cmdforge system-deps {_format_tool_ref(tool)} install", file=sys.stderr)
# Continue execution - let it fail naturally if dep is actually needed
# Initialize variables with input and arguments
variables = {"input": input_text}

313
src/cmdforge/system_deps.py Normal file
View File

@ -0,0 +1,313 @@
"""System dependency management.
Handles detection, checking, and installation of system-level package
dependencies (apt, brew, pacman, dnf) for CmdForge tools.
"""
import platform
import shutil
import subprocess
import sys
from typing import Optional, List, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .tool import SystemDependency
# Supported package managers in precedence order
SUPPORTED_MANAGERS = ["apt", "brew", "pacman", "dnf"]
def detect_os() -> str:
"""Detect operating system.
Returns:
'linux', 'darwin', or 'windows'
"""
system = platform.system().lower()
if system == "darwin":
return "darwin"
elif system == "windows":
return "windows"
return "linux"
def detect_package_manager() -> Optional[str]:
"""Detect the system's package manager.
Uses shutil.which for fast detection without subprocess overhead.
Returns:
Package manager name ('apt', 'brew', 'pacman', 'dnf') or None
"""
os_type = detect_os()
if os_type == "windows":
return None # Windows not supported for auto-install
# Manager binary names (apt uses apt-get for scripted installs)
manager_binaries = {
"apt": "apt-get",
"brew": "brew",
"pacman": "pacman",
"dnf": "dnf",
}
# On macOS, check brew first
if os_type == "darwin":
if shutil.which("brew"):
return "brew"
return None
# On Linux, check in order of preference
for manager, binary in manager_binaries.items():
if shutil.which(binary):
return manager
return None
def check_system_dep(dep: "SystemDependency") -> bool:
"""Check if a system dependency is installed.
A dependency is considered satisfied if ANY of its binaries exist.
Args:
dep: SystemDependency to check
Returns:
True if dependency is satisfied (at least one binary found)
"""
binaries = dep.get_binaries_to_check()
return any(shutil.which(b) is not None for b in binaries)
def get_binary_path(dep: "SystemDependency") -> Optional[str]:
"""Get the path to the first found binary for a dependency.
Args:
dep: SystemDependency to check
Returns:
Path to binary, or None if not found
"""
binaries = dep.get_binaries_to_check()
for b in binaries:
path = shutil.which(b)
if path:
return path
return None
def get_install_command(dep: "SystemDependency", pkg_manager: str) -> Optional[List[str]]:
"""Get the install command as a list (safe, no shell injection).
Args:
dep: SystemDependency to install
pkg_manager: Package manager to use ('apt', 'brew', 'pacman', 'dnf')
Returns:
Command as list of strings, or None if unsupported manager
"""
pkg_name = dep.get_package_name(pkg_manager)
commands = {
"apt": ["sudo", "apt-get", "install", "-y", pkg_name],
"brew": ["brew", "install", pkg_name],
"pacman": ["sudo", "pacman", "-S", "--noconfirm", pkg_name],
"dnf": ["sudo", "dnf", "install", "-y", pkg_name],
}
return commands.get(pkg_manager)
def get_install_command_string(dep: "SystemDependency", pkg_manager: str) -> str:
"""Get human-readable install command for display.
Args:
dep: SystemDependency to install
pkg_manager: Package manager to use
Returns:
Command as string for display to user
"""
cmd = get_install_command(dep, pkg_manager)
return " ".join(cmd) if cmd else f"# No install command for {pkg_manager}"
def install_system_dep(dep: "SystemDependency", pkg_manager: str) -> Tuple[bool, str]:
"""Install a system dependency.
Args:
dep: SystemDependency to install
pkg_manager: Package manager to use
Returns:
Tuple of (success, message)
"""
cmd = get_install_command(dep, pkg_manager)
if not cmd:
return False, f"No install command for package manager: {pkg_manager}"
try:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
# Verify installation worked
if check_system_dep(dep):
return True, f"Installed {dep.name}"
else:
return False, f"Installed but binaries not found"
else:
error = result.stderr.strip() or result.stdout.strip()
return False, f"Failed: {error[:200]}"
except FileNotFoundError:
return False, f"Package manager '{pkg_manager}' not found"
except PermissionError:
return False, "Permission denied (try running with sudo)"
except Exception as e:
return False, str(e)
def check_and_report(deps: List["SystemDependency"]) -> Tuple[List["SystemDependency"], List["SystemDependency"]]:
"""Check dependencies and return categorized lists.
Args:
deps: List of SystemDependency objects to check
Returns:
Tuple of (installed_deps, missing_deps)
"""
installed = []
missing = []
for dep in deps:
if check_system_dep(dep):
installed.append(dep)
else:
missing.append(dep)
return installed, missing
def format_dep_status(dep: "SystemDependency", pkg_manager: Optional[str] = None) -> str:
"""Format dependency status for display.
Args:
dep: SystemDependency to format
pkg_manager: Optional package manager for install hint
Returns:
Formatted status string
"""
if check_system_dep(dep):
path = get_binary_path(dep)
return f" \u2713 {dep.name} (found: {path})"
else:
binaries = dep.get_binaries_to_check()
line = f" \u2717 {dep.name} (not found"
if len(binaries) > 1:
line += f", provides: {', '.join(binaries)}"
line += ")"
if pkg_manager:
install_cmd = get_install_command_string(dep, pkg_manager)
line += f"\n Install: {install_cmd}"
return line
def install_missing_deps(
deps: List["SystemDependency"],
pkg_manager: str,
callback=None
) -> List[Tuple["SystemDependency", bool, str]]:
"""Install multiple missing dependencies.
Args:
deps: List of SystemDependency objects to install
pkg_manager: Package manager to use
callback: Optional callback(dep, success, message) called after each install
Returns:
List of tuples: (dep, success, message)
"""
results = []
for dep in deps:
success, msg = install_system_dep(dep, pkg_manager)
results.append((dep, success, msg))
if callback:
callback(dep, success, msg)
return results
def prompt_install_missing(
deps: List["SystemDependency"],
tool_ref: str,
auto_yes: bool = False
) -> None:
"""Prompt to install missing system dependencies for a tool.
Args:
deps: List of system dependencies.
tool_ref: Tool reference string for messages (owner/name or name).
auto_yes: If True, install without prompting.
"""
installed, missing = check_and_report(deps)
if not missing:
return
print(f"Tool '{tool_ref}' requires system packages:")
pkg_manager = detect_package_manager()
for dep in missing:
desc = f" - {dep.description}" if dep.description else ""
print(f" \u2717 {dep.name}{desc}")
if pkg_manager:
print(f" Install: {get_install_command_string(dep, pkg_manager)}")
print()
os_type = detect_os()
if os_type == "windows":
print("Note: Automatic installation not supported on Windows.")
print("Please install the packages manually.")
return
if not pkg_manager:
print("Warning: No supported package manager found.")
print("Please install the packages manually.")
return
if not auto_yes and not sys.stdin.isatty():
print("Skipping system package install (non-interactive).")
print(f"Install later with: cmdforge system-deps {tool_ref} install")
return
if auto_yes:
do_install = True
else:
try:
confirm = input(f"Install {len(missing)} missing package(s)? [Y/n] ")
do_install = confirm.lower() in ('', 'y', 'yes')
except (EOFError, KeyboardInterrupt):
print("\nSkipping system package installation.")
return
if not do_install:
print(f"Skipped. Install later with: cmdforge system-deps {tool_ref} install")
return
print()
succeeded = 0
failed = 0
for dep in missing:
print(f"Installing {dep.name}...")
success, msg = install_system_dep(dep, pkg_manager)
if success:
print(f" \u2713 {msg}")
succeeded += 1
else:
print(f" \u2717 {msg}")
failed += 1
print()
if failed:
print(f"Installed {succeeded}/{len(missing)} packages.")
print("Tool installed but may not work correctly without missing packages.")
else:
print(f"All {succeeded} system package(s) installed.")

View File

@ -17,6 +17,54 @@ TOOLS_DIR = Path.home() / ".cmdforge"
BIN_DIR = Path.home() / ".local" / "bin"
@dataclass
class SystemDependency:
"""A system-level package dependency (apt, brew, pacman, etc.)."""
name: str
description: str = ""
binaries: List[str] = field(default_factory=list) # Executables to check for
packages: dict = field(default_factory=dict) # {apt: pkg, brew: pkg, ...}
_original_format: str = field(default="long", repr=False) # Track for round-trip serialization
def to_dict(self):
"""Serialize to dict or string (short form)."""
# Short form: if only name is set, return just the string
if self._original_format == "short" or (
not self.description and not self.binaries and not self.packages
):
return self.name
d = {"name": self.name}
if self.description:
d["description"] = self.description
if self.binaries:
d["binaries"] = self.binaries
if self.packages:
d["packages"] = self.packages
return d
@classmethod
def from_dict(cls, data) -> "SystemDependency":
"""Create from dict or string (short form)."""
if isinstance(data, str):
return cls(name=data, _original_format="short")
return cls(
name=data.get("name", ""),
description=data.get("description", ""),
binaries=data.get("binaries", []),
packages=data.get("packages", {}),
_original_format="long"
)
def get_binaries_to_check(self) -> List[str]:
"""Return binaries to check, defaulting to [name]."""
return self.binaries if self.binaries else [self.name]
def get_package_name(self, pkg_manager: str) -> str:
"""Return package name for a manager, defaulting to self.name."""
return self.packages.get(pkg_manager, self.name)
@dataclass
class ToolArgument:
"""Definition of a custom input argument."""
@ -197,6 +245,7 @@ class Tool:
steps: List[Step] = field(default_factory=list)
output: str = "{input}" # Output template
dependencies: List[str] = field(default_factory=list) # Required tools for meta-tools
system_dependencies: List[SystemDependency] = field(default_factory=list) # System packages (apt, brew, etc.)
source: Optional[ToolSource] = None # Attribution for imported/external tools
version: str = "" # Tool version
path: Optional[Path] = None # Path to config.yaml (set by load_tool)
@ -231,6 +280,12 @@ class Tool:
dependencies.append(dep["name"])
# Skip invalid entries
# Parse system dependencies
raw_sys_deps = data.get("system_dependencies", [])
system_dependencies = []
for sd in raw_sys_deps:
system_dependencies.append(SystemDependency.from_dict(sd))
return cls(
name=data["name"],
description=data.get("description", ""),
@ -239,6 +294,7 @@ class Tool:
steps=steps,
output=data.get("output", "{input}"),
dependencies=dependencies,
system_dependencies=system_dependencies,
source=source,
version=data.get("version", ""),
)
@ -258,6 +314,8 @@ class Tool:
d["source"] = self.source.to_dict()
if self.dependencies:
d["dependencies"] = self.dependencies
if self.system_dependencies:
d["system_dependencies"] = [sd.to_dict() for sd in self.system_dependencies]
d["arguments"] = [arg.to_dict() for arg in self.arguments]
d["steps"] = [step.to_dict() for step in self.steps]
d["output"] = self.output
@ -271,9 +329,10 @@ class Tool:
for arg in self.arguments:
variables.append(arg.variable)
# Add step output variables
# Add step output variables (handle comma-separated output_var)
for step in self.steps:
variables.append(step.output_var)
for var in step.output_var.split(','):
variables.append(var.strip())
return variables
@ -478,7 +537,7 @@ def create_wrapper_script(name: str) -> Path:
script = f"""#!/bin/bash
# CmdForge wrapper for '{name}'
# Auto-generated - do not edit
exec {python_path} -m cmdforge.runner {name} "$@"
exec "{python_path}" -m cmdforge.runner "{name}" "$@"
"""
wrapper_path.write_text(script)

View File

@ -110,13 +110,14 @@ class TestCreateCommand:
captured = capsys.readouterr()
assert 'exists' in captured.err.lower() or 'exists' in captured.out.lower()
@pytest.mark.skip(reason="CLI does not yet validate tool names - potential enhancement")
def test_create_invalid_name(self, temp_tools_dir, capsys):
"""Invalid tool name should fail."""
with patch('sys.argv', ['cmdforge', 'create', 'invalid/name']):
result = main()
assert result != 0
captured = capsys.readouterr()
assert 'invalid' in captured.out.lower() or 'invalid' in captured.err.lower()
class TestDeleteCommand:

View File

@ -92,6 +92,21 @@ Line 3: {var1} again"""
result = substitute_variables("Count: {n}", {"n": 42})
assert result == "Count: 42"
def test_zero_value_preserved(self):
"""Zero should be preserved, not converted to empty string."""
result = substitute_variables("Count: {n}", {"n": 0})
assert result == "Count: 0"
def test_false_value_preserved(self):
"""False should be preserved, not converted to empty string."""
result = substitute_variables("Enabled: {flag}", {"flag": False})
assert result == "Enabled: False"
def test_true_value_preserved(self):
"""True should be preserved as 'True'."""
result = substitute_variables("Enabled: {flag}", {"flag": True})
assert result == "Enabled: True"
class TestExecutePromptStep:
"""Tests for prompt step execution."""
@ -179,9 +194,10 @@ class TestExecuteCodeStep:
outputs, success = execute_code_step(step, variables)
assert success is True
assert outputs["a"] == "1"
assert outputs["b"] == "2"
assert outputs["c"] == "3"
# Code step outputs preserve original types (integers here)
assert outputs["a"] == 1
assert outputs["b"] == 2
assert outputs["c"] == 3
def test_code_uses_variables(self):
step = CodeStep(
@ -242,7 +258,43 @@ class TestExecuteCodeStep:
outputs, success = execute_code_step(step, variables)
assert success is True
assert outputs["result"] == "3"
# Code step outputs preserve original types (integer here)
assert outputs["result"] == 3
def test_code_preserves_complex_types(self):
"""Code step outputs preserve lists/dicts for code→code workflows."""
step = CodeStep(
code="data = [1, 2, 3]\ninfo = {'key': 'value'}",
output_var="data, info"
)
variables = {"input": ""}
outputs, success = execute_code_step(step, variables)
assert success is True
assert outputs["data"] == [1, 2, 3]
assert outputs["info"] == {"key": "value"}
def test_code_to_code_workflow(self):
"""Test that complex types can flow between code steps."""
# First step creates a list
step1 = CodeStep(
code="numbers = [1, 2, 3, 4, 5]",
output_var="numbers"
)
variables = {"input": ""}
outputs1, _ = execute_code_step(step1, variables)
variables.update(outputs1)
# Second step uses the list directly (no re-parsing needed)
step2 = CodeStep(
code="total = sum(numbers)",
output_var="total"
)
outputs2, success = execute_code_step(step2, variables)
assert success is True
assert outputs2["total"] == 15
class TestRunTool:

310
tests/test_system_deps.py Normal file
View File

@ -0,0 +1,310 @@
"""Tests for system dependency management."""
import shutil
from unittest.mock import patch, MagicMock
import pytest
from cmdforge.tool import SystemDependency, Tool
from cmdforge.system_deps import (
detect_os, detect_package_manager, check_system_dep,
get_install_command, get_install_command_string,
check_and_report, get_binary_path
)
class TestSystemDependencyDataclass:
"""Tests for SystemDependency dataclass."""
def test_short_form_roundtrip(self):
"""Short form 'ffmpeg' serializes back to string."""
dep = SystemDependency.from_dict("ffmpeg")
assert dep.name == "ffmpeg"
assert dep.to_dict() == "ffmpeg"
def test_long_form_roundtrip(self):
"""Long form with all fields serializes correctly."""
data = {
"name": "ffmpeg",
"description": "Audio/video processing",
"binaries": ["ffplay", "ffmpeg"],
"packages": {"apt": "ffmpeg", "brew": "ffmpeg"}
}
dep = SystemDependency.from_dict(data)
result = dep.to_dict()
assert result["name"] == "ffmpeg"
assert result["description"] == "Audio/video processing"
assert result["binaries"] == ["ffplay", "ffmpeg"]
assert result["packages"]["apt"] == "ffmpeg"
def test_long_form_minimal_becomes_short(self):
"""Long form with only name becomes short form on serialize."""
data = {"name": "curl"}
dep = SystemDependency.from_dict(data)
# Empty fields should serialize to short form
dep._original_format = "short"
assert dep.to_dict() == "curl"
def test_get_binaries_to_check_default(self):
"""get_binaries_to_check returns [name] when binaries is empty."""
dep = SystemDependency(name="curl")
assert dep.get_binaries_to_check() == ["curl"]
def test_get_binaries_to_check_specified(self):
"""get_binaries_to_check returns specified binaries."""
dep = SystemDependency(name="ffmpeg", binaries=["ffplay", "ffmpeg"])
assert dep.get_binaries_to_check() == ["ffplay", "ffmpeg"]
def test_get_package_name_default(self):
"""get_package_name returns name when no override."""
dep = SystemDependency(name="ffmpeg")
assert dep.get_package_name("apt") == "ffmpeg"
assert dep.get_package_name("brew") == "ffmpeg"
def test_get_package_name_override(self):
"""get_package_name returns override when specified."""
dep = SystemDependency(name="ffmpeg", packages={"brew": "ffmpeg-headless"})
assert dep.get_package_name("apt") == "ffmpeg" # Default
assert dep.get_package_name("brew") == "ffmpeg-headless" # Override
class TestPlatformDetection:
"""Tests for platform detection functions."""
@patch('platform.system')
def test_detect_os_linux(self, mock_system):
"""detect_os returns 'linux' for Linux."""
mock_system.return_value = "Linux"
assert detect_os() == "linux"
@patch('platform.system')
def test_detect_os_darwin(self, mock_system):
"""detect_os returns 'darwin' for macOS."""
mock_system.return_value = "Darwin"
assert detect_os() == "darwin"
@patch('platform.system')
def test_detect_os_windows(self, mock_system):
"""detect_os returns 'windows' for Windows."""
mock_system.return_value = "Windows"
assert detect_os() == "windows"
@patch('cmdforge.system_deps.detect_os')
@patch('shutil.which')
def test_detect_package_manager_apt(self, mock_which, mock_os):
"""detect_package_manager finds apt on Linux."""
mock_os.return_value = "linux"
mock_which.side_effect = lambda x: "/usr/bin/apt-get" if x == "apt-get" else None
assert detect_package_manager() == "apt"
@patch('cmdforge.system_deps.detect_os')
@patch('shutil.which')
def test_detect_package_manager_brew_macos(self, mock_which, mock_os):
"""detect_package_manager finds brew on macOS."""
mock_os.return_value = "darwin"
mock_which.side_effect = lambda x: "/usr/local/bin/brew" if x == "brew" else None
assert detect_package_manager() == "brew"
@patch('cmdforge.system_deps.detect_os')
def test_detect_package_manager_windows_returns_none(self, mock_os):
"""detect_package_manager returns None on Windows."""
mock_os.return_value = "windows"
assert detect_package_manager() is None
class TestDependencyChecking:
"""Tests for dependency checking functions."""
@patch('shutil.which')
def test_check_system_dep_found(self, mock_which):
"""check_system_dep returns True when binary exists."""
mock_which.return_value = "/usr/bin/curl"
dep = SystemDependency(name="curl")
assert check_system_dep(dep) is True
@patch('shutil.which')
def test_check_system_dep_not_found(self, mock_which):
"""check_system_dep returns False when binary not found."""
mock_which.return_value = None
dep = SystemDependency(name="nonexistent-pkg")
assert check_system_dep(dep) is False
@patch('shutil.which')
def test_check_system_dep_any_binary_satisfied(self, mock_which):
"""Dep is satisfied if ANY binary is found."""
def which_side_effect(binary):
return "/usr/bin/ffplay" if binary == "ffplay" else None
mock_which.side_effect = which_side_effect
dep = SystemDependency(name="ffmpeg", binaries=["ffplay", "ffmpeg"])
assert check_system_dep(dep) is True
@patch('shutil.which')
def test_check_and_report(self, mock_which):
"""check_and_report categorizes deps correctly."""
def which_side_effect(binary):
if binary in ["curl", "git"]:
return f"/usr/bin/{binary}"
return None
mock_which.side_effect = which_side_effect
deps = [
SystemDependency(name="curl"),
SystemDependency(name="git"),
SystemDependency(name="nonexistent"),
]
installed, missing = check_and_report(deps)
assert len(installed) == 2
assert len(missing) == 1
assert missing[0].name == "nonexistent"
class TestInstallCommands:
"""Tests for install command generation."""
def test_get_install_command_apt(self):
"""get_install_command returns correct apt command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "apt")
assert cmd == ["sudo", "apt-get", "install", "-y", "ffmpeg"]
def test_get_install_command_brew(self):
"""get_install_command returns correct brew command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "brew")
assert cmd == ["brew", "install", "ffmpeg"]
def test_get_install_command_pacman(self):
"""get_install_command returns correct pacman command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "pacman")
assert cmd == ["sudo", "pacman", "-S", "--noconfirm", "ffmpeg"]
def test_get_install_command_dnf(self):
"""get_install_command returns correct dnf command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "dnf")
assert cmd == ["sudo", "dnf", "install", "-y", "ffmpeg"]
def test_get_install_command_unknown_manager(self):
"""get_install_command returns None for unknown manager."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "unknown")
assert cmd is None
def test_install_command_no_shell_injection(self):
"""Install command uses list, no shell injection possible."""
# Even malicious input is safe because subprocess with list doesn't use shell
dep = SystemDependency(name="test; rm -rf /")
cmd = get_install_command(dep, "apt")
assert cmd == ["sudo", "apt-get", "install", "-y", "test; rm -rf /"]
# This is safe - the semicolon is passed as literal arg, not interpreted
def test_get_install_command_string(self):
"""get_install_command_string returns readable string."""
dep = SystemDependency(name="ffmpeg")
cmd_str = get_install_command_string(dep, "apt")
assert cmd_str == "sudo apt-get install -y ffmpeg"
class TestToolIntegration:
"""Tests for Tool integration with system dependencies."""
def test_tool_with_system_dependencies_loads(self):
"""Tool config with system_dependencies parses correctly."""
data = {
"name": "test-tool",
"description": "Test",
"system_dependencies": [
"curl",
{
"name": "ffmpeg",
"description": "Audio processing",
"binaries": ["ffplay"],
}
],
"steps": [],
"output": "{input}"
}
tool = Tool.from_dict(data)
assert len(tool.system_dependencies) == 2
assert tool.system_dependencies[0].name == "curl"
assert tool.system_dependencies[1].name == "ffmpeg"
assert tool.system_dependencies[1].binaries == ["ffplay"]
def test_tool_to_dict_includes_system_dependencies(self):
"""Tool.to_dict includes system_dependencies when present."""
tool = Tool(
name="test-tool",
description="Test",
system_dependencies=[
SystemDependency(name="curl", _original_format="short"),
SystemDependency(
name="ffmpeg",
description="Audio",
binaries=["ffplay"],
_original_format="long"
)
]
)
data = tool.to_dict()
assert "system_dependencies" in data
assert len(data["system_dependencies"]) == 2
assert data["system_dependencies"][0] == "curl" # Short form
assert data["system_dependencies"][1]["name"] == "ffmpeg" # Long form
def test_tool_without_system_dependencies(self):
"""Tool without system_dependencies works correctly."""
data = {
"name": "simple-tool",
"description": "Test",
"steps": [],
"output": "{input}"
}
tool = Tool.from_dict(data)
assert tool.system_dependencies == []
# to_dict should not include empty system_dependencies
result = tool.to_dict()
assert "system_dependencies" not in result
class TestRunnerIntegration:
"""Tests for runner.py integration."""
@patch('cmdforge.system_deps.check_system_dep')
def test_check_system_dependencies_function(self, mock_check):
"""check_system_dependencies returns missing deps."""
from cmdforge.runner import check_system_dependencies
mock_check.side_effect = lambda d: d.name != "missing-pkg"
tool = Tool(
name="test-tool",
system_dependencies=[
SystemDependency(name="installed-pkg"),
SystemDependency(name="missing-pkg"),
]
)
missing = check_system_dependencies(tool)
assert len(missing) == 1
assert missing[0].name == "missing-pkg"
def test_check_system_dependencies_empty(self):
"""check_system_dependencies returns empty for no deps."""
from cmdforge.runner import check_system_dependencies
tool = Tool(name="test-tool")
missing = check_system_dependencies(tool)
assert missing == []