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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-27 11:56:07 -04:00
parent ce94ba89dc
commit 8eff55ed1e
19 changed files with 894 additions and 33 deletions

View File

@ -6,6 +6,35 @@ All notable changes to CmdForge will be documented in this file.
### Added ### Added
#### Tool Settings Files
- **Configurable tool settings**: Tools can now ship with default settings that users can customize
- `defaults.yaml` - Default settings published with the tool (immutable)
- `settings.yaml` - User's local overrides (auto-created from defaults on first use)
- Settings available in templates as `{settings.key}` (top-level scalars only)
- Settings available in code steps as `settings['key']` (full dict access)
- **New CLI commands**:
- `cmdforge settings <tool> show` - View current settings
- `cmdforge settings <tool> edit` - Edit settings in $EDITOR
- `cmdforge settings <tool> reset` - Reset settings to defaults
- `cmdforge settings <tool> diff` - Show differences from defaults
- **GUI support**:
- Tool Builder: New "Defaults (Optional)" collapsible section for defining default settings
- Tools Page: "Configure" button for tools with settings
- Settings Dialog: Edit tool settings with defaults reference panel
- **Registry integration**:
- `defaults.yaml` is published alongside `config.yaml` and `README.md`
- `defaults` field added to registry API (publish, download, info endpoints)
- Size limit: 64KB for defaults content
- Secret detection warning on publish (warns about api_key, password, token, etc.)
- **Automatic settings creation**:
- `ensure_settings()` helper copies defaults.yaml to settings.yaml when missing
- Called on tool load, save, and registry install for consistency
- Fully backward compatible - tools without defaults work unchanged
#### Transitive Dependency Resolution #### Transitive Dependency Resolution
- **Full transitive dependency resolution**: When installing dependencies, CmdForge now resolves and installs the complete dependency tree - **Full transitive dependency resolution**: When installing dependencies, CmdForge now resolves and installs the complete dependency tree
- `DependencyGraph` and `DependencyNode` dataclasses for structured dependency tracking - `DependencyGraph` and `DependencyNode` dataclasses for structured dependency tracking

View File

@ -42,6 +42,8 @@ cmdforge
### Key Paths ### Key Paths
- **Tools storage**: `~/.cmdforge/<toolname>/config.yaml` - **Tools storage**: `~/.cmdforge/<toolname>/config.yaml`
- **Tool defaults**: `~/.cmdforge/<toolname>/defaults.yaml` (optional, published with tool)
- **Tool settings**: `~/.cmdforge/<toolname>/settings.yaml` (user overrides, auto-created from defaults)
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts) - **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
- **Provider config**: `~/.cmdforge/providers.yaml` - **Provider config**: `~/.cmdforge/providers.yaml`
- **Collections storage**: `~/.cmdforge/collections/<name>.yaml` - **Collections storage**: `~/.cmdforge/collections/<name>.yaml`
@ -65,8 +67,10 @@ Variables are passed between steps:
- `{input}` - always available (stdin/file content) - `{input}` - always available (stdin/file content)
- `{argname}` - from tool arguments - `{argname}` - from tool arguments
- `{step_output_var}` - from previous step's `output_var` - `{step_output_var}` - from previous step's `output_var`
- `{settings.key}` - from tool's settings.yaml (top-level scalars only in templates)
- `settings['key']` - full dict access in code steps
Variable substitution is simple string replacement in `runner.py:substitute_variables()`. Variable substitution is handled in `runner.py:substitute_variables()`. Settings are loaded from `settings.yaml` if it exists, otherwise an empty dict is used.
## Provider System ## Provider System

View File

@ -164,6 +164,12 @@ cmdforge add owner/tool # Add tool as dependency
cmdforge install # Install all dependencies cmdforge install # Install all dependencies
cmdforge deps # Check dependency status cmdforge deps # Check dependency status
# Tool Settings
cmdforge settings mytool show # View tool settings
cmdforge settings mytool edit # Edit in $EDITOR
cmdforge settings mytool reset # Reset to defaults
cmdforge settings mytool diff # Show changes from defaults
# Configuration # Configuration
cmdforge config show # Show current config cmdforge config show # Show current config
cmdforge config connect username # Connect to registry account cmdforge config connect username # Connect to registry account
@ -416,6 +422,54 @@ See [Meta-Tools Design](docs/reference/meta-tools.md) for full documentation.
| `{input}` | The piped/file input | | `{input}` | The piped/file input |
| `{argname}` | Custom argument value | | `{argname}` | Custom argument value |
| `{output_var}` | Output from previous step (prompt, code, or tool) | | `{output_var}` | Output from previous step (prompt, code, or tool) |
| `{settings.key}` | Value from tool's settings file (scalars only) |
### Tool Settings
Tools can ship with configurable settings that users can customize without editing the tool itself:
```
~/.cmdforge/tts/
├── config.yaml # Tool definition (published)
├── defaults.yaml # Default settings (published with tool)
├── settings.yaml # User's customized settings (auto-created, not published)
└── README.md # Documentation
```
**Creating a tool with settings:**
```yaml
# defaults.yaml - ships with your tool
backend: piper
endpoint: http://localhost:5001
api_key: '' # User fills in their own key
voice: default
```
**Using settings in steps:**
```yaml
# In a prompt step (top-level scalars only)
prompt: "Using {settings.backend} at {settings.endpoint}"
# In a code step (full access)
code: |
endpoint = settings.get('endpoint')
api_key = settings['api_key']
if not api_key:
raise ValueError("Please set api_key in settings")
```
**Managing settings via CLI:**
```bash
cmdforge settings tts show # View current settings
cmdforge settings tts edit # Edit in $EDITOR
cmdforge settings tts reset # Reset to defaults
cmdforge settings tts diff # Show changes from defaults
```
Settings are fully optional - tools without a `defaults.yaml` work exactly as before.
## Project Dependencies ## Project Dependencies

View File

@ -96,6 +96,40 @@ Steps execute in order. Each step's `output_var` becomes available to subsequent
- `{input}` - Always available, contains stdin or input file content (empty string if no input) - `{input}` - Always available, contains stdin or input file content (empty string if no input)
- `{variable_name}` - From arguments (e.g., `{max}`) - `{variable_name}` - From arguments (e.g., `{max}`)
- `{output_var}` - From previous steps (e.g., `{response}`, `{processed}`) - `{output_var}` - From previous steps (e.g., `{response}`, `{processed}`)
- `{settings.key}` - From tool's settings file (top-level scalars only in templates)
### Tool Settings
Tools can ship with configurable settings via `defaults.yaml`:
```yaml
# ~/.cmdforge/mytool/defaults.yaml
backend: piper
endpoint: http://localhost:5001
api_key: '' # User fills in
```
When a tool with `defaults.yaml` is first loaded, `settings.yaml` is auto-created as a copy. Users edit `settings.yaml` to customize; `defaults.yaml` remains unchanged.
**In templates** (scalars only):
```yaml
prompt: "Using {settings.backend} at {settings.endpoint}"
```
**In code steps** (full access):
```python
endpoint = settings.get('endpoint')
api_key = settings['api_key']
nested = settings['options']['timeout'] # Nested access works
```
**CLI commands:**
```bash
cmdforge settings mytool show # View settings
cmdforge settings mytool edit # Edit in $EDITOR
cmdforge settings mytool reset # Reset to defaults
cmdforge settings mytool diff # Show changes
```
### Output Variables ### Output Variables

View File

@ -14,6 +14,7 @@ from .registry_commands import cmd_registry
from .collections_commands import cmd_collections from .collections_commands import cmd_collections
from .project_commands import cmd_deps, cmd_deps_tree, cmd_install_deps, cmd_add, cmd_remove, cmd_init, cmd_lock, cmd_verify from .project_commands import cmd_deps, cmd_deps_tree, cmd_install_deps, cmd_add, cmd_remove, cmd_init, cmd_lock, cmd_verify
from .config_commands import cmd_config from .config_commands import cmd_config
from .settings_commands import cmd_settings
def main(): def main():
@ -182,6 +183,7 @@ def main():
p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry") p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry")
p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)") p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)")
p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing") p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing")
p_reg_publish.add_argument("-f", "--force", action="store_true", help="Skip confirmation prompts")
p_reg_publish.set_defaults(func=cmd_registry) p_reg_publish.set_defaults(func=cmd_registry)
# registry my-tools # registry my-tools
@ -368,6 +370,33 @@ def main():
# Default for config with no subcommand # Default for config with no subcommand
p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args))) p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args)))
# -------------------------------------------------------------------------
# Settings Commands
# -------------------------------------------------------------------------
p_settings = subparsers.add_parser("settings", help="Manage tool settings")
p_settings.add_argument("tool", help="Tool name")
settings_sub = p_settings.add_subparsers(dest="settings_cmd")
# settings show (default)
p_settings_show = settings_sub.add_parser("show", help="Show current settings")
p_settings_show.set_defaults(func=cmd_settings)
# settings edit
p_settings_edit = settings_sub.add_parser("edit", help="Edit settings in $EDITOR")
p_settings_edit.set_defaults(func=cmd_settings)
# settings reset
p_settings_reset = settings_sub.add_parser("reset", help="Reset settings to defaults")
p_settings_reset.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
p_settings_reset.set_defaults(func=cmd_settings)
# settings diff
p_settings_diff = settings_sub.add_parser("diff", help="Show differences from defaults")
p_settings_diff.set_defaults(func=cmd_settings)
# Default for settings with no subcommand
p_settings.set_defaults(func=cmd_settings)
args = parser.parse_args() args = parser.parse_args()
# If no command, launch UI # If no command, launch UI

View File

@ -654,13 +654,16 @@ def _publish_single_tool(tool_name: str, client) -> dict:
if not tool: if not tool:
return {"success": False, "error": f"Tool '{tool_name}' not found"} return {"success": False, "error": f"Tool '{tool_name}' not found"}
# Load README if exists # Load README/defaults if exists
readme_path = get_tools_dir() / tool_name / "README.md" tool_dir = tool.path.parent if tool.path else (get_tools_dir() / tool_name)
readme_path = tool_dir / "README.md"
readme = readme_path.read_text() if readme_path.exists() else "" readme = readme_path.read_text() if readme_path.exists() else ""
defaults_path = tool_dir / "defaults.yaml"
defaults = defaults_path.read_text() if defaults_path.exists() else ""
try: try:
config_yaml = yaml.safe_dump(tool.to_dict(), sort_keys=False) config_yaml = yaml.safe_dump(tool.to_dict(), sort_keys=False)
result = client.publish_tool(config_yaml, readme=readme, dry_run=False) result = client.publish_tool(config_yaml, readme=readme, defaults=defaults, dry_run=False)
# Update local tool config with registry metadata # Update local tool config with registry metadata
config_path = get_tools_dir() / tool_name / "config.yaml" config_path = get_tools_dir() / tool_name / "config.yaml"

View File

@ -1,6 +1,7 @@
"""Registry commands.""" """Registry commands."""
import json import json
import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -359,6 +360,32 @@ def _cmd_registry_publish(args):
readme_path = tool_path / "README.md" readme_path = tool_path / "README.md"
readme = readme_path.read_text() if readme_path.exists() else "" readme = readme_path.read_text() if readme_path.exists() else ""
# Read defaults if exists
defaults_path = tool_path / "defaults.yaml"
defaults = ""
if defaults_path.exists():
defaults = defaults_path.read_text()
# Warn about potential secrets in defaults
defaults_lower = defaults.lower()
secret_patterns = ['api_key:', 'api_secret:', 'password:', 'token:', 'secret:']
for pattern in secret_patterns:
if pattern in defaults_lower:
# Check if it has a non-empty value
match = re.search(rf'{pattern}\s*["\']?([^"\'\n]+)', defaults_lower)
if match and match.group(1).strip() and match.group(1).strip() not in ('""', "''", ''):
print(f"Warning: defaults.yaml contains '{pattern[:-1]}' with a value.")
print(" Make sure you're not publishing actual credentials!")
if not getattr(args, 'force', False):
try:
confirm = input("Continue anyway? [y/N] ")
if confirm.lower() != 'y':
return 1
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 1
break
# Validate # Validate
try: try:
data = yaml.safe_load(config_yaml) data = yaml.safe_load(config_yaml)
@ -408,7 +435,7 @@ def _cmd_registry_publish(args):
try: try:
client = get_client() client = get_client()
result = client.publish_tool(config_yaml, readme) result = client.publish_tool(config_yaml, readme, defaults)
pr_url = result.get("pr_url", "") pr_url = result.get("pr_url", "")
status = result.get("status", "") status = result.get("status", "")

View File

@ -0,0 +1,142 @@
"""Settings management commands."""
import os
import shutil
import subprocess
from pathlib import Path
import yaml
from ..tool import load_tool
def cmd_settings(args):
"""Manage tool settings."""
if not hasattr(args, 'settings_cmd') or not args.settings_cmd:
args.settings_cmd = "show"
if args.settings_cmd == "show":
return _cmd_settings_show(args)
elif args.settings_cmd == "edit":
return _cmd_settings_edit(args)
elif args.settings_cmd == "reset":
return _cmd_settings_reset(args)
elif args.settings_cmd == "diff":
return _cmd_settings_diff(args)
else:
print("Settings commands:")
print(" show Show current settings")
print(" edit Edit settings in $EDITOR")
print(" reset Reset settings to defaults")
print(" diff Show differences from defaults")
return 0
def _get_tool_paths(tool_name: str):
"""Get paths for tool's settings files."""
tool = load_tool(tool_name)
if not tool or not tool.path:
return None, None, None
tool_dir = tool.path.parent
return (
tool_dir / "defaults.yaml",
tool_dir / "settings.yaml",
tool_dir
)
def _cmd_settings_show(args):
"""Show current settings for a tool."""
defaults_path, settings_path, _ = _get_tool_paths(args.tool)
if not settings_path:
print(f"Tool '{args.tool}' not found")
return 1
if not settings_path.exists():
if defaults_path and defaults_path.exists():
print(f"No settings.yaml yet. Defaults from defaults.yaml:")
print(defaults_path.read_text())
else:
print(f"Tool '{args.tool}' has no configurable settings")
return 0
print(settings_path.read_text())
return 0
def _cmd_settings_edit(args):
"""Open settings in $EDITOR."""
defaults_path, settings_path, tool_dir = _get_tool_paths(args.tool)
if not settings_path:
print(f"Tool '{args.tool}' not found")
return 1
# Create from defaults if doesn't exist
if not settings_path.exists():
if defaults_path and defaults_path.exists():
shutil.copy(defaults_path, settings_path)
print(f"Created settings.yaml from defaults")
else:
print(f"Tool '{args.tool}' has no configurable settings")
return 1
editor = os.environ.get('EDITOR', 'nano')
return subprocess.call([editor, str(settings_path)])
def _cmd_settings_reset(args):
"""Reset settings to defaults."""
defaults_path, settings_path, _ = _get_tool_paths(args.tool)
if not defaults_path or not defaults_path.exists():
print(f"Tool '{args.tool}' has no defaults.yaml")
return 1
if settings_path.exists() and not getattr(args, 'force', False):
print("This will overwrite your current settings.")
try:
confirm = input("Continue? [y/N] ")
if confirm.lower() != 'y':
print("Cancelled")
return 0
except (EOFError, KeyboardInterrupt):
print("\nCancelled")
return 0
shutil.copy(defaults_path, settings_path)
print(f"Reset settings to defaults")
return 0
def _cmd_settings_diff(args):
"""Show diff between current settings and defaults."""
defaults_path, settings_path, _ = _get_tool_paths(args.tool)
if not defaults_path or not defaults_path.exists():
print(f"Tool '{args.tool}' has no defaults.yaml")
return 1
if not settings_path or not settings_path.exists():
print("No settings.yaml yet (using defaults)")
return 0
# Use diff if available, otherwise show both
try:
result = subprocess.run(
['diff', '-u', str(defaults_path), str(settings_path)],
capture_output=True, text=True
)
if result.stdout:
print(result.stdout)
else:
print("No differences (settings match defaults)")
return 0
except FileNotFoundError:
print("=== defaults.yaml ===")
print(defaults_path.read_text())
print("\n=== settings.yaml ===")
print(settings_path.read_text())
return 0

View File

@ -84,10 +84,11 @@ class PublishWorker(QThread):
success = Signal(dict) success = Signal(dict)
error = Signal(str) error = Signal(str)
def __init__(self, config_yaml: str, readme: str = ""): def __init__(self, config_yaml: str, readme: str = "", defaults: str = ""):
super().__init__() super().__init__()
self.config_yaml = config_yaml self.config_yaml = config_yaml
self.readme = readme self.readme = readme
self.defaults = defaults
def run(self): def run(self):
try: try:
@ -95,7 +96,7 @@ class PublishWorker(QThread):
client = RegistryClient() client = RegistryClient()
client.token = config.registry.token client.token = config.registry.token
result = client.publish_tool(self.config_yaml, self.readme) result = client.publish_tool(self.config_yaml, self.readme, self.defaults)
self.success.emit(result) self.success.emit(result)
except Exception as e: except Exception as e:
self.error.emit(str(e)) self.error.emit(str(e))
@ -402,11 +403,15 @@ class PublishDialog(QDialog):
# Get README if it exists # Get README if it exists
readme = "" readme = ""
defaults = ""
tool_dir = self._tool.path.parent if hasattr(self._tool, 'path') and self._tool.path else None tool_dir = self._tool.path.parent if hasattr(self._tool, 'path') and self._tool.path else None
if tool_dir: if tool_dir:
readme_path = tool_dir / "README.md" readme_path = tool_dir / "README.md"
if readme_path.exists(): if readme_path.exists():
readme = readme_path.read_text() readme = readme_path.read_text()
defaults_path = tool_dir / "defaults.yaml"
if defaults_path.exists():
defaults = defaults_path.read_text()
self.btn_publish.setEnabled(False) self.btn_publish.setEnabled(False)
self.btn_cancel.setEnabled(False) self.btn_cancel.setEnabled(False)
@ -416,7 +421,7 @@ class PublishDialog(QDialog):
# Store the config we're publishing so we can save it on success # Store the config we're publishing so we can save it on success
self._published_config = tool_config self._published_config = tool_config
self._worker = PublishWorker(config_yaml, readme) self._worker = PublishWorker(config_yaml, readme, defaults)
self._worker.success.connect(self._on_success) self._worker.success.connect(self._on_success)
self._worker.error.connect(self._on_error) self._worker.error.connect(self._on_error)
self._worker.start() self._worker.start()

View File

@ -0,0 +1,112 @@
"""Settings dialog for editing tool settings."""
import yaml
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox,
QPlainTextEdit, QPushButton, QMessageBox, QLabel
)
from ...tool import load_tool
class SettingsDialog(QDialog):
"""Dialog for editing tool settings."""
def __init__(self, parent, tool_name: str):
super().__init__(parent)
self.tool_name = tool_name
self.setWindowTitle(f"Settings: {tool_name}")
self.setMinimumSize(500, 400)
self._setup_ui()
self._load_settings()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Reference panel (shows defaults)
ref_group = QGroupBox("Defaults (reference)")
ref_layout = QVBoxLayout(ref_group)
ref_help = QLabel("These are the original defaults. Your settings below will override them.")
ref_help.setStyleSheet("color: #718096; font-size: 11px;")
ref_help.setWordWrap(True)
ref_layout.addWidget(ref_help)
self.defaults_view = QPlainTextEdit()
self.defaults_view.setReadOnly(True)
self.defaults_view.setMaximumHeight(120)
ref_layout.addWidget(self.defaults_view)
layout.addWidget(ref_group)
# Settings editor
edit_group = QGroupBox("Your Settings")
edit_layout = QVBoxLayout(edit_group)
edit_help = QLabel(
"Edit your settings below. Access in templates as {settings.key} "
"or in code as settings['key']."
)
edit_help.setStyleSheet("color: #718096; font-size: 11px;")
edit_help.setWordWrap(True)
edit_layout.addWidget(edit_help)
self.settings_editor = QPlainTextEdit()
edit_layout.addWidget(self.settings_editor)
layout.addWidget(edit_group, 1)
# Buttons
btn_layout = QHBoxLayout()
self.reset_btn = QPushButton("Reset to Defaults")
self.reset_btn.setObjectName("secondary")
self.reset_btn.clicked.connect(self._reset)
btn_layout.addWidget(self.reset_btn)
btn_layout.addStretch()
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.setObjectName("secondary")
self.cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(self.cancel_btn)
self.save_btn = QPushButton("Save")
self.save_btn.clicked.connect(self._save)
self.save_btn.setDefault(True)
btn_layout.addWidget(self.save_btn)
layout.addLayout(btn_layout)
def _load_settings(self):
tool = load_tool(self.tool_name)
if not tool or not tool.path:
return
defaults_path = tool.path.parent / "defaults.yaml"
settings_path = tool.path.parent / "settings.yaml"
if defaults_path.exists():
self.defaults_view.setPlainText(defaults_path.read_text())
if settings_path.exists():
self.settings_editor.setPlainText(settings_path.read_text())
elif defaults_path.exists():
self.settings_editor.setPlainText(defaults_path.read_text())
def _reset(self):
"""Reset settings to defaults."""
self.settings_editor.setPlainText(self.defaults_view.toPlainText())
def _save(self):
"""Save settings."""
tool = load_tool(self.tool_name)
if not tool or not tool.path:
QMessageBox.warning(self, "Error", f"Tool '{self.tool_name}' not found")
return
content = self.settings_editor.toPlainText()
# Validate YAML
try:
yaml.safe_load(content)
except yaml.YAMLError as e:
QMessageBox.warning(self, "Invalid YAML", f"Settings YAML is invalid:\n{e}")
return
settings_path = tool.path.parent / "settings.yaml"
settings_path.write_text(content)
self.accept()

View File

@ -1,8 +1,10 @@
"""Tool builder page - create and edit tools.""" """Tool builder page - create and edit tools."""
import yaml
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QTextEdit, QComboBox, QPushButton, QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QPushButton,
QGroupBox, QListWidget, QListWidgetItem, QLabel, QGroupBox, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QSplitter, QFrame, QStackedWidget, QMessageBox, QSplitter, QFrame, QStackedWidget,
QButtonGroup QButtonGroup
@ -11,7 +13,8 @@ from PySide6.QtCore import Qt
from ...tool import ( from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, ToolStep, Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES,
ensure_settings
) )
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
@ -173,7 +176,37 @@ class ToolBuilderPage(QWidget):
deps_layout.addLayout(deps_add_row) deps_layout.addLayout(deps_add_row)
left_layout.addWidget(deps_box) left_layout.addWidget(deps_box)
left_layout.addStretch() # Push everything up, deps_box won't stretch
# Defaults group (collapsible)
self.defaults_group = QGroupBox("Defaults (Optional)")
self.defaults_group.setCheckable(True)
self.defaults_group.setChecked(False) # Collapsed by default
defaults_layout = QVBoxLayout(self.defaults_group)
defaults_layout.setContentsMargins(9, 9, 9, 9)
defaults_layout.setSpacing(8)
defaults_help = QLabel(
"Define configurable settings users can customize.\n"
"Access in templates as {settings.key} (scalars only)\n"
"Access in code as settings['key'] (any type)"
)
defaults_help.setWordWrap(True)
defaults_help.setStyleSheet("color: #718096; font-size: 11px;")
defaults_layout.addWidget(defaults_help)
self.defaults_editor = QPlainTextEdit()
self.defaults_editor.setPlaceholderText(
"# Example defaults.yaml\n"
"backend: piper\n"
"endpoint: http://localhost:5001\n"
"api_key: '' # User fills in\n"
)
self.defaults_editor.setMaximumHeight(150)
defaults_layout.addWidget(self.defaults_editor)
left_layout.addWidget(self.defaults_group)
left_layout.addStretch() # Push everything up, groups won't stretch
splitter.addWidget(left) splitter.addWidget(left)
@ -457,6 +490,16 @@ class ToolBuilderPage(QWidget):
# Set output # Set output
self.output_input.setPlainText(tool.output or "{response}") self.output_input.setPlainText(tool.output or "{response}")
# Load defaults if exists
if tool.path:
defaults_path = tool.path.parent / "defaults.yaml"
if defaults_path.exists():
self.defaults_editor.setPlainText(defaults_path.read_text())
self.defaults_group.setChecked(True)
else:
self.defaults_editor.clear()
self.defaults_group.setChecked(False)
def _refresh_arguments(self): def _refresh_arguments(self):
"""Refresh arguments list.""" """Refresh arguments list."""
self.args_list.clear() self.args_list.clear()
@ -948,7 +991,31 @@ class ToolBuilderPage(QWidget):
tool.version = self._tool.version tool.version = self._tool.version
try: try:
save_tool(tool) config_path = save_tool(tool)
tool_dir = config_path.parent
# Save defaults if provided
defaults_content = self.defaults_editor.toPlainText().strip()
defaults_path = tool_dir / "defaults.yaml"
if defaults_content and self.defaults_group.isChecked():
# Validate YAML
try:
yaml.safe_load(defaults_content)
except yaml.YAMLError as e:
QMessageBox.warning(self, "Invalid YAML", f"Defaults YAML is invalid:\n{e}")
return
defaults_path.write_text(defaults_content)
# Ensure settings.yaml exists
ensure_settings(tool_dir)
elif defaults_path.exists() and not self.defaults_group.isChecked():
# Remove defaults if group is unchecked and was previously present
defaults_path.unlink()
settings_path = tool_dir / "settings.yaml"
if settings_path.exists():
settings_path.unlink()
self.main_window.show_status(f"Saved tool '{name}'") self.main_window.show_status(f"Saved tool '{name}'")
self.main_window.close_tool_builder() self.main_window.close_tool_builder()
except Exception as e: except Exception as e:

View File

@ -226,6 +226,13 @@ class ToolsPage(QWidget):
self.btn_edit.setEnabled(False) self.btn_edit.setEnabled(False)
btn_layout.addWidget(self.btn_edit) btn_layout.addWidget(self.btn_edit)
self.btn_configure = QPushButton("Configure")
self.btn_configure.setObjectName("secondary")
self.btn_configure.setToolTip("Edit tool settings (only available for tools with configurable settings)")
self.btn_configure.clicked.connect(self._configure_tool)
self.btn_configure.setEnabled(False)
btn_layout.addWidget(self.btn_configure)
self.btn_delete = QPushButton("Delete") self.btn_delete = QPushButton("Delete")
self.btn_delete.setObjectName("danger") self.btn_delete.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_tool) self.btn_delete.clicked.connect(self._delete_tool)
@ -582,12 +589,24 @@ class ToolsPage(QWidget):
self.info_text.setHtml("\n".join(lines)) self.info_text.setHtml("\n".join(lines))
def _has_settings(self, tool: Tool) -> bool:
"""Check if a tool has configurable settings."""
if not tool or not tool.path:
return False
defaults_path = tool.path.parent / "defaults.yaml"
settings_path = tool.path.parent / "settings.yaml"
return defaults_path.exists() or settings_path.exists()
def _update_buttons(self): def _update_buttons(self):
"""Update button enabled states.""" """Update button enabled states."""
has_selection = self._current_tool is not None has_selection = self._current_tool is not None
self.btn_edit.setEnabled(has_selection) self.btn_edit.setEnabled(has_selection)
self.btn_delete.setEnabled(has_selection) self.btn_delete.setEnabled(has_selection)
# Configure button only for tools with settings
has_settings = has_selection and self._has_settings(self._current_tool)
self.btn_configure.setEnabled(has_settings)
config = load_config() config = load_config()
if config.registry.token: if config.registry.token:
# Connected - enable Publish when tool selected # Connected - enable Publish when tool selected
@ -605,6 +624,25 @@ class ToolsPage(QWidget):
if self._current_tool: if self._current_tool:
self.main_window.open_tool_builder(self._current_tool.name) self.main_window.open_tool_builder(self._current_tool.name)
def _configure_tool(self):
"""Open settings dialog for the selected tool."""
if not self._current_tool:
return
if not self._has_settings(self._current_tool):
QMessageBox.information(
self,
"No Settings",
f"Tool '{self._current_tool.name}' has no configurable settings.\n\n"
"Add a defaults.yaml to the tool to enable settings."
)
return
from ..dialogs.settings_dialog import SettingsDialog
dialog = SettingsDialog(self, self._current_tool.name)
if dialog.exec():
self.main_window.show_status(f"Settings saved for '{self._current_tool.name}'")
def _delete_tool(self): def _delete_tool(self):
"""Delete the selected tool.""" """Delete the selected tool."""
if not self._current_tool: if not self._current_tool:

View File

@ -31,6 +31,7 @@ from .stats import (
MAX_BODY_BYTES = 512 * 1024 MAX_BODY_BYTES = 512 * 1024
MAX_CONFIG_BYTES = 64 * 1024 MAX_CONFIG_BYTES = 64 * 1024
MAX_README_BYTES = 256 * 1024 MAX_README_BYTES = 256 * 1024
MAX_DEFAULTS_BYTES = 64 * 1024
MAX_TOOL_NAME_LEN = 64 MAX_TOOL_NAME_LEN = 64
MAX_DESC_LEN = 500 MAX_DESC_LEN = 500
MAX_TAG_LEN = 32 MAX_TAG_LEN = 32
@ -1002,6 +1003,7 @@ def create_app() -> Flask:
"replacement": row["replacement"], "replacement": row["replacement"],
"config": row["config_yaml"], "config": row["config_yaml"],
"readme": row["readme"], "readme": row["readme"],
"defaults": row.get("defaults") or "",
"source": source_obj, "source": source_obj,
"forked_from": row.get("forked_from"), "forked_from": row.get("forked_from"),
"forked_version": row.get("forked_version"), "forked_version": row.get("forked_version"),
@ -1163,6 +1165,7 @@ def create_app() -> Flask:
"config": row["config_yaml"], "config": row["config_yaml"],
"readme": row["readme"] or "", "readme": row["readme"] or "",
"config_hash": row.get("config_hash") or "", "config_hash": row.get("config_hash") or "",
"defaults": row.get("defaults") or "",
} }
}) })
response.headers["Cache-Control"] = "max-age=3600, immutable" response.headers["Cache-Control"] = "max-age=3600, immutable"
@ -2309,6 +2312,7 @@ def create_app() -> Flask:
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
config_text = payload.get("config") or "" config_text = payload.get("config") or ""
readme = payload.get("readme") or "" readme = payload.get("readme") or ""
defaults = payload.get("defaults") or ""
dry_run = bool(payload.get("dry_run")) dry_run = bool(payload.get("dry_run"))
size_resp = validate_payload_size("config", config_text, MAX_CONFIG_BYTES) size_resp = validate_payload_size("config", config_text, MAX_CONFIG_BYTES)
@ -2318,6 +2322,15 @@ def create_app() -> Flask:
size_resp = validate_payload_size("readme", readme, MAX_README_BYTES) size_resp = validate_payload_size("readme", readme, MAX_README_BYTES)
if size_resp: if size_resp:
return size_resp return size_resp
if defaults:
size_resp = validate_payload_size("defaults", defaults, MAX_DEFAULTS_BYTES)
if size_resp:
return size_resp
# Validate defaults YAML syntax
try:
yaml.safe_load(defaults)
except yaml.YAMLError as e:
return error_response("VALIDATION_ERROR", f"Invalid YAML in defaults: {e}")
try: try:
data = yaml.safe_load(config_text) or {} data = yaml.safe_load(config_text) or {}
@ -2514,11 +2527,11 @@ def create_app() -> Flask:
""" """
INSERT INTO tools ( INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme, owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads, defaults, publisher_id, deprecated, deprecated_message, replacement, downloads,
scrutiny_status, scrutiny_report, source, source_url, source_json, scrutiny_status, scrutiny_report, source, source_url, source_json,
config_hash, visibility, moderation_status, forked_from, forked_version, config_hash, visibility, moderation_status, forked_from, forked_version,
published_at published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
[ [
owner, owner,
@ -2529,6 +2542,7 @@ def create_app() -> Flask:
tags_json, tags_json,
config_text, config_text,
readme, readme,
defaults or None,
g.current_publisher["id"], g.current_publisher["id"],
int(bool(data.get("deprecated"))), int(bool(data.get("deprecated"))),
data.get("deprecated_message"), data.get("deprecated_message"),

View File

@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS tools (
tags TEXT, tags TEXT,
config_yaml TEXT NOT NULL, config_yaml TEXT NOT NULL,
readme TEXT, readme TEXT,
defaults TEXT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id), publisher_id INTEGER NOT NULL REFERENCES publishers(id),
deprecated BOOLEAN DEFAULT FALSE, deprecated BOOLEAN DEFAULT FALSE,
deprecated_message TEXT, deprecated_message TEXT,
@ -472,6 +473,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
("moderated_at", "TIMESTAMP", "NULL"), ("moderated_at", "TIMESTAMP", "NULL"),
("forked_from", "TEXT", "NULL"), ("forked_from", "TEXT", "NULL"),
("forked_version", "TEXT", "NULL"), ("forked_version", "TEXT", "NULL"),
("defaults", "TEXT", "NULL"),
] ]
for col_name, col_type, default in tools_migrations: for col_name, col_type, default in tools_migrations:

View File

@ -81,6 +81,7 @@ class ToolInfo:
replacement: str = "" replacement: str = ""
published_at: str = "" published_at: str = ""
readme: str = "" readme: str = ""
defaults: str = "" # Default settings YAML content
@property @property
def full_name(self) -> str: def full_name(self) -> str:
@ -100,7 +101,8 @@ class ToolInfo:
deprecated_message=data.get("deprecated_message", ""), deprecated_message=data.get("deprecated_message", ""),
replacement=data.get("replacement", ""), replacement=data.get("replacement", ""),
published_at=data.get("published_at", ""), published_at=data.get("published_at", ""),
readme=data.get("readme", "") readme=data.get("readme", ""),
defaults=data.get("defaults", "")
) )
@ -113,6 +115,7 @@ class DownloadResult:
config_yaml: str config_yaml: str
readme: str = "" readme: str = ""
config_hash: str = "" # Registry hash for integrity verification config_hash: str = "" # Registry hash for integrity verification
defaults: str = "" # Default settings YAML content
class RegistryClient: class RegistryClient:
@ -546,7 +549,8 @@ class RegistryClient:
resolved_version=data.get("resolved_version", ""), resolved_version=data.get("resolved_version", ""),
config_yaml=data.get("config", ""), config_yaml=data.get("config", ""),
readme=data.get("readme", ""), readme=data.get("readme", ""),
config_hash=data.get("config_hash", "") config_hash=data.get("config_hash", ""),
defaults=data.get("defaults", "")
) )
def get_categories(self) -> List[Dict[str, Any]]: def get_categories(self) -> List[Dict[str, Any]]:
@ -605,6 +609,7 @@ class RegistryClient:
self, self,
config_yaml: str, config_yaml: str,
readme: str = "", readme: str = "",
defaults: str = "",
dry_run: bool = False, dry_run: bool = False,
visibility: str = "public" visibility: str = "public"
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@ -614,6 +619,7 @@ class RegistryClient:
Args: Args:
config_yaml: Tool configuration YAML content config_yaml: Tool configuration YAML content
readme: README.md content readme: README.md content
defaults: Default settings YAML content (defaults.yaml)
dry_run: If True, validate without publishing dry_run: If True, validate without publishing
visibility: Tool visibility - "public", "private", or "unlisted" visibility: Tool visibility - "public", "private", or "unlisted"
@ -626,6 +632,8 @@ class RegistryClient:
"dry_run": dry_run, "dry_run": dry_run,
"visibility": visibility "visibility": visibility
} }
if defaults:
payload["defaults"] = defaults
response = self._request( response = self._request(
"POST", "POST",

View File

@ -353,7 +353,8 @@ class ToolResolver:
version=result.resolved_version, version=result.resolved_version,
config_yaml=result.config_yaml, config_yaml=result.config_yaml,
readme=result.readme, readme=result.readme,
config_hash=result.config_hash config_hash=result.config_hash,
defaults=result.defaults
) )
if self.verbose: if self.verbose:
@ -381,7 +382,8 @@ class ToolResolver:
version: str, version: str,
config_yaml: str, config_yaml: str,
readme: str = "", readme: str = "",
config_hash: str = "" config_hash: str = "",
defaults: str = ""
) -> ResolvedTool: ) -> ResolvedTool:
"""Install a tool fetched from registry to global directory.""" """Install a tool fetched from registry to global directory."""
# Verify hash if provided # Verify hash if provided
@ -413,6 +415,15 @@ class ToolResolver:
readme_path = tool_dir / "README.md" readme_path = tool_dir / "README.md"
readme_path.write_text(readme) readme_path.write_text(readme)
# Write defaults if present
if defaults:
defaults_path = tool_dir / "defaults.yaml"
defaults_path.write_text(defaults)
# Ensure settings.yaml exists if defaults.yaml exists
from .tool import ensure_settings
ensure_settings(tool_dir)
# Load the tool # Load the tool
tool = self._load_tool_from_path(config_path) tool = self._load_tool_from_path(config_path)
@ -486,7 +497,11 @@ exec {python_path} -m cmdforge.runner {owner}/{name} "$@"
if "prompt" in data and "steps" not in data: if "prompt" in data and "steps" not in data:
data = self._convert_legacy_format(data) data = self._convert_legacy_format(data)
return Tool.from_dict(data) tool = Tool.from_dict(data)
tool.path = config_path
from .tool import ensure_settings
ensure_settings(config_path.parent)
return tool
except yaml.YAMLError as e: except yaml.YAMLError as e:
logger.warning(f"YAML error in {config_path}: {e}") logger.warning(f"YAML error in {config_path}: {e}")
if self.verbose: if self.verbose:
@ -612,7 +627,8 @@ def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedT
version=result.resolved_version, version=result.resolved_version,
config_yaml=result.config_yaml, config_yaml=result.config_yaml,
readme=result.readme, readme=result.readme,
config_hash=result.config_hash config_hash=result.config_hash,
defaults=result.defaults
) )

View File

@ -5,6 +5,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import yaml
from .tool import Tool, PromptStep, CodeStep, ToolStep from .tool import Tool, PromptStep, CodeStep, ToolStep
from .providers import call_provider, mock_provider from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry 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 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. Substitute {variable} placeholders in a template.
Supports escaping: use {{ for literal { and }} for literal } Supports escaping: use {{ for literal { and }} for literal }
Also supports {settings.key} syntax for accessing top-level scalar
values from the settings dict.
Args: Args:
template: String with {var} placeholders template: String with {var} placeholders
variables: Dict of variable name -> value variables: Dict of variable name -> value
@ -112,7 +117,11 @@ def substitute_variables(template: str, variables: dict) -> str:
'Hello World' 'Hello World'
>>> substitute_variables("Use {{braces}}", {"braces": "nope"}) >>> substitute_variables("Use {{braces}}", {"braces": "nope"})
'Use {braces}' 'Use {braces}'
>>> substitute_variables("Backend: {settings.backend}", {"settings": {"backend": "piper"}})
'Backend: piper'
""" """
import re
# Use unique placeholders for escaped braces # Use unique placeholders for escaped braces
ESCAPE_OPEN = "\x00\x01OPEN\x01\x00" ESCAPE_OPEN = "\x00\x01OPEN\x01\x00"
ESCAPE_CLOSE = "\x00\x01CLOSE\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 # First, replace escaped braces with placeholders
result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE) 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(): for name, value in variables.items():
if name == "settings":
continue # Already handled above
result = result.replace(f"{{{name}}}", str(value) if value else "") result = result.replace(f"{{{name}}}", str(value) if value else "")
# Finally, restore escaped braces as single braces # Finally, restore escaped braces as single braces
@ -130,7 +163,12 @@ def substitute_variables(template: str, variables: dict) -> str:
return result 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. Execute a prompt step.
@ -143,7 +181,7 @@ def execute_prompt_step(step: PromptStep, variables: dict, provider_override: st
Tuple of (output_value, success) Tuple of (output_value, success)
""" """
# Build prompt with variable substitution # 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 # Inject profile system prompt if specified
if step.profile: if step.profile:
@ -167,7 +205,12 @@ def execute_prompt_step(step: PromptStep, variables: dict, provider_override: st
return result.text, True 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. 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) Tuple of (output_vars_dict, success)
""" """
# Substitute variables in code (like {outputfile} -> actual value) # 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 # Create execution environment with variables
local_vars = dict(variables) local_vars = dict(variables)
@ -281,12 +324,12 @@ def execute_tool_step(
return "", False return "", False
# Prepare input by substituting variables # 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 # Prepare arguments by substituting variables in arg values
custom_args = {} custom_args = {}
for key, value in step.args.items(): 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) # Determine effective provider (step override > parent override)
effective_provider = step.provider or provider_override effective_provider = step.provider or provider_override
@ -377,6 +420,18 @@ def run_tool(
value = custom_args.get(arg.variable, arg.default) value = custom_args.get(arg.variable, arg.default)
variables[arg.variable] = value 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: if verbose:
print(f"[verbose] Tool: {tool.name}", file=sys.stderr) print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
print(f"[verbose] Variables: {list(variables.keys())}", 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 no steps, just substitute output template
if not tool.steps: if not tool.steps:
output = substitute_variables(tool.output, variables) output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
return output, 0 return output, 0
# Execute each step # Execute each step
@ -403,7 +458,7 @@ def run_tool(
if isinstance(step, PromptStep): if isinstance(step, PromptStep):
# Show prompt if requested # Show prompt if requested
if show_prompt or dry_run: 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(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
print(prompt, file=sys.stderr) print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr) print("=== END PROMPT ===", file=sys.stderr)
@ -411,7 +466,7 @@ def run_tool(
if dry_run: if dry_run:
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]" variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
else: else:
output, success = execute_prompt_step(step, variables, provider_override) output, success = execute_prompt_step(step, variables, provider_override, verbose=verbose)
if not success: if not success:
return "", 2 return "", 2
variables[step.output_var] = output variables[step.output_var] = output
@ -427,7 +482,7 @@ def run_tool(
for var in [v.strip() for v in step.output_var.split(',')]: for var in [v.strip() for v in step.output_var.split(',')]:
variables[var] = "[DRY RUN - would execute code]" variables[var] = "[DRY RUN - would execute code]"
else: 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: if not success:
return "", 1 return "", 1
# Merge all output vars into variables # Merge all output vars into variables
@ -456,7 +511,7 @@ def run_tool(
variables[step.output_var] = output variables[step.output_var] = output
# Generate final output # Generate final output
output = substitute_variables(tool.output, variables) output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
return output, 0 return output, 0

View File

@ -1,6 +1,7 @@
"""Tool loading, saving, and management.""" """Tool loading, saving, and management."""
import os import os
import shutil
import stat import stat
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@ -283,6 +284,29 @@ def get_tools_dir() -> Path:
return TOOLS_DIR return TOOLS_DIR
def ensure_settings(tool_dir: Path) -> Optional[Path]:
"""Ensure settings.yaml exists if defaults.yaml exists.
Called on tool load, save, and install to ensure consistency
across all tool creation paths (registry, local, GUI).
Args:
tool_dir: Path to the tool directory (e.g., ~/.cmdforge/my-tool/)
Returns:
Path to settings.yaml if created/exists, None otherwise.
"""
defaults_path = tool_dir / "defaults.yaml"
settings_path = tool_dir / "settings.yaml"
if defaults_path.exists() and not settings_path.exists():
shutil.copy(defaults_path, settings_path)
return settings_path
elif settings_path.exists():
return settings_path
return None
def get_bin_dir() -> Path: def get_bin_dir() -> Path:
"""Get the bin directory for wrapper scripts, creating it if needed.""" """Get the bin directory for wrapper scripts, creating it if needed."""
BIN_DIR.mkdir(parents=True, exist_ok=True) BIN_DIR.mkdir(parents=True, exist_ok=True)
@ -371,6 +395,8 @@ def load_tool(name: str) -> Optional[Tool]:
tool = Tool.from_dict(data) tool = Tool.from_dict(data)
tool.path = config_path tool.path = config_path
# Ensure settings.yaml exists if defaults.yaml exists
ensure_settings(config_path.parent)
return tool return tool
except yaml.YAMLError as e: except yaml.YAMLError as e:
import sys import sys
@ -414,6 +440,9 @@ def save_tool(tool: Tool) -> Path:
# Create wrapper script # Create wrapper script
create_wrapper_script(tool.name) create_wrapper_script(tool.name)
# Ensure settings.yaml exists if defaults.yaml exists
ensure_settings(tool_dir)
return config_path return config_path

193
tests/test_settings.py Normal file
View File

@ -0,0 +1,193 @@
"""Tests for tool settings functionality."""
import pytest
import tempfile
import shutil
from pathlib import Path
import yaml
from cmdforge.tool import ensure_settings, save_tool, load_tool, Tool
from cmdforge.runner import substitute_variables
class TestEnsureSettings:
"""Tests for ensure_settings helper function."""
def test_creates_settings_from_defaults(self, tmp_path):
"""Settings.yaml created when defaults.yaml exists."""
tool_dir = tmp_path / "my-tool"
tool_dir.mkdir()
# Create defaults.yaml
defaults_path = tool_dir / "defaults.yaml"
defaults_path.write_text("backend: piper\nendpoint: http://localhost:5001\n")
# Ensure settings.yaml doesn't exist yet
settings_path = tool_dir / "settings.yaml"
assert not settings_path.exists()
# Call ensure_settings
result = ensure_settings(tool_dir)
# Check settings.yaml was created
assert result == settings_path
assert settings_path.exists()
assert settings_path.read_text() == defaults_path.read_text()
def test_preserves_existing_settings(self, tmp_path):
"""Existing settings.yaml not overwritten."""
tool_dir = tmp_path / "my-tool"
tool_dir.mkdir()
# Create defaults.yaml
defaults_path = tool_dir / "defaults.yaml"
defaults_path.write_text("backend: piper\n")
# Create existing settings.yaml with different content
settings_path = tool_dir / "settings.yaml"
settings_path.write_text("backend: google\napi_key: my-key\n")
# Call ensure_settings
result = ensure_settings(tool_dir)
# Check settings.yaml was not overwritten
assert result == settings_path
assert settings_path.read_text() == "backend: google\napi_key: my-key\n"
def test_no_defaults_returns_none(self, tmp_path):
"""Returns None when no defaults.yaml exists."""
tool_dir = tmp_path / "my-tool"
tool_dir.mkdir()
result = ensure_settings(tool_dir)
assert result is None
assert not (tool_dir / "settings.yaml").exists()
def test_existing_settings_no_defaults_returns_path(self, tmp_path):
"""Returns settings path when settings exists but defaults doesn't."""
tool_dir = tmp_path / "my-tool"
tool_dir.mkdir()
# Create only settings.yaml
settings_path = tool_dir / "settings.yaml"
settings_path.write_text("custom: value\n")
result = ensure_settings(tool_dir)
assert result == settings_path
class TestSubstituteVariablesSettings:
"""Tests for {settings.key} substitution in templates."""
def test_settings_scalar_string(self):
"""Template substitution works for string settings."""
variables = {"settings": {"backend": "piper"}}
result = substitute_variables("Using {settings.backend}", variables)
assert result == "Using piper"
def test_settings_scalar_int(self):
"""Template substitution works for int settings."""
variables = {"settings": {"port": 5001}}
result = substitute_variables("Port: {settings.port}", variables)
assert result == "Port: 5001"
def test_settings_scalar_float(self):
"""Template substitution works for float settings."""
variables = {"settings": {"threshold": 0.75}}
result = substitute_variables("Threshold: {settings.threshold}", variables)
assert result == "Threshold: 0.75"
def test_settings_scalar_bool(self):
"""Template substitution works for bool settings."""
variables = {"settings": {"enabled": True}}
result = substitute_variables("Enabled: {settings.enabled}", variables)
assert result == "Enabled: True"
def test_settings_nonscalar_unchanged(self, capsys):
"""Non-scalar settings leave placeholder unchanged and warn."""
variables = {"settings": {"items": ["a", "b", "c"]}}
result = substitute_variables("Items: {settings.items}", variables, warn_non_scalar=True)
# Placeholder should be unchanged
assert result == "Items: {settings.items}"
# Should have printed a warning
captured = capsys.readouterr()
assert "not a scalar value" in captured.err
def test_settings_missing_key_unchanged(self):
"""Missing settings key leaves placeholder unchanged."""
variables = {"settings": {"backend": "piper"}}
result = substitute_variables("API: {settings.api_key}", variables)
assert result == "API: {settings.api_key}"
def test_settings_empty_dict(self):
"""Empty settings dict leaves placeholders unchanged."""
variables = {"settings": {}}
result = substitute_variables("Backend: {settings.backend}", variables)
assert result == "Backend: {settings.backend}"
def test_mixed_variables_and_settings(self):
"""Regular variables and settings work together."""
variables = {
"input": "Hello",
"name": "World",
"settings": {"backend": "piper"}
}
result = substitute_variables(
"{input} {name}! Using {settings.backend}.",
variables
)
assert result == "Hello World! Using piper."
def test_escaped_braces_preserved(self):
"""Escaped braces {{}} work with settings."""
variables = {"settings": {"key": "value"}}
result = substitute_variables(
"{{literal}} and {settings.key}",
variables
)
assert result == "{literal} and value"
class TestRunnerLoadsSettings:
"""Integration tests for runner loading settings."""
def test_settings_available_in_code_step(self, monkeypatch, tmp_path):
"""Settings dict is available in code steps."""
from cmdforge.tool import TOOLS_DIR
from cmdforge.runner import run_tool
# Patch TOOLS_DIR
monkeypatch.setattr("cmdforge.tool.TOOLS_DIR", tmp_path)
# Create tool directory with config and settings
tool_dir = tmp_path / "test-settings-tool"
tool_dir.mkdir()
config = {
"name": "test-settings-tool",
"description": "Test tool",
"steps": [
{
"type": "code",
"code": "result = f\"backend={settings.get('backend', 'none')}\"",
"output_var": "result"
}
],
"output": "{result}"
}
(tool_dir / "config.yaml").write_text(yaml.dump(config))
settings = {"backend": "piper", "api_key": "test-key"}
(tool_dir / "settings.yaml").write_text(yaml.dump(settings))
# Load and run the tool
tool = load_tool("test-settings-tool")
output, exit_code = run_tool(tool, "test input", {})
assert exit_code == 0
assert "backend=piper" in output