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
#### 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
- **Full transitive dependency resolution**: When installing dependencies, CmdForge now resolves and installs the complete dependency tree
- `DependencyGraph` and `DependencyNode` dataclasses for structured dependency tracking

View File

@ -42,6 +42,8 @@ cmdforge
### Key Paths
- **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)
- **Provider config**: `~/.cmdforge/providers.yaml`
- **Collections storage**: `~/.cmdforge/collections/<name>.yaml`
@ -65,8 +67,10 @@ Variables are passed between steps:
- `{input}` - always available (stdin/file content)
- `{argname}` - from tool arguments
- `{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

View File

@ -164,6 +164,12 @@ cmdforge add owner/tool # Add tool as dependency
cmdforge install # Install all dependencies
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
cmdforge config show # Show current config
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 |
| `{argname}` | Custom argument value |
| `{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

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)
- `{variable_name}` - From arguments (e.g., `{max}`)
- `{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

View File

@ -14,6 +14,7 @@ from .registry_commands import cmd_registry
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 .config_commands import cmd_config
from .settings_commands import cmd_settings
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.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("-f", "--force", action="store_true", help="Skip confirmation prompts")
p_reg_publish.set_defaults(func=cmd_registry)
# registry my-tools
@ -368,6 +370,33 @@ def main():
# 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)))
# -------------------------------------------------------------------------
# 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()
# If no command, launch UI

View File

@ -654,13 +654,16 @@ def _publish_single_tool(tool_name: str, client) -> dict:
if not tool:
return {"success": False, "error": f"Tool '{tool_name}' not found"}
# Load README if exists
readme_path = get_tools_dir() / tool_name / "README.md"
# Load README/defaults if exists
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 ""
defaults_path = tool_dir / "defaults.yaml"
defaults = defaults_path.read_text() if defaults_path.exists() else ""
try:
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
config_path = get_tools_dir() / tool_name / "config.yaml"

View File

@ -1,6 +1,7 @@
"""Registry commands."""
import json
import re
import sys
from pathlib import Path
@ -359,6 +360,32 @@ def _cmd_registry_publish(args):
readme_path = tool_path / "README.md"
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
try:
data = yaml.safe_load(config_yaml)
@ -408,7 +435,7 @@ def _cmd_registry_publish(args):
try:
client = get_client()
result = client.publish_tool(config_yaml, readme)
result = client.publish_tool(config_yaml, readme, defaults)
pr_url = result.get("pr_url", "")
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)
error = Signal(str)
def __init__(self, config_yaml: str, readme: str = ""):
def __init__(self, config_yaml: str, readme: str = "", defaults: str = ""):
super().__init__()
self.config_yaml = config_yaml
self.readme = readme
self.defaults = defaults
def run(self):
try:
@ -95,7 +96,7 @@ class PublishWorker(QThread):
client = RegistryClient()
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)
except Exception as e:
self.error.emit(str(e))
@ -402,11 +403,15 @@ class PublishDialog(QDialog):
# Get README if it exists
readme = ""
defaults = ""
tool_dir = self._tool.path.parent if hasattr(self._tool, 'path') and self._tool.path else None
if tool_dir:
readme_path = tool_dir / "README.md"
if readme_path.exists():
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_cancel.setEnabled(False)
@ -416,7 +421,7 @@ class PublishDialog(QDialog):
# Store the config we're publishing so we can save it on success
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.error.connect(self._on_error)
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."""
import yaml
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QTextEdit, QComboBox, QPushButton,
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QPushButton,
QGroupBox, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QSplitter, QFrame, QStackedWidget,
QButtonGroup
@ -11,7 +13,8 @@ from PySide6.QtCore import Qt
from ...tool import (
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
@ -173,7 +176,37 @@ class ToolBuilderPage(QWidget):
deps_layout.addLayout(deps_add_row)
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)
@ -457,6 +490,16 @@ class ToolBuilderPage(QWidget):
# Set output
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):
"""Refresh arguments list."""
self.args_list.clear()
@ -948,7 +991,31 @@ class ToolBuilderPage(QWidget):
tool.version = self._tool.version
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.close_tool_builder()
except Exception as e:

View File

@ -226,6 +226,13 @@ class ToolsPage(QWidget):
self.btn_edit.setEnabled(False)
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.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_tool)
@ -582,12 +589,24 @@ class ToolsPage(QWidget):
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):
"""Update button enabled states."""
has_selection = self._current_tool is not None
self.btn_edit.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()
if config.registry.token:
# Connected - enable Publish when tool selected
@ -605,6 +624,25 @@ class ToolsPage(QWidget):
if self._current_tool:
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):
"""Delete the selected tool."""
if not self._current_tool:

View File

@ -31,6 +31,7 @@ from .stats import (
MAX_BODY_BYTES = 512 * 1024
MAX_CONFIG_BYTES = 64 * 1024
MAX_README_BYTES = 256 * 1024
MAX_DEFAULTS_BYTES = 64 * 1024
MAX_TOOL_NAME_LEN = 64
MAX_DESC_LEN = 500
MAX_TAG_LEN = 32
@ -1002,6 +1003,7 @@ def create_app() -> Flask:
"replacement": row["replacement"],
"config": row["config_yaml"],
"readme": row["readme"],
"defaults": row.get("defaults") or "",
"source": source_obj,
"forked_from": row.get("forked_from"),
"forked_version": row.get("forked_version"),
@ -1163,6 +1165,7 @@ def create_app() -> Flask:
"config": row["config_yaml"],
"readme": row["readme"] or "",
"config_hash": row.get("config_hash") or "",
"defaults": row.get("defaults") or "",
}
})
response.headers["Cache-Control"] = "max-age=3600, immutable"
@ -2309,6 +2312,7 @@ def create_app() -> Flask:
payload = request.get_json(silent=True) or {}
config_text = payload.get("config") or ""
readme = payload.get("readme") or ""
defaults = payload.get("defaults") or ""
dry_run = bool(payload.get("dry_run"))
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)
if 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:
data = yaml.safe_load(config_text) or {}
@ -2514,11 +2527,11 @@ def create_app() -> Flask:
"""
INSERT INTO tools (
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,
config_hash, visibility, moderation_status, forked_from, forked_version,
published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
@ -2529,6 +2542,7 @@ def create_app() -> Flask:
tags_json,
config_text,
readme,
defaults or None,
g.current_publisher["id"],
int(bool(data.get("deprecated"))),
data.get("deprecated_message"),

View File

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

View File

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

View File

@ -353,7 +353,8 @@ class ToolResolver:
version=result.resolved_version,
config_yaml=result.config_yaml,
readme=result.readme,
config_hash=result.config_hash
config_hash=result.config_hash,
defaults=result.defaults
)
if self.verbose:
@ -381,7 +382,8 @@ class ToolResolver:
version: str,
config_yaml: str,
readme: str = "",
config_hash: str = ""
config_hash: str = "",
defaults: str = ""
) -> ResolvedTool:
"""Install a tool fetched from registry to global directory."""
# Verify hash if provided
@ -413,6 +415,15 @@ class ToolResolver:
readme_path = tool_dir / "README.md"
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
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:
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:
logger.warning(f"YAML error in {config_path}: {e}")
if self.verbose:
@ -612,7 +627,8 @@ def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedT
version=result.resolved_version,
config_yaml=result.config_yaml,
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 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

View File

@ -1,6 +1,7 @@
"""Tool loading, saving, and management."""
import os
import shutil
import stat
from dataclasses import dataclass, field
from pathlib import Path
@ -283,6 +284,29 @@ def get_tools_dir() -> Path:
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:
"""Get the bin directory for wrapper scripts, creating it if needed."""
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.path = config_path
# Ensure settings.yaml exists if defaults.yaml exists
ensure_settings(config_path.parent)
return tool
except yaml.YAMLError as e:
import sys
@ -414,6 +440,9 @@ def save_tool(tool: Tool) -> Path:
# Create wrapper script
create_wrapper_script(tool.name)
# Ensure settings.yaml exists if defaults.yaml exists
ensure_settings(tool_dir)
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