Add tool settings files for configurable tool defaults

Tools can now ship with defaults.yaml containing configurable settings
that users can customize via settings.yaml (auto-created from defaults).

Features:
- ensure_settings() helper copies defaults to settings on first use
- Settings available as {settings.key} in templates (scalars only)
- Full dict access via settings['key'] in code steps
- CLI: cmdforge settings <tool> show/edit/reset/diff
- GUI: Defaults editor in Tool Builder, Configure button on Tools page
- Registry: defaults.yaml published with tools, included in downloads
- Secret detection warning on publish (api_key, password, token, etc.)

Fully backward compatible - tools without defaults work unchanged.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-27 11:56:07 -04:00
co-authored by Claude Opus 4.5
parent ce94ba89dc
commit 8eff55ed1e
19 changed files with 894 additions and 33 deletions
+71 -4
View File
@@ -1,8 +1,10 @@
"""Tool builder page - create and edit tools."""
import yaml
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QTextEdit, QComboBox, QPushButton,
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QPushButton,
QGroupBox, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QSplitter, QFrame, QStackedWidget,
QButtonGroup
@@ -11,7 +13,8 @@ from PySide6.QtCore import Qt
from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES,
ensure_settings
)
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
@@ -173,7 +176,37 @@ class ToolBuilderPage(QWidget):
deps_layout.addLayout(deps_add_row)
left_layout.addWidget(deps_box)
left_layout.addStretch() # Push everything up, deps_box won't stretch
# Defaults group (collapsible)
self.defaults_group = QGroupBox("Defaults (Optional)")
self.defaults_group.setCheckable(True)
self.defaults_group.setChecked(False) # Collapsed by default
defaults_layout = QVBoxLayout(self.defaults_group)
defaults_layout.setContentsMargins(9, 9, 9, 9)
defaults_layout.setSpacing(8)
defaults_help = QLabel(
"Define configurable settings users can customize.\n"
"Access in templates as {settings.key} (scalars only)\n"
"Access in code as settings['key'] (any type)"
)
defaults_help.setWordWrap(True)
defaults_help.setStyleSheet("color: #718096; font-size: 11px;")
defaults_layout.addWidget(defaults_help)
self.defaults_editor = QPlainTextEdit()
self.defaults_editor.setPlaceholderText(
"# Example defaults.yaml\n"
"backend: piper\n"
"endpoint: http://localhost:5001\n"
"api_key: '' # User fills in\n"
)
self.defaults_editor.setMaximumHeight(150)
defaults_layout.addWidget(self.defaults_editor)
left_layout.addWidget(self.defaults_group)
left_layout.addStretch() # Push everything up, groups won't stretch
splitter.addWidget(left)
@@ -457,6 +490,16 @@ class ToolBuilderPage(QWidget):
# Set output
self.output_input.setPlainText(tool.output or "{response}")
# Load defaults if exists
if tool.path:
defaults_path = tool.path.parent / "defaults.yaml"
if defaults_path.exists():
self.defaults_editor.setPlainText(defaults_path.read_text())
self.defaults_group.setChecked(True)
else:
self.defaults_editor.clear()
self.defaults_group.setChecked(False)
def _refresh_arguments(self):
"""Refresh arguments list."""
self.args_list.clear()
@@ -948,7 +991,31 @@ class ToolBuilderPage(QWidget):
tool.version = self._tool.version
try:
save_tool(tool)
config_path = save_tool(tool)
tool_dir = config_path.parent
# Save defaults if provided
defaults_content = self.defaults_editor.toPlainText().strip()
defaults_path = tool_dir / "defaults.yaml"
if defaults_content and self.defaults_group.isChecked():
# Validate YAML
try:
yaml.safe_load(defaults_content)
except yaml.YAMLError as e:
QMessageBox.warning(self, "Invalid YAML", f"Defaults YAML is invalid:\n{e}")
return
defaults_path.write_text(defaults_content)
# Ensure settings.yaml exists
ensure_settings(tool_dir)
elif defaults_path.exists() and not self.defaults_group.isChecked():
# Remove defaults if group is unchecked and was previously present
defaults_path.unlink()
settings_path = tool_dir / "settings.yaml"
if settings_path.exists():
settings_path.unlink()
self.main_window.show_status(f"Saved tool '{name}'")
self.main_window.close_tool_builder()
except Exception as e:
+38
View File
@@ -226,6 +226,13 @@ class ToolsPage(QWidget):
self.btn_edit.setEnabled(False)
btn_layout.addWidget(self.btn_edit)
self.btn_configure = QPushButton("Configure")
self.btn_configure.setObjectName("secondary")
self.btn_configure.setToolTip("Edit tool settings (only available for tools with configurable settings)")
self.btn_configure.clicked.connect(self._configure_tool)
self.btn_configure.setEnabled(False)
btn_layout.addWidget(self.btn_configure)
self.btn_delete = QPushButton("Delete")
self.btn_delete.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_tool)
@@ -582,12 +589,24 @@ class ToolsPage(QWidget):
self.info_text.setHtml("\n".join(lines))
def _has_settings(self, tool: Tool) -> bool:
"""Check if a tool has configurable settings."""
if not tool or not tool.path:
return False
defaults_path = tool.path.parent / "defaults.yaml"
settings_path = tool.path.parent / "settings.yaml"
return defaults_path.exists() or settings_path.exists()
def _update_buttons(self):
"""Update button enabled states."""
has_selection = self._current_tool is not None
self.btn_edit.setEnabled(has_selection)
self.btn_delete.setEnabled(has_selection)
# Configure button only for tools with settings
has_settings = has_selection and self._has_settings(self._current_tool)
self.btn_configure.setEnabled(has_settings)
config = load_config()
if config.registry.token:
# Connected - enable Publish when tool selected
@@ -605,6 +624,25 @@ class ToolsPage(QWidget):
if self._current_tool:
self.main_window.open_tool_builder(self._current_tool.name)
def _configure_tool(self):
"""Open settings dialog for the selected tool."""
if not self._current_tool:
return
if not self._has_settings(self._current_tool):
QMessageBox.information(
self,
"No Settings",
f"Tool '{self._current_tool.name}' has no configurable settings.\n\n"
"Add a defaults.yaml to the tool to enable settings."
)
return
from ..dialogs.settings_dialog import SettingsDialog
dialog = SettingsDialog(self, self._current_tool.name)
if dialog.exec():
self.main_window.show_status(f"Settings saved for '{self._current_tool.name}'")
def _delete_tool(self):
"""Delete the selected tool."""
if not self._current_tool: