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:
parent
ce94ba89dc
commit
8eff55ed1e
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue