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
+8 -3
View File
@@ -84,10 +84,11 @@ class PublishWorker(QThread):
success = Signal(dict)
error = Signal(str)
def __init__(self, config_yaml: str, readme: str = ""):
def __init__(self, config_yaml: str, readme: str = "", defaults: str = ""):
super().__init__()
self.config_yaml = config_yaml
self.readme = readme
self.defaults = defaults
def run(self):
try:
@@ -95,7 +96,7 @@ class PublishWorker(QThread):
client = RegistryClient()
client.token = config.registry.token
result = client.publish_tool(self.config_yaml, self.readme)
result = client.publish_tool(self.config_yaml, self.readme, self.defaults)
self.success.emit(result)
except Exception as e:
self.error.emit(str(e))
@@ -402,11 +403,15 @@ class PublishDialog(QDialog):
# Get README if it exists
readme = ""
defaults = ""
tool_dir = self._tool.path.parent if hasattr(self._tool, 'path') and self._tool.path else None
if tool_dir:
readme_path = tool_dir / "README.md"
if readme_path.exists():
readme = readme_path.read_text()
defaults_path = tool_dir / "defaults.yaml"
if defaults_path.exists():
defaults = defaults_path.read_text()
self.btn_publish.setEnabled(False)
self.btn_cancel.setEnabled(False)
@@ -416,7 +421,7 @@ class PublishDialog(QDialog):
# Store the config we're publishing so we can save it on success
self._published_config = tool_config
self._worker = PublishWorker(config_yaml, readme)
self._worker = PublishWorker(config_yaml, readme, defaults)
self._worker.success.connect(self._on_success)
self._worker.error.connect(self._on_error)
self._worker.start()
+112
View File
@@ -0,0 +1,112 @@
"""Settings dialog for editing tool settings."""
import yaml
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox,
QPlainTextEdit, QPushButton, QMessageBox, QLabel
)
from ...tool import load_tool
class SettingsDialog(QDialog):
"""Dialog for editing tool settings."""
def __init__(self, parent, tool_name: str):
super().__init__(parent)
self.tool_name = tool_name
self.setWindowTitle(f"Settings: {tool_name}")
self.setMinimumSize(500, 400)
self._setup_ui()
self._load_settings()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Reference panel (shows defaults)
ref_group = QGroupBox("Defaults (reference)")
ref_layout = QVBoxLayout(ref_group)
ref_help = QLabel("These are the original defaults. Your settings below will override them.")
ref_help.setStyleSheet("color: #718096; font-size: 11px;")
ref_help.setWordWrap(True)
ref_layout.addWidget(ref_help)
self.defaults_view = QPlainTextEdit()
self.defaults_view.setReadOnly(True)
self.defaults_view.setMaximumHeight(120)
ref_layout.addWidget(self.defaults_view)
layout.addWidget(ref_group)
# Settings editor
edit_group = QGroupBox("Your Settings")
edit_layout = QVBoxLayout(edit_group)
edit_help = QLabel(
"Edit your settings below. Access in templates as {settings.key} "
"or in code as settings['key']."
)
edit_help.setStyleSheet("color: #718096; font-size: 11px;")
edit_help.setWordWrap(True)
edit_layout.addWidget(edit_help)
self.settings_editor = QPlainTextEdit()
edit_layout.addWidget(self.settings_editor)
layout.addWidget(edit_group, 1)
# Buttons
btn_layout = QHBoxLayout()
self.reset_btn = QPushButton("Reset to Defaults")
self.reset_btn.setObjectName("secondary")
self.reset_btn.clicked.connect(self._reset)
btn_layout.addWidget(self.reset_btn)
btn_layout.addStretch()
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.setObjectName("secondary")
self.cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(self.cancel_btn)
self.save_btn = QPushButton("Save")
self.save_btn.clicked.connect(self._save)
self.save_btn.setDefault(True)
btn_layout.addWidget(self.save_btn)
layout.addLayout(btn_layout)
def _load_settings(self):
tool = load_tool(self.tool_name)
if not tool or not tool.path:
return
defaults_path = tool.path.parent / "defaults.yaml"
settings_path = tool.path.parent / "settings.yaml"
if defaults_path.exists():
self.defaults_view.setPlainText(defaults_path.read_text())
if settings_path.exists():
self.settings_editor.setPlainText(settings_path.read_text())
elif defaults_path.exists():
self.settings_editor.setPlainText(defaults_path.read_text())
def _reset(self):
"""Reset settings to defaults."""
self.settings_editor.setPlainText(self.defaults_view.toPlainText())
def _save(self):
"""Save settings."""
tool = load_tool(self.tool_name)
if not tool or not tool.path:
QMessageBox.warning(self, "Error", f"Tool '{self.tool_name}' not found")
return
content = self.settings_editor.toPlainText()
# Validate YAML
try:
yaml.safe_load(content)
except yaml.YAMLError as e:
QMessageBox.warning(self, "Invalid YAML", f"Settings YAML is invalid:\n{e}")
return
settings_path = tool.path.parent / "settings.yaml"
settings_path.write_text(content)
self.accept()