Add tool settings files for configurable tool defaults
Tools can now ship with defaults.yaml containing configurable settings
that users can customize via settings.yaml (auto-created from defaults).
Features:
- ensure_settings() helper copies defaults to settings on first use
- Settings available as {settings.key} in templates (scalars only)
- Full dict access via settings['key'] in code steps
- CLI: cmdforge settings <tool> show/edit/reset/diff
- GUI: Defaults editor in Tool Builder, Configure button on Tools page
- Registry: defaults.yaml published with tools, included in downloads
- Secret detection warning on publish (api_key, password, token, etc.)
Fully backward compatible - tools without defaults work unchanged.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
+68
-13
@@ -5,6 +5,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .tool import Tool, PromptStep, CodeStep, ToolStep
|
||||
from .providers import call_provider, mock_provider
|
||||
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
|
||||
@@ -94,12 +96,15 @@ def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
|
||||
return missing
|
||||
|
||||
|
||||
def substitute_variables(template: str, variables: dict) -> str:
|
||||
def substitute_variables(template: str, variables: dict, warn_non_scalar: bool = False) -> str:
|
||||
"""
|
||||
Substitute {variable} placeholders in a template.
|
||||
|
||||
Supports escaping: use {{ for literal { and }} for literal }
|
||||
|
||||
Also supports {settings.key} syntax for accessing top-level scalar
|
||||
values from the settings dict.
|
||||
|
||||
Args:
|
||||
template: String with {var} placeholders
|
||||
variables: Dict of variable name -> value
|
||||
@@ -112,7 +117,11 @@ def substitute_variables(template: str, variables: dict) -> str:
|
||||
'Hello World'
|
||||
>>> substitute_variables("Use {{braces}}", {"braces": "nope"})
|
||||
'Use {braces}'
|
||||
>>> substitute_variables("Backend: {settings.backend}", {"settings": {"backend": "piper"}})
|
||||
'Backend: piper'
|
||||
"""
|
||||
import re
|
||||
|
||||
# Use unique placeholders for escaped braces
|
||||
ESCAPE_OPEN = "\x00\x01OPEN\x01\x00"
|
||||
ESCAPE_CLOSE = "\x00\x01CLOSE\x01\x00"
|
||||
@@ -120,8 +129,32 @@ def substitute_variables(template: str, variables: dict) -> str:
|
||||
# First, replace escaped braces with placeholders
|
||||
result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE)
|
||||
|
||||
# Now do variable substitution
|
||||
# Handle settings.key syntax (top-level scalar values only)
|
||||
settings = variables.get("settings", {})
|
||||
if settings and isinstance(settings, dict):
|
||||
def replace_settings(match):
|
||||
key = match.group(1)
|
||||
if key in settings:
|
||||
value = settings[key]
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return str(value) if value is not None else ""
|
||||
else:
|
||||
# Non-scalar value - leave placeholder, warn if requested
|
||||
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 unchanged
|
||||
return match.group(0) # Key not found, leave unchanged
|
||||
|
||||
result = re.sub(r'\{settings\.([^}]+)\}', replace_settings, result)
|
||||
|
||||
# Now do regular variable substitution (skip 'settings' since we handled it)
|
||||
for name, value in variables.items():
|
||||
if name == "settings":
|
||||
continue # Already handled above
|
||||
result = result.replace(f"{{{name}}}", str(value) if value else "")
|
||||
|
||||
# Finally, restore escaped braces as single braces
|
||||
@@ -130,7 +163,12 @@ def substitute_variables(template: str, variables: dict) -> str:
|
||||
return result
|
||||
|
||||
|
||||
def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]:
|
||||
def execute_prompt_step(
|
||||
step: PromptStep,
|
||||
variables: dict,
|
||||
provider_override: str = None,
|
||||
verbose: bool = False
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
Execute a prompt step.
|
||||
|
||||
@@ -143,7 +181,7 @@ def execute_prompt_step(step: PromptStep, variables: dict, provider_override: st
|
||||
Tuple of (output_value, success)
|
||||
"""
|
||||
# Build prompt with variable substitution
|
||||
prompt = substitute_variables(step.prompt, variables)
|
||||
prompt = substitute_variables(step.prompt, variables, warn_non_scalar=verbose)
|
||||
|
||||
# Inject profile system prompt if specified
|
||||
if step.profile:
|
||||
@@ -167,7 +205,12 @@ def execute_prompt_step(step: PromptStep, variables: dict, provider_override: st
|
||||
return result.text, True
|
||||
|
||||
|
||||
def execute_code_step(step: CodeStep, variables: dict, step_num: int = 0) -> tuple[dict, bool]:
|
||||
def execute_code_step(
|
||||
step: CodeStep,
|
||||
variables: dict,
|
||||
step_num: int = 0,
|
||||
verbose: bool = False
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Execute a code step.
|
||||
|
||||
@@ -180,7 +223,7 @@ def execute_code_step(step: CodeStep, variables: dict, step_num: int = 0) -> tup
|
||||
Tuple of (output_vars_dict, success)
|
||||
"""
|
||||
# Substitute variables in code (like {outputfile} -> actual value)
|
||||
code = substitute_variables(step.code, variables)
|
||||
code = substitute_variables(step.code, variables, warn_non_scalar=verbose)
|
||||
|
||||
# Create execution environment with variables
|
||||
local_vars = dict(variables)
|
||||
@@ -281,12 +324,12 @@ def execute_tool_step(
|
||||
return "", False
|
||||
|
||||
# Prepare input by substituting variables
|
||||
input_text = substitute_variables(step.input_template, 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)
|
||||
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
|
||||
@@ -377,6 +420,18 @@ def run_tool(
|
||||
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)
|
||||
@@ -384,7 +439,7 @@ def run_tool(
|
||||
|
||||
# If no steps, just substitute output template
|
||||
if not tool.steps:
|
||||
output = substitute_variables(tool.output, variables)
|
||||
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
|
||||
return output, 0
|
||||
|
||||
# Execute each step
|
||||
@@ -403,7 +458,7 @@ def run_tool(
|
||||
if isinstance(step, PromptStep):
|
||||
# Show prompt if requested
|
||||
if show_prompt or dry_run:
|
||||
prompt = substitute_variables(step.prompt, variables)
|
||||
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)
|
||||
@@ -411,7 +466,7 @@ def run_tool(
|
||||
if dry_run:
|
||||
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
|
||||
else:
|
||||
output, success = execute_prompt_step(step, variables, provider_override)
|
||||
output, success = execute_prompt_step(step, variables, provider_override, verbose=verbose)
|
||||
if not success:
|
||||
return "", 2
|
||||
variables[step.output_var] = output
|
||||
@@ -427,7 +482,7 @@ def run_tool(
|
||||
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)
|
||||
outputs, success = execute_code_step(step, variables, step_num=i+1, verbose=verbose)
|
||||
if not success:
|
||||
return "", 1
|
||||
# Merge all output vars into variables
|
||||
@@ -456,7 +511,7 @@ def run_tool(
|
||||
variables[step.output_var] = output
|
||||
|
||||
# Generate final output
|
||||
output = substitute_variables(tool.output, variables)
|
||||
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
|
||||
|
||||
return output, 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user