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
+29
View File
@@ -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
+6 -3
View File
@@ -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"
+28 -1
View File
@@ -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", "")
+142
View File
@@ -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
+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()
+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:
+16 -2
View File
@@ -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"),
+2
View File
@@ -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:
+10 -2
View File
@@ -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",
+20 -4
View File
@@ -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
)
+68 -13
View File
@@ -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
+29
View File
@@ -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