CmdForge/src/cmdforge/runner.py

1198 lines
40 KiB
Python

"""Tool execution engine."""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Optional
import yaml
from .tool import Tool, PromptStep, CodeStep, ToolStep, McpStep
from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
from .manifest import load_manifest
from .profiles import load_profile
from .mcp_client import McpClientManager
# Maximum recursion depth for nested tool calls
MAX_TOOL_DEPTH = 10
# Default schema for structured output
# Separates AI reasoning from actual output
DEFAULT_OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"output": {
"type": "string",
"description": "The actual response content"
},
"reasoning": {
"type": "string",
"description": "Internal thoughts, analysis, or step-by-step reasoning"
}
},
"required": ["output"]
}
class SchemaValidationError(Exception):
"""Raised when response doesn't match expected schema."""
pass
def validate_schema(data: dict, schema: dict) -> None:
"""
Validate data against a JSON schema.
Args:
data: Parsed JSON data
schema: JSON schema to validate against
Raises:
SchemaValidationError: If validation fails
"""
try:
from jsonschema import validate, ValidationError
validate(instance=data, schema=schema)
except ValidationError as e:
# Extract path info for clearer error
path = ".".join(str(p) for p in e.absolute_path) if e.absolute_path else "root"
raise SchemaValidationError(f"Field '{path}': {e.message}")
except ImportError:
# jsonschema not installed - skip validation with warning
print("Warning: jsonschema not installed, skipping schema validation", file=sys.stderr)
def generate_schema_example(schema: dict) -> str:
"""
Generate a simple example JSON from a schema.
Small models work better with concrete examples than abstract JSON schemas.
Args:
schema: JSON schema to generate example from
Returns:
Example JSON string
"""
def build_example(schema_part: dict) -> any:
"""Recursively build example from schema."""
type_ = schema_part.get("type", "string")
if type_ == "string":
if "enum" in schema_part:
return schema_part["enum"][0]
desc = schema_part.get("description", "value")
return f"<{desc}>"
elif type_ == "number":
return 0.0
elif type_ == "integer":
return 0
elif type_ == "boolean":
return True
elif type_ == "array":
items = schema_part.get("items", {})
if items.get("type") == "object" and "properties" in items:
# Nested object array - generate one example item
return [build_example(items)]
elif items.get("type") == "string":
return ["<item1>", "<item2>"]
else:
return []
elif type_ == "object":
if "properties" in schema_part:
obj = {}
for key, spec in schema_part["properties"].items():
obj[key] = build_example(spec)
return obj
return {"...": "..."}
else:
return f"<{type_}>"
# Handle top-level schema
if schema.get("type") == "array":
example = build_example(schema)
elif schema.get("type") == "object" or "properties" in schema:
example = {}
for key, spec in schema.get("properties", {}).items():
example[key] = build_example(spec)
else:
example = build_example(schema)
return json.dumps(example, indent=2)
def prepend_schema_instructions(prompt: str, schema: dict) -> str:
"""
Prepend schema instructions to a prompt using example-based format.
Small models work better when format instructions come FIRST and use
concrete examples rather than abstract JSON schemas. This prevents
models from echoing the schema as content.
Args:
prompt: Original prompt text
schema: JSON schema the response must match
Returns:
Augmented prompt with schema instructions prepended
"""
example = generate_schema_example(schema)
# Build field guidance based on schema
props = schema.get("properties", {})
required = schema.get("required", [])
field_notes = []
if "reasoning" in props and "output" in props:
field_notes.append("Put your thinking in 'reasoning' and your answer in 'output'.")
if required:
field_notes.append(f"Required: {', '.join(required)}.")
field_guidance = " ".join(field_notes)
return f"""Respond with ONLY valid JSON in this format:
{example}
No text before or after the JSON. No markdown fences.{' ' + field_guidance if field_guidance else ''}
---
{prompt}"""
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.
Args:
tool: Tool to check and install dependencies for
verbose: Show installation progress
Returns:
List of successfully installed tool references
"""
missing = check_dependencies(tool)
if not missing:
return []
installed = []
for dep in missing:
try:
if verbose:
print(f"[auto-install] Installing {dep}...", file=sys.stderr)
install_from_registry(dep)
installed.append(dep)
if verbose:
print(f"[auto-install] Installed {dep}", file=sys.stderr)
except Exception as e:
print(f"Warning: Failed to auto-install {dep}: {e}", file=sys.stderr)
return installed
def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
"""
Check if all dependencies for a tool are available.
Args:
tool: Tool to check dependencies for
checked: Set of already checked tools (prevents infinite loops)
Returns:
List of missing dependency tool references
"""
if checked is None:
checked = set()
missing = []
# Check explicit dependencies
for dep in tool.dependencies:
if dep in checked:
continue
checked.add(dep)
try:
resolved = resolve_tool(dep)
# Recursively check nested dependencies
nested_missing = check_dependencies(resolved.tool, checked)
missing.extend(nested_missing)
except ToolNotFoundError:
missing.append(dep)
# Also check tool steps for implicit dependencies
for step in tool.steps:
if isinstance(step, ToolStep):
tool_ref = step.tool
if tool_ref in checked:
continue
checked.add(tool_ref)
try:
resolved = resolve_tool(tool_ref)
# Recursively check nested dependencies
nested_missing = check_dependencies(resolved.tool, checked)
missing.extend(nested_missing)
except ToolNotFoundError:
missing.append(tool_ref)
return missing
def _get_nested_value(obj, path_parts: list):
"""
Navigate nested dict/JSON to get a value.
Args:
obj: Starting object (dict or JSON string)
path_parts: List of keys to navigate
Returns:
Value at path, or None if not found
"""
value = obj
for part in path_parts:
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, str):
# Try parsing as JSON
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
value = parsed.get(part)
else:
return None
except json.JSONDecodeError:
return None
else:
return None
if value is None:
return None
return value
def substitute_variables(template: str, variables: dict, warn_non_scalar: bool = False) -> str:
"""
Substitute {variable} placeholders in a template.
Supports:
- Simple: {varname}
- Nested: {varname.field} or {varname.field.subfield}
- Settings: {settings.key}
- Escaping: {{ for literal { and }} for literal }
For nested access, if the variable is a JSON string, it will be parsed.
Args:
template: String with {var} placeholders
variables: Dict of variable name -> value
Returns:
String with placeholders replaced
Examples:
>>> substitute_variables("Hello {name}", {"name": "World"})
'Hello World'
>>> substitute_variables("Use {{braces}}", {"braces": "nope"})
'Use {braces}'
>>> substitute_variables("Backend: {settings.backend}", {"settings": {"backend": "piper"}})
'Backend: piper'
>>> substitute_variables("Answer: {result.output}", {"result": '{"output": "Hello"}'})
'Answer: Hello'
"""
import re
# Use unique placeholders for escaped braces
ESCAPE_OPEN = "\x00\x01OPEN\x01\x00"
ESCAPE_CLOSE = "\x00\x01CLOSE\x01\x00"
# First, replace escaped braces with placeholders
result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE)
# Pattern for {var} or {var.field.subfield}
pattern = r'\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}'
def replace_var(match):
path = match.group(1)
parts = path.split('.')
# Get base variable
base_name = parts[0]
# Check if variable exists in dict (distinguish None value from missing key)
if base_name not in variables:
return match.group(0) # Leave unchanged for missing variables
base_value = variables[base_name]
# If no nested parts, simple substitution
if len(parts) == 1:
if base_name == "settings":
# Settings dict - leave unchanged (handled specially)
return match.group(0)
if isinstance(base_value, (dict, list)):
return json.dumps(base_value)
# Replace with empty string for None, str() for other scalar values
return "" if base_value is None else str(base_value)
# Handle nested access
if base_name == "settings":
# Settings dict - only allow scalar values in templates
settings = base_value if isinstance(base_value, dict) else {}
key = parts[1] # Only support one level: settings.key
if key in settings:
value = settings[key]
if isinstance(value, (str, int, float, bool)):
return str(value) if value is not None else ""
else:
if warn_non_scalar:
print(
f"Warning: {{settings.{key}}} is not a scalar value, "
f"access it in code steps instead",
file=sys.stderr
)
return match.group(0)
return match.group(0)
# Navigate nested path for other variables
value = _get_nested_value(base_value, parts[1:])
if value is None:
return match.group(0) # Leave unchanged if not found
# Convert to string for template
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
result = re.sub(pattern, replace_var, result)
# Finally, restore escaped braces as single braces
result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}")
return result
def _extract_json(text: str) -> dict | list | None:
"""
Extract JSON from LLM response using multiple strategies.
LLMs often wrap JSON in markdown, add explanations before/after,
or include other text. This function tries several approaches
to find and extract valid JSON.
Returns parsed JSON (dict or list) or None if extraction fails.
"""
if not text:
return None
text = text.strip()
# Strategy 1: Direct parse (already clean JSON)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Strategy 2: Strip markdown code fences
if text.startswith("```"):
cleaned = re.sub(r'^```\w*\n?', '', text)
cleaned = re.sub(r'\n?```\s*$', '', cleaned)
try:
return json.loads(cleaned.strip())
except json.JSONDecodeError:
pass
# Strategy 3: Find first { or [ and match to last } or ]
# This handles "Here's the JSON:\n{...}" or "{...}\nExplanation"
first_brace = text.find('{')
first_bracket = text.find('[')
if first_brace == -1 and first_bracket == -1:
return None
# Determine which comes first and what we're looking for
if first_bracket == -1 or (first_brace != -1 and first_brace < first_bracket):
start_char, end_char = '{', '}'
start_pos = first_brace
else:
start_char, end_char = '[', ']'
start_pos = first_bracket
# Find matching end by counting braces/brackets
depth = 0
end_pos = -1
in_string = False
escape_next = False
for i in range(start_pos, len(text)):
char = text[i]
if escape_next:
escape_next = False
continue
if char == '\\':
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if char == start_char:
depth += 1
elif char == end_char:
depth -= 1
if depth == 0:
end_pos = i
break
if end_pos != -1:
candidate = text[start_pos:end_pos + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
pass
return None
def _read_step_file(filename: str, base_dir: Optional[Path], step_type: str) -> str:
"""Read a prompt/code file relative to the tool directory."""
if not base_dir:
raise ValueError(f"{step_type}_file requires the tool to have a filesystem path")
relative_path = Path(filename)
if relative_path.is_absolute():
raise ValueError(f"{step_type}_file must be relative to the tool directory")
base = base_dir.resolve()
path = (base / relative_path).resolve()
if path == base or base not in path.parents:
raise ValueError(f"{step_type}_file cannot reference files outside the tool directory")
if not path.is_file():
raise FileNotFoundError(f"{step_type}_file not found: {filename}")
return path.read_text()
def execute_prompt_step(
step: PromptStep,
variables: dict,
provider_override: str = None,
verbose: bool = False,
base_dir: Optional[Path] = None
) -> tuple[str, bool]:
"""
Execute a prompt step.
Args:
step: The prompt step to execute
variables: Current variable values
provider_override: Override the step's provider
Returns:
Tuple of (output_value, success)
If plain_text=True, returns raw provider output.
Otherwise, enforces structured JSON output with schema validation.
"""
import re
# Build prompt with variable substitution
try:
prompt_template = _read_step_file(step.prompt_file, base_dir, "prompt") if step.prompt_file else step.prompt
except (OSError, ValueError) as e:
print(f"Error in prompt step: {e}", file=sys.stderr)
return "", False
prompt = substitute_variables(prompt_template, variables, warn_non_scalar=verbose)
# Inject profile system prompt if specified
if step.profile:
profile = load_profile(step.profile)
if profile and profile.system_prompt:
# Prepend system prompt to user prompt
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
# Determine provider
provider = provider_override or substitute_variables(step.provider, variables, warn_non_scalar=verbose)
# Plain text mode - bypass structured output
if step.plain_text:
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, prompt, max_tokens=step.max_tokens)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
return "", False
text = result.text
if step.strip_fences:
text = re.sub(r'^```\w*\n', '', text.strip())
text = re.sub(r'\n```\s*$', '', text)
return text, True
# Structured output mode - enforce JSON with schema validation
schema = step.output_schema or DEFAULT_OUTPUT_SCHEMA
# Augment prompt with schema instructions (prepended for small model compatibility)
augmented_prompt = prepend_schema_instructions(prompt, schema)
max_attempts = step.max_retries + 1
last_response = None
last_error = None
for attempt in range(max_attempts):
current_prompt = augmented_prompt
if attempt > 0 and last_response:
# Retry with error feedback
current_prompt = f"""{augmented_prompt}
Your previous response was:
{last_response[:1000]}{"..." if len(last_response) > 1000 else ""}
This failed validation: {last_error}
Please try again with valid JSON matching the schema exactly."""
if verbose:
if attempt > 0:
print(f"[verbose] Retry {attempt}/{step.max_retries} after validation failure", file=sys.stderr)
# Call provider
if provider.lower() == "mock":
result = mock_provider(current_prompt)
else:
result = call_provider(provider, current_prompt, max_tokens=step.max_tokens)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
return "", False
last_response = result.text
# Extract and parse JSON with multiple strategies
parsed = _extract_json(result.text)
if parsed is None:
last_error = f"Invalid JSON: Could not extract valid JSON from response"
if attempt == max_attempts - 1:
print(f"Prompt step failed after {step.max_retries} retry(s): {last_error}", file=sys.stderr)
if verbose:
print(f"[verbose] Last response: {last_response[:500]}...", file=sys.stderr)
return "", False
continue
# Validate against schema
try:
validate_schema(parsed, schema)
except SchemaValidationError as e:
last_error = str(e)
if attempt == max_attempts - 1:
print(f"Prompt step failed after {step.max_retries} retry(s): {last_error}", file=sys.stderr)
if verbose:
print(f"[verbose] Parsed response: {json.dumps(parsed)[:500]}...", file=sys.stderr)
return "", False
continue
# Success - return parsed dict for code step compatibility
# Template substitution handles dicts via _get_nested_value
return parsed, True
# Should not reach here, but just in case
return "", False
def execute_code_step(
step: CodeStep,
variables: dict,
step_num: int = 0,
verbose: bool = False,
base_dir: Optional[Path] = None
) -> tuple[dict, bool]:
"""
Execute a code step.
Args:
step: The code step to execute
variables: Current variable values (available in code)
step_num: Step number for error reporting
Returns:
Tuple of (output_vars_dict, success)
"""
# Substitute variables in code (like {outputfile} -> actual value)
try:
code_template = _read_step_file(step.code_file, base_dir, "code") if step.code_file else step.code
except (OSError, ValueError) as e:
print(f"Error in code step (step {step_num}):", file=sys.stderr)
print(f" {e}", file=sys.stderr)
return {}, False
code = substitute_variables(code_template, variables, warn_non_scalar=verbose)
# Create execution environment with variables
# IMPORTANT: Use the same dict for both globals and locals.
# When exec() gets separate dicts, nested functions can't access
# module-level imports (they look in globals, not the exec's locals).
local_vars = {"__builtins__": __builtins__}
local_vars.update(variables)
try:
# Execute the code with substituted variables
exec(code, local_vars, local_vars)
# Support comma-separated output vars (e.g., "a, b, c")
output_vars = [v.strip() for v in step.output_var.split(',')]
outputs = {}
for var in output_vars:
# Preserve original types for code→code workflows
# Template substitution will convert to string when needed
outputs[var] = local_vars.get(var)
return outputs, True
except Exception as e:
import traceback
code_lines = code.split('\n')
# Extract line number from traceback
error_line = None
tb = traceback.extract_tb(e.__traceback__)
for frame in tb:
if frame.filename == '<string>': # exec'd code
error_line = frame.lineno
break
print(f"Error in code step (step {step_num}):", file=sys.stderr)
print(f" {type(e).__name__}: {e}", file=sys.stderr)
if error_line and 0 < error_line <= len(code_lines):
print(file=sys.stderr)
# Show context: line before, error line, line after
start = max(0, error_line - 2)
end = min(len(code_lines), error_line + 1)
for i in range(start, end):
marker = ">>>" if i == error_line - 1 else " "
print(f" {marker} {i+1}: {code_lines[i]}", file=sys.stderr)
print(file=sys.stderr)
print(f" Available variables: {list(variables.keys())}", file=sys.stderr)
return {}, False
def _print_call_stack(call_stack: list, error_msg: str) -> None:
"""Print the tool call stack for error reporting."""
if len(call_stack) > 1:
print("Error in tool chain:", file=sys.stderr)
for i, (name, step_num) in enumerate(call_stack):
indent = " " * i
arrow = "-> " if i > 0 else ""
step_info = f" (step {step_num})" if step_num else ""
print(f"{indent}{arrow}{name}{step_info}", file=sys.stderr)
print(f"{' ' * len(call_stack)}{error_msg}", file=sys.stderr)
else:
print(f"Error: {error_msg}", file=sys.stderr)
def execute_tool_step(
step: ToolStep,
variables: dict,
depth: int = 0,
provider_override: Optional[str] = None,
dry_run: bool = False,
verbose: bool = False,
call_stack: Optional[list] = None
) -> tuple[str, bool]:
"""
Execute a tool step by calling another tool.
Args:
step: The tool step to execute
variables: Current variable values
depth: Current recursion depth
provider_override: Override provider for nested calls
dry_run: Just show what would happen
verbose: Show debug info
call_stack: List of (tool_name, step_num) tuples for error reporting
Returns:
Tuple of (output_value, success)
"""
if call_stack is None:
call_stack = []
if depth >= MAX_TOOL_DEPTH:
_print_call_stack(call_stack, f"Maximum tool nesting depth ({MAX_TOOL_DEPTH}) exceeded")
return "", False
# Resolve the tool reference
try:
resolved = resolve_tool(step.tool)
nested_tool = resolved.tool
except ToolNotFoundError as e:
_print_call_stack(call_stack, f"Tool '{step.tool}' not found")
print(f"Hint: Install it with: cmdforge install {step.tool}", file=sys.stderr)
return "", False
# Prepare input by substituting variables
input_text = substitute_variables(step.input_template, variables, warn_non_scalar=verbose)
# Prepare arguments by substituting variables in arg values
custom_args = {}
for key, value in step.args.items():
custom_args[key] = substitute_variables(str(value), variables, warn_non_scalar=verbose)
# Determine effective provider (step override > parent override)
effective_provider = step.provider or provider_override
if verbose:
print(f"[verbose] Tool step: calling {step.tool}", file=sys.stderr)
print(f"[verbose] Input length: {len(input_text)} chars", file=sys.stderr)
print(f"[verbose] Args: {list(custom_args.keys())}", file=sys.stderr)
if dry_run:
return f"[DRY RUN - would call tool {step.tool}]", True
# Run the nested tool
output, exit_code = run_tool(
tool=nested_tool,
input_text=input_text,
custom_args=custom_args,
provider_override=effective_provider,
dry_run=dry_run,
show_prompt=False,
verbose=verbose,
_depth=depth + 1,
_call_stack=call_stack
)
return output, exit_code == 0
def run_tool(
tool: Tool,
input_text: str,
custom_args: dict,
provider_override: Optional[str] = None,
dry_run: bool = False,
show_prompt: bool = False,
verbose: bool = False,
auto_install: bool = False,
_depth: int = 0,
_call_stack: Optional[list] = None
) -> tuple[str, int]:
"""
Execute a tool.
Args:
tool: Tool definition
input_text: Input content
custom_args: Custom argument values
provider_override: Override all providers
dry_run: Just show what would happen
show_prompt: Show prompts in addition to output
verbose: Show debug info
auto_install: Automatically install missing dependencies
_depth: Internal recursion depth tracker
_call_stack: Internal call stack for error reporting
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 = []
current_stack = _call_stack + [(tool.name, None)]
# Check dependencies on first level only (not for nested calls)
if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)):
missing = check_dependencies(tool)
if missing:
if auto_install:
# Try to auto-install missing dependencies
installed = auto_install_dependencies(tool, verbose=verbose)
# Re-check after installation
still_missing = check_dependencies(tool)
if still_missing:
print(f"Warning: Could not install all dependencies. Missing: {', '.join(still_missing)}", file=sys.stderr)
elif installed:
print(f"Auto-installed dependencies: {', '.join(installed)}", file=sys.stderr)
else:
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr)
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}
# Add argument values (with defaults)
for arg in tool.arguments:
value = custom_args.get(arg.variable, arg.default)
variables[arg.variable] = value
# Load user settings if exists
settings = {}
if tool.path:
settings_path = tool.path.parent / "settings.yaml"
if settings_path.exists():
try:
settings = yaml.safe_load(settings_path.read_text()) or {}
except yaml.YAMLError:
print(f"Warning: Failed to load settings.yaml", file=sys.stderr)
settings = {}
variables["settings"] = settings
if verbose:
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
tool_base_dir = tool.path.parent if tool.path else None
# If no steps, just substitute output template
if not tool.steps:
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
return output, 0
# Reuse MCP configuration and schema caches across all MCP steps in this run.
mcp_manager = None
# Execute each step
for i, step in enumerate(tool.steps):
if verbose:
if isinstance(step, PromptStep):
step_type = "PROMPT"
elif isinstance(step, CodeStep):
step_type = "CODE"
elif isinstance(step, ToolStep):
step_type = f"TOOL({step.tool})"
elif isinstance(step, McpStep):
step_type = f"MCP({step.server}/{step.tool})"
else:
step_type = "UNKNOWN"
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
if isinstance(step, PromptStep):
# Show prompt if requested
if show_prompt or dry_run:
prompt = substitute_variables(step.prompt, variables, warn_non_scalar=verbose)
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr)
if dry_run:
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
else:
output, success = execute_prompt_step(
step,
variables,
provider_override,
verbose=verbose,
base_dir=tool_base_dir
)
if not success:
return "", 2
variables[step.output_var] = output
elif isinstance(step, CodeStep):
if verbose or dry_run:
print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(step.code, file=sys.stderr)
print("=== END CODE ===", file=sys.stderr)
if dry_run:
# Handle comma-separated output vars for dry run
for var in [v.strip() for v in step.output_var.split(',')]:
variables[var] = "[DRY RUN - would execute code]"
else:
outputs, success = execute_code_step(
step,
variables,
step_num=i+1,
verbose=verbose,
base_dir=tool_base_dir
)
if not success:
return "", 1
# Merge all output vars into variables
variables.update(outputs)
elif isinstance(step, ToolStep):
if verbose or dry_run:
print(f"=== TOOL (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(f" Calling: {step.tool}", file=sys.stderr)
print(f" Args: {step.args}", file=sys.stderr)
print("=== END TOOL ===", file=sys.stderr)
# Update call stack with current step number
step_stack = _call_stack + [(tool.name, i + 1)]
output, success = execute_tool_step(
step,
variables,
depth=_depth,
provider_override=provider_override,
dry_run=dry_run,
verbose=verbose,
call_stack=step_stack
)
if not success:
return "", 3
variables[step.output_var] = output
elif isinstance(step, McpStep):
if verbose or dry_run:
print(f"=== MCP (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(f" Server: {step.server} Tool: {step.tool}", file=sys.stderr)
print(f" Args: {step.arguments}", file=sys.stderr)
print("=== END MCP ===", file=sys.stderr)
if dry_run:
variables[step.output_var] = f"[DRY RUN - would call mcp:{step.server}/{step.tool}]"
else:
args = _substitute_mcp_args(step.arguments, variables)
try:
if mcp_manager is None:
mcp_manager = McpClientManager()
result = mcp_manager.call_tool(
step.server, step.tool, args,
result_mode=step.result_mode,
)
variables[step.output_var] = result
except ImportError as e:
print(f"{e}", file=sys.stderr)
return "", 4
except Exception as e:
print(f"MCP step failed (server={step.server}, tool={step.tool}): {e}", file=sys.stderr)
return "", 4
# Generate final output
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
return output, 0
def _substitute_mcp_args(arguments: dict, variables: dict) -> dict:
"""Substitute variables in MCP arguments, preserving types."""
result = {}
for key, value in arguments.items():
result[key] = _deep_substitute(value, variables)
return result
def _deep_substitute(value, variables: dict):
if isinstance(value, str):
import re
exact_reference = re.fullmatch(
r"\s*\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}\s*",
value,
)
if exact_reference:
path = exact_reference.group(1).split(".")
if path[0] in variables:
resolved = variables[path[0]]
found = True
for key in path[1:]:
if isinstance(resolved, dict) and key in resolved:
resolved = resolved[key]
elif hasattr(resolved, key):
resolved = getattr(resolved, key)
else:
found = False
break
if found:
return resolved
substituted = substitute_variables(value, variables)
return substituted if substituted != value else value
elif isinstance(value, dict):
return {k: _deep_substitute(v, variables) for k, v in value.items()}
elif isinstance(value, list):
return [_deep_substitute(v, variables) for v in value]
return value
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
"""
Create an argument parser for a tool.
Args:
tool: Tool definition
Returns:
Configured ArgumentParser
"""
parser = argparse.ArgumentParser(
prog=tool.name,
description=tool.description or f"CmdForge: {tool.name}"
)
# Universal flags
parser.add_argument("-i", "--input", dest="input_file",
help="Input file (reads from stdin if piped)")
parser.add_argument("--stdin", action="store_true",
help="Read input interactively from stdin (type then Ctrl+D)")
parser.add_argument("-o", "--output", dest="output_file",
help="Output file (writes to stdout if omitted)")
parser.add_argument("--dry-run", action="store_true",
help="Show what would happen without executing")
parser.add_argument("--show-prompt", action="store_true",
help="Show prompts in addition to output")
parser.add_argument("-p", "--provider",
help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information")
parser.add_argument("--auto-install", action="store_true",
help="Automatically install missing tool dependencies")
# Tool-specific flags from arguments
for arg in tool.arguments:
parser.add_argument(
arg.flag,
dest=arg.variable,
default=arg.default,
help=arg.description or f"{arg.variable} (default: {arg.default})"
)
return parser
def collect_custom_args(tool: Tool, args: argparse.Namespace) -> dict:
"""Collect tool-specific arguments from a parsed argparse namespace."""
custom_args = {}
for arg in tool.arguments:
value = getattr(args, arg.variable, None)
if value is not None:
custom_args[arg.variable] = value
return custom_args
def main():
"""Entry point for tool execution via wrapper script."""
if len(sys.argv) < 2:
print("Usage: python -m cmdforge.runner <tool_name> [args...]", file=sys.stderr)
sys.exit(1)
tool_spec = sys.argv[1]
# Resolve tool using new resolution order
try:
resolved = resolve_tool(tool_spec)
tool = resolved.tool
except ToolNotFoundError as e:
print(f"Error: Tool '{tool_spec}' not found", file=sys.stderr)
print(f"Searched: {', '.join(e.searched_paths[:3])}", file=sys.stderr)
sys.exit(1)
# Check for manifest overrides
manifest = load_manifest()
provider_override_from_manifest = None
if manifest:
override = manifest.get_override(tool_spec)
if override and override.provider:
provider_override_from_manifest = override.provider
# Parse remaining arguments
parser = create_argument_parser(tool)
args = parser.parse_args(sys.argv[2:])
# Read input
if args.input_file:
# Read from file
input_path = Path(args.input_file)
if not input_path.exists():
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args
custom_args = collect_custom_args(tool, args)
# Determine provider override (CLI flag takes precedence over manifest)
effective_provider = args.provider or provider_override_from_manifest
# Run tool
output, exit_code = run_tool(
tool=tool,
input_text=input_text,
custom_args=custom_args,
provider_override=effective_provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose,
auto_install=args.auto_install
)
# Write output
if exit_code == 0 and output:
if args.output_file:
Path(args.output_file).write_text(output)
else:
print(output)
sys.exit(exit_code)
if __name__ == "__main__":
main()