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:
rob
2026-01-27 11:56:07 -04:00
co-authored by Claude Opus 4.5
parent ce94ba89dc
commit 8eff55ed1e
19 changed files with 894 additions and 33 deletions
+68 -13
View File
@@ -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