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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user