From 8eff55ed1e8b901ddaba2f7a3692013f2c7ed6d1 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 27 Jan 2026 11:56:07 -0400 Subject: [PATCH] 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 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 --- CHANGELOG.md | 29 +++ CLAUDE.md | 6 +- README.md | 54 ++++++ olddocs/DESIGN.md | 34 ++++ src/cmdforge/cli/__init__.py | 29 +++ src/cmdforge/cli/collections_commands.py | 9 +- src/cmdforge/cli/registry_commands.py | 29 ++- src/cmdforge/cli/settings_commands.py | 142 ++++++++++++++ src/cmdforge/gui/dialogs/publish_dialog.py | 11 +- src/cmdforge/gui/dialogs/settings_dialog.py | 112 ++++++++++++ src/cmdforge/gui/pages/tool_builder_page.py | 75 +++++++- src/cmdforge/gui/pages/tools_page.py | 38 ++++ src/cmdforge/registry/app.py | 18 +- src/cmdforge/registry/db.py | 2 + src/cmdforge/registry_client.py | 12 +- src/cmdforge/resolver.py | 24 ++- src/cmdforge/runner.py | 81 ++++++-- src/cmdforge/tool.py | 29 +++ tests/test_settings.py | 193 ++++++++++++++++++++ 19 files changed, 894 insertions(+), 33 deletions(-) create mode 100644 src/cmdforge/cli/settings_commands.py create mode 100644 src/cmdforge/gui/dialogs/settings_dialog.py create mode 100644 tests/test_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ceeb0ca..8c103d7 100644 --- a/CHANGELOG.md +++ b/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 show` - View current settings + - `cmdforge settings edit` - Edit settings in $EDITOR + - `cmdforge settings reset` - Reset settings to defaults + - `cmdforge settings 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 diff --git a/CLAUDE.md b/CLAUDE.md index 038c9ff..8dee0b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ cmdforge ### Key Paths - **Tools storage**: `~/.cmdforge//config.yaml` +- **Tool defaults**: `~/.cmdforge//defaults.yaml` (optional, published with tool) +- **Tool settings**: `~/.cmdforge//settings.yaml` (user overrides, auto-created from defaults) - **Wrapper scripts**: `~/.local/bin/` (auto-generated bash scripts) - **Provider config**: `~/.cmdforge/providers.yaml` - **Collections storage**: `~/.cmdforge/collections/.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 diff --git a/README.md b/README.md index 2c231eb..100a0ed 100644 --- a/README.md +++ b/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 diff --git a/olddocs/DESIGN.md b/olddocs/DESIGN.md index d8926f4..154de4c 100644 --- a/olddocs/DESIGN.md +++ b/olddocs/DESIGN.md @@ -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 diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index 3ebb9fe..cc903bf 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -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 diff --git a/src/cmdforge/cli/collections_commands.py b/src/cmdforge/cli/collections_commands.py index 9b44255..0f21be8 100644 --- a/src/cmdforge/cli/collections_commands.py +++ b/src/cmdforge/cli/collections_commands.py @@ -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" diff --git a/src/cmdforge/cli/registry_commands.py b/src/cmdforge/cli/registry_commands.py index 82f0a06..b69c490 100644 --- a/src/cmdforge/cli/registry_commands.py +++ b/src/cmdforge/cli/registry_commands.py @@ -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", "") diff --git a/src/cmdforge/cli/settings_commands.py b/src/cmdforge/cli/settings_commands.py new file mode 100644 index 0000000..8124ded --- /dev/null +++ b/src/cmdforge/cli/settings_commands.py @@ -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 diff --git a/src/cmdforge/gui/dialogs/publish_dialog.py b/src/cmdforge/gui/dialogs/publish_dialog.py index e48069e..e430225 100644 --- a/src/cmdforge/gui/dialogs/publish_dialog.py +++ b/src/cmdforge/gui/dialogs/publish_dialog.py @@ -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() diff --git a/src/cmdforge/gui/dialogs/settings_dialog.py b/src/cmdforge/gui/dialogs/settings_dialog.py new file mode 100644 index 0000000..e051af8 --- /dev/null +++ b/src/cmdforge/gui/dialogs/settings_dialog.py @@ -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() diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index 8883b1c..47ab1da 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -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: diff --git a/src/cmdforge/gui/pages/tools_page.py b/src/cmdforge/gui/pages/tools_page.py index 74d5c5d..e3e441e 100644 --- a/src/cmdforge/gui/pages/tools_page.py +++ b/src/cmdforge/gui/pages/tools_page.py @@ -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: diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 514a28f..a4b84d4 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -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"), diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index 880a595..8377574 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -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: diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py index f1d9221..203312e 100644 --- a/src/cmdforge/registry_client.py +++ b/src/cmdforge/registry_client.py @@ -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", diff --git a/src/cmdforge/resolver.py b/src/cmdforge/resolver.py index 7876bf5..ed0594e 100644 --- a/src/cmdforge/resolver.py +++ b/src/cmdforge/resolver.py @@ -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 ) diff --git a/src/cmdforge/runner.py b/src/cmdforge/runner.py index 3535593..96b2f4e 100644 --- a/src/cmdforge/runner.py +++ b/src/cmdforge/runner.py @@ -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 diff --git a/src/cmdforge/tool.py b/src/cmdforge/tool.py index dd1a676..fbb76a3 100644 --- a/src/cmdforge/tool.py +++ b/src/cmdforge/tool.py @@ -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 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..b37f691 --- /dev/null +++ b/tests/test_settings.py @@ -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