diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c103d7..614a624 100644 --- a/CHANGELOG.md +++ b/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 [install]` + - `cmdforge system-deps ` - Show system dependency status + - `cmdforge system-deps install` - Install missing system packages + - `--yes` flag for non-interactive installation + +- **Enhanced CLI commands**: + - `cmdforge check ` - 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) diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index cc903bf..c608afd 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -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 diff --git a/src/cmdforge/cli/project_commands.py b/src/cmdforge/cli/project_commands.py index eb876ee..834b2fb 100644 --- a/src/cmdforge/cli/project_commands.py +++ b/src/cmdforge/cli/project_commands.py @@ -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)) diff --git a/src/cmdforge/cli/registry_commands.py b/src/cmdforge/cli/registry_commands.py index b69c490..a3dc349 100644 --- a/src/cmdforge/cli/registry_commands.py +++ b/src/cmdforge/cli/registry_commands.py @@ -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) diff --git a/src/cmdforge/cli/system_deps_commands.py b/src/cmdforge/cli/system_deps_commands.py new file mode 100644 index 0000000..ef507da --- /dev/null +++ b/src/cmdforge/cli/system_deps_commands.py @@ -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 diff --git a/src/cmdforge/cli/tool_commands.py b/src/cmdforge/cli/tool_commands.py index 675b1f4..ce10945 100644 --- a/src/cmdforge/cli/tool_commands.py +++ b/src/cmdforge/cli/tool_commands.py @@ -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 diff --git a/src/cmdforge/gui/dialogs/system_dep_dialog.py b/src/cmdforge/gui/dialogs/system_dep_dialog.py new file mode 100644 index 0000000..75ef30f --- /dev/null +++ b/src/cmdforge/gui/dialogs/system_dep_dialog.py @@ -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 + ) diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index 47ab1da..d289103 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -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 diff --git a/src/cmdforge/resolver.py b/src/cmdforge/resolver.py index ed0594e..44dd023 100644 --- a/src/cmdforge/resolver.py +++ b/src/cmdforge/resolver.py @@ -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) diff --git a/src/cmdforge/runner.py b/src/cmdforge/runner.py index 96b2f4e..58df4b6 100644 --- a/src/cmdforge/runner.py +++ b/src/cmdforge/runner.py @@ -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// 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} diff --git a/src/cmdforge/system_deps.py b/src/cmdforge/system_deps.py new file mode 100644 index 0000000..eb740e7 --- /dev/null +++ b/src/cmdforge/system_deps.py @@ -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.") diff --git a/src/cmdforge/tool.py b/src/cmdforge/tool.py index fbb76a3..8ce5dc3 100644 --- a/src/cmdforge/tool.py +++ b/src/cmdforge/tool.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2739a06..8a62340 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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: diff --git a/tests/test_runner.py b/tests/test_runner.py index de9acf6..8794a9f 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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: diff --git a/tests/test_system_deps.py b/tests/test_system_deps.py new file mode 100644 index 0000000..651dcea --- /dev/null +++ b/tests/test_system_deps.py @@ -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 == []