Add system dependencies support for tools

Tools can now declare system-level package dependencies (apt, brew, pacman, dnf)
that get checked and optionally installed when the tool is installed or run.

Features:
- SystemDependency dataclass with short form (string) and long form (dict)
- New system_deps.py module with platform detection and installation
- `cmdforge system-deps <tool>` command to check/install system packages
- `cmdforge check` now shows both CmdForge and system dependencies
- `cmdforge registry install` prompts for system dep installation
- GUI: System Dependencies section in Tool Builder with add/edit dialog
- Runner warns about missing system deps before execution
- Integration with project install (manifest and lock-based)

Also includes:
- Quote paths in wrapper scripts for spaces support
- Tests for value type preservation in code steps
- Unskip invalid tool name test

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