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:
parent
8eff55ed1e
commit
071ade0ffb
43
CHANGELOG.md
43
CHANGELOG.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
Loading…
Reference in New Issue