"""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 ["", ""] 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) # Determine provider provider = provider_override or substitute_variables(step.provider, variables, warn_non_scalar=verbose) # Build context from the inside out so the final order is: # profile system prompt -> skills -> user prompt. if step.skills is not None: from .skills import inject_skills skill_names = None if "*" in step.skills else step.skills prompt = inject_skills(prompt, provider, skill_names) if step.profile: profile = load_profile(step.profile) if profile and profile.system_prompt: prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}" # Structured or plain-text # 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 == '': # 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// 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 [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()