"""Tool builder page - create and edit tools."""
import yaml
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QPushButton,
QGroupBox, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QSplitter, QFrame, QStackedWidget,
QButtonGroup
)
from PySide6.QtCore import Qt
from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
load_tool, save_tool, validate_tool_name, get_all_categories,
ensure_settings
)
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
class ToolBuilderPage(QWidget):
"""Tool builder/editor page."""
def __init__(self, main_window, tool_name: str = None):
super().__init__()
self.main_window = main_window
self.editing = tool_name is not None
self.original_name = tool_name
self._tool = None
self._flow_widget = None # Lazy-loaded
self._setup_ui()
if tool_name:
self._load_tool(tool_name)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("Edit Tool" if self.editing else "Create Tool")
title.setObjectName("heading")
header_layout.addWidget(title)
header_layout.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.setToolTip("Cancel and return to tools page (Escape)")
self.btn_cancel.clicked.connect(self._cancel)
header_layout.addWidget(self.btn_cancel)
self.btn_save = QPushButton("Save")
self.btn_save.setToolTip("Save this tool (Ctrl+S)")
self.btn_save.clicked.connect(self._save)
header_layout.addWidget(self.btn_save)
layout.addWidget(header)
# Main form splitter
splitter = QSplitter(Qt.Horizontal)
# Left: Basic info
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(16)
# Basic info group
info_box = QGroupBox("Basic Information")
info_layout = QFormLayout(info_box)
info_layout.setSpacing(12)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("my-tool")
self.name_input.setToolTip("Unique tool name using lowercase letters, numbers, and hyphens")
info_layout.addRow("Name:", self.name_input)
self.desc_input = QLineEdit()
self.desc_input.setPlaceholderText("A brief description of what this tool does")
self.desc_input.setToolTip("Brief description shown in tool listings and help text")
info_layout.addRow("Description:", self.desc_input)
self.category_combo = QComboBox()
self.category_combo.setEditable(True)
self.category_combo.setToolTip("Category for organizing tools (select or type custom)")
for cat in get_all_categories():
self.category_combo.addItem(cat)
info_layout.addRow("Category:", self.category_combo)
left_layout.addWidget(info_box)
# Arguments group
args_box = QGroupBox()
args_layout = QVBoxLayout(args_box)
args_label = QLabel("Arguments")
args_label.setObjectName("sectionHeading")
args_label.setToolTip(
"
Arguments are command-line flags users can pass to your tool.
"
"Example: Adding --max with variable 'max' lets users run:
"
"my-tool --max 100 < input.txt
"
"Use {max} in your prompts to reference the value.
"
)
args_layout.addWidget(args_label)
self.args_list = QListWidget()
self.args_list.itemDoubleClicked.connect(self._edit_argument)
args_layout.addWidget(self.args_list)
args_btns = QHBoxLayout()
self.btn_add_arg = QPushButton("Add")
self.btn_add_arg.clicked.connect(self._add_argument)
args_btns.addWidget(self.btn_add_arg)
self.btn_edit_arg = QPushButton("Edit")
self.btn_edit_arg.setObjectName("secondary")
self.btn_edit_arg.clicked.connect(self._edit_argument)
args_btns.addWidget(self.btn_edit_arg)
self.btn_del_arg = QPushButton("Delete")
self.btn_del_arg.setObjectName("danger")
self.btn_del_arg.clicked.connect(self._delete_argument)
args_btns.addWidget(self.btn_del_arg)
args_btns.addStretch()
args_layout.addLayout(args_btns)
left_layout.addWidget(args_box)
# Dependencies group (compact - doesn't stretch)
deps_box = QGroupBox()
deps_layout = QVBoxLayout(deps_box)
deps_layout.setContentsMargins(9, 9, 9, 9)
deps_layout.setSpacing(8)
deps_label = QLabel("Dependencies")
deps_label.setObjectName("sectionHeading")
deps_label.setToolTip(
"Declare tools called via subprocess in code steps. "
"Tool steps are automatically included when saved."
)
deps_layout.addWidget(deps_label)
self.deps_list = QListWidget()
self.deps_list.setMaximumHeight(80)
self.deps_list.setMinimumHeight(60)
deps_layout.addWidget(self.deps_list)
# Dependency add row: combo + add button
deps_add_row = QHBoxLayout()
deps_add_row.setSpacing(6)
self.deps_combo = QComboBox()
self.deps_combo.setEditable(True)
self.deps_combo.setPlaceholderText("Select or type tool name...")
self._populate_deps_combo()
deps_add_row.addWidget(self.deps_combo, 1)
self.btn_add_dep = QPushButton("Add")
self.btn_add_dep.clicked.connect(self._add_dependency)
deps_add_row.addWidget(self.btn_add_dep)
self.btn_del_dep = QPushButton("Remove")
self.btn_del_dep.setObjectName("danger")
self.btn_del_dep.clicked.connect(self._delete_dependency)
deps_add_row.addWidget(self.btn_del_dep)
deps_layout.addLayout(deps_add_row)
left_layout.addWidget(deps_box)
# System Dependencies group (compact)
sys_deps_box = QGroupBox()
sys_deps_layout = QVBoxLayout(sys_deps_box)
sys_deps_layout.setContentsMargins(9, 9, 9, 9)
sys_deps_layout.setSpacing(8)
sys_deps_label = QLabel("System Dependencies")
sys_deps_label.setObjectName("sectionHeading")
sys_deps_label.setToolTip(
"System packages (apt, brew, pacman, dnf) required by your tool.\n"
"Users will be prompted to install missing packages."
)
sys_deps_layout.addWidget(sys_deps_label)
self.sys_deps_list = QListWidget()
self.sys_deps_list.setMaximumHeight(80)
self.sys_deps_list.setMinimumHeight(60)
self.sys_deps_list.itemDoubleClicked.connect(self._edit_sys_dep)
sys_deps_layout.addWidget(self.sys_deps_list)
# System dependency buttons
sys_deps_btns = QHBoxLayout()
sys_deps_btns.setSpacing(6)
self.btn_add_sys_dep = QPushButton("Add")
self.btn_add_sys_dep.clicked.connect(self._add_sys_dep)
sys_deps_btns.addWidget(self.btn_add_sys_dep)
self.btn_edit_sys_dep = QPushButton("Edit")
self.btn_edit_sys_dep.setObjectName("secondary")
self.btn_edit_sys_dep.clicked.connect(self._edit_sys_dep)
sys_deps_btns.addWidget(self.btn_edit_sys_dep)
self.btn_del_sys_dep = QPushButton("Remove")
self.btn_del_sys_dep.setObjectName("danger")
self.btn_del_sys_dep.clicked.connect(self._delete_sys_dep)
sys_deps_btns.addWidget(self.btn_del_sys_dep)
sys_deps_btns.addStretch()
sys_deps_layout.addLayout(sys_deps_btns)
left_layout.addWidget(sys_deps_box)
# 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)
# Right: Steps and output
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(16)
# Steps group with view toggle
steps_box = QGroupBox()
steps_layout = QVBoxLayout(steps_box)
# Steps header with label and view toggle
steps_header = QHBoxLayout()
steps_label = QLabel("Steps")
steps_label.setObjectName("sectionHeading")
steps_label.setToolTip(
"Steps define what your tool does, executed in order.
"
"Step types:
"
"- Prompt: Call an AI provider with a template
"
"- Code: Run Python code to process data
"
"- Tool: Call another CmdForge tool
"
"Use {variable} syntax to pass data between steps.
"
"Built-in: {input} contains stdin content.
"
)
steps_header.addWidget(steps_label)
steps_header.addStretch()
# View toggle buttons
self.btn_list_view = QPushButton("List")
self.btn_list_view.setCheckable(True)
self.btn_list_view.setChecked(True)
self.btn_list_view.setObjectName("viewToggle")
self.btn_list_view.setToolTip("View steps as a list (drag to reorder)")
self.btn_list_view.clicked.connect(lambda: self._set_view_mode(0))
self.btn_flow_view = QPushButton("Flow")
self.btn_flow_view.setCheckable(True)
self.btn_flow_view.setObjectName("viewToggle")
self.btn_flow_view.setToolTip("View steps as a visual flow graph")
self.btn_flow_view.clicked.connect(lambda: self._set_view_mode(1))
# Button group for mutual exclusivity
self.view_group = QButtonGroup(self)
self.view_group.addButton(self.btn_list_view, 0)
self.view_group.addButton(self.btn_flow_view, 1)
steps_header.addWidget(self.btn_list_view)
steps_header.addWidget(self.btn_flow_view)
steps_layout.addLayout(steps_header)
# Stacked widget for list/flow views
self.steps_stack = QStackedWidget()
# List view (index 0)
list_container = QWidget()
list_layout = QVBoxLayout(list_container)
list_layout.setContentsMargins(0, 0, 0, 0)
self.steps_list = QListWidget()
self.steps_list.itemDoubleClicked.connect(self._edit_step)
# Enable drag-drop reordering
self.steps_list.setDragDropMode(QListWidget.InternalMove)
self.steps_list.setDefaultDropAction(Qt.MoveAction)
self.steps_list.model().rowsMoved.connect(self._on_steps_reordered)
list_layout.addWidget(self.steps_list)
self.steps_stack.addWidget(list_container)
# Flow view placeholder (index 1) - lazy loaded
flow_placeholder = QLabel("Loading flow visualization...")
flow_placeholder.setAlignment(Qt.AlignCenter)
flow_placeholder.setStyleSheet("color: #718096; padding: 40px;")
self.steps_stack.addWidget(flow_placeholder)
steps_layout.addWidget(self.steps_stack)
# Step action buttons
steps_btns = QHBoxLayout()
self.btn_add_prompt = QPushButton("Add Prompt")
self.btn_add_prompt.setToolTip("Add an AI prompt step - calls your provider with a template")
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
steps_btns.addWidget(self.btn_add_prompt)
self.btn_add_code = QPushButton("Add Code")
self.btn_add_code.setToolTip("Add a Python code step - process or transform data")
self.btn_add_code.clicked.connect(self._add_code_step)
steps_btns.addWidget(self.btn_add_code)
self.btn_add_tool = QPushButton("Add Tool")
self.btn_add_tool.setToolTip("Add a tool step - chain another CmdForge tool")
self.btn_add_tool.clicked.connect(self._add_tool_step)
steps_btns.addWidget(self.btn_add_tool)
self.btn_edit_step = QPushButton("Edit")
self.btn_edit_step.setObjectName("secondary")
self.btn_edit_step.setToolTip("Edit the selected step (or double-click)")
self.btn_edit_step.clicked.connect(self._edit_step)
steps_btns.addWidget(self.btn_edit_step)
self.btn_test_step = QPushButton("Test")
self.btn_test_step.setObjectName("secondary")
self.btn_test_step.setToolTip("Test the selected step with custom input")
self.btn_test_step.clicked.connect(self._test_step)
steps_btns.addWidget(self.btn_test_step)
self.btn_del_step = QPushButton("Delete")
self.btn_del_step.setObjectName("danger")
self.btn_del_step.setToolTip("Delete the selected step")
self.btn_del_step.clicked.connect(self._delete_step)
steps_btns.addWidget(self.btn_del_step)
steps_btns.addStretch()
steps_layout.addLayout(steps_btns)
right_layout.addWidget(steps_box, 1) # Steps box gets stretch priority
# Output group
output_box = QGroupBox()
output_layout = QVBoxLayout(output_box)
output_label = QLabel("Output Template")
output_label.setObjectName("sectionHeading")
output_label.setToolTip(
"The output template defines what your tool prints.
"
"Use {variable} to include step outputs:
"
"- {response} - typical AI response variable
"
"- {result} - common code step output
"
"- Any output_var you defined in steps
"
"Example: {response} prints just the AI response.
"
)
output_layout.addWidget(output_label)
self.output_input = QTextEdit()
self.output_input.setPlaceholderText("Use {variable} to reference step outputs, e.g. {response}")
self.output_input.setPlainText("{response}") # Default value
self.output_input.setMinimumHeight(80)
self.output_input.setMaximumHeight(120)
output_layout.addWidget(self.output_input)
right_layout.addWidget(output_box, 0) # Output box stays fixed size
splitter.addWidget(right)
splitter.setSizes([400, 600])
layout.addWidget(splitter, 1)
def _set_view_mode(self, mode: int):
"""Switch between list (0) and flow (1) views."""
if mode == 1 and self._flow_widget is None:
# Lazy-load flow widget
self._init_flow_widget()
self.steps_stack.setCurrentIndex(mode)
# Update flow widget if visible
if mode == 1 and self._flow_widget:
self._flow_widget.set_tool(self._tool)
def _init_flow_widget(self):
"""Initialize the flow visualization widget."""
try:
from ..widgets.flow_graph import FlowGraphWidget
self._flow_widget = FlowGraphWidget()
self._flow_widget.node_double_clicked.connect(self._on_flow_node_double_clicked)
self._flow_widget.steps_deleted.connect(self._on_flow_steps_deleted)
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
self._flow_widget.step_name_changed.connect(self._on_flow_step_name_changed)
# Replace placeholder
old_widget = self.steps_stack.widget(1)
self.steps_stack.removeWidget(old_widget)
old_widget.deleteLater()
self.steps_stack.insertWidget(1, self._flow_widget)
# Set current tool
if self._tool:
self._flow_widget.set_tool(self._tool)
except ImportError as e:
# NodeGraphQt not available
error_label = QLabel(f"Flow visualization unavailable:\n{e}")
error_label.setAlignment(Qt.AlignCenter)
error_label.setStyleSheet("color: #e53e3e; padding: 40px;")
old_widget = self.steps_stack.widget(1)
self.steps_stack.removeWidget(old_widget)
old_widget.deleteLater()
self.steps_stack.insertWidget(1, error_label)
def _on_flow_node_double_clicked(self, step_index: int, step_type: str):
"""Handle double-click on a flow node."""
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
return
step = self._tool.steps[step_index]
self._edit_step_at_index(step_index, step)
def _on_flow_steps_deleted(self, indices: list):
"""Handle step deletion from flow view."""
if not self._tool:
return
# Delete steps in reverse order (indices are already sorted reverse)
for idx in indices:
if 0 <= idx < len(self._tool.steps):
del self._tool.steps[idx]
# Refresh the list view (flow view will be refreshed by set_tool)
self._refresh_steps()
def _on_flow_step_name_changed(self, step_index: int, new_name: str):
"""Handle step name change from flow view inline editing."""
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
return
step = self._tool.steps[step_index]
step.name = new_name
# Refresh list view to show updated name
self._refresh_steps_list_only()
def _on_flow_steps_reordered(self, new_order: list):
"""Handle step reordering from flow view.
Args:
new_order: List of old indices in new order.
e.g., [0, 2, 1] means step 0 stays first,
step 2 moves to second, step 1 moves to third.
"""
if not self._tool or not self._tool.steps:
return
# Reorder steps according to new_order
old_steps = self._tool.steps[:]
new_steps = [old_steps[i] for i in new_order if i < len(old_steps)]
# Validate the new order
warnings = self._validate_step_order(new_steps)
if warnings:
# Show warning but allow the reorder
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
QMessageBox.warning(self, "Dependency Warning", warning_msg)
self._tool.steps = new_steps
# Refresh both views
self._refresh_steps()
def _load_tool(self, name: str):
"""Load an existing tool for editing."""
tool = load_tool(name)
if not tool:
QMessageBox.critical(self, "Error", f"Tool '{name}' not found")
self._cancel()
return
self._tool = tool
self.name_input.setText(tool.name)
self.name_input.setEnabled(False) # Can't rename
self.desc_input.setText(tool.description or "")
# Set category
if tool.category:
idx = self.category_combo.findText(tool.category)
if idx >= 0:
self.category_combo.setCurrentIndex(idx)
else:
self.category_combo.setCurrentText(tool.category)
# Load arguments
self._refresh_arguments()
# Load dependencies
self._refresh_dependencies()
# Load system dependencies
self._refresh_sys_deps()
# Load steps
self._refresh_steps()
# 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()
if self._tool and self._tool.arguments:
for arg in self._tool.arguments:
item = QListWidgetItem(f"{arg.flag} → ${arg.variable}")
item.setData(Qt.UserRole, arg)
self.args_list.addItem(item)
def _refresh_steps(self):
"""Refresh steps list and flow view."""
self.steps_list.clear()
if self._tool and self._tool.steps:
# Count steps by type for default naming
prompt_count = 0
code_count = 0
# Track available variables for dependency checking
available_vars = {"input"}
if self._tool.arguments:
for arg in self._tool.arguments:
if arg.variable:
available_vars.add(arg.variable)
# Count for default naming
tool_count = 0
for i, step in enumerate(self._tool.steps, 1):
if isinstance(step, PromptStep):
prompt_count += 1
# Use custom name if set, otherwise default per-type naming
step_name = step.name if step.name else f"Prompt {prompt_count}"
text = f"{step_name} [{step.provider}] → ${step.output_var}"
icon = get_prompt_icon(20)
elif isinstance(step, CodeStep):
code_count += 1
step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20)
elif isinstance(step, ToolStep):
tool_count += 1
step_name = step.name if step.name else f"Tool {tool_count}"
text = f"{step_name} [{step.tool}] → ${step.output_var}"
icon = get_tool_icon(20)
else:
text = f"Unknown step"
icon = None
item = QListWidgetItem(text)
if icon:
item.setIcon(icon)
item.setData(Qt.UserRole, step)
# Check for broken dependencies
refs = self._get_step_variable_refs(step)
broken_refs = [r for r in refs if r not in available_vars]
if broken_refs:
# Mark item with warning color and tooltip
item.setForeground(Qt.red)
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
self.steps_list.addItem(item)
# Add this step's output to available vars for next iteration
if hasattr(step, 'output_var') and step.output_var:
for var in step.output_var.split(','):
available_vars.add(var.strip())
# Update flow widget if initialized
if self._flow_widget:
self._flow_widget.set_tool(self._tool)
def _refresh_steps_list_only(self):
"""Refresh only the steps list (not flow view).
Used when a change originated from the flow view to avoid
rebuilding the graph unnecessarily.
"""
self.steps_list.clear()
if self._tool and self._tool.steps:
# Count steps by type for default naming
prompt_count = 0
code_count = 0
tool_count = 0
# Track available variables for dependency checking
available_vars = {"input"}
if self._tool.arguments:
for arg in self._tool.arguments:
if arg.variable:
available_vars.add(arg.variable)
for i, step in enumerate(self._tool.steps, 1):
if isinstance(step, PromptStep):
prompt_count += 1
step_name = step.name if step.name else f"Prompt {prompt_count}"
text = f"{step_name} [{step.provider}] → ${step.output_var}"
icon = get_prompt_icon(20)
elif isinstance(step, CodeStep):
code_count += 1
step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20)
elif isinstance(step, ToolStep):
tool_count += 1
step_name = step.name if step.name else f"Tool {tool_count}"
text = f"{step_name} [{step.tool}] → ${step.output_var}"
icon = get_tool_icon(20)
else:
text = f"Unknown step"
icon = None
item = QListWidgetItem(text)
if icon:
item.setIcon(icon)
item.setData(Qt.UserRole, step)
# Check for broken dependencies
refs = self._get_step_variable_refs(step)
broken_refs = [r for r in refs if r not in available_vars]
if broken_refs:
item.setForeground(Qt.red)
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
self.steps_list.addItem(item)
# Add this step's output to available vars for next iteration
if hasattr(step, 'output_var') and step.output_var:
for var in step.output_var.split(','):
available_vars.add(var.strip())
def _add_argument(self):
"""Add a new argument."""
from ..dialogs.argument_dialog import ArgumentDialog
dialog = ArgumentDialog(self)
if dialog.exec():
arg = dialog.get_argument()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.arguments.append(arg)
self._refresh_arguments()
def _edit_argument(self):
"""Edit selected argument."""
items = self.args_list.selectedItems()
if not items:
return
arg = items[0].data(Qt.UserRole)
idx = self.args_list.row(items[0])
from ..dialogs.argument_dialog import ArgumentDialog
dialog = ArgumentDialog(self, arg)
if dialog.exec():
self._tool.arguments[idx] = dialog.get_argument()
self._refresh_arguments()
def _delete_argument(self):
"""Delete selected argument."""
items = self.args_list.selectedItems()
if not items:
return
idx = self.args_list.row(items[0])
del self._tool.arguments[idx]
self._refresh_arguments()
def _populate_deps_combo(self):
"""Populate dependencies combo with installed tools."""
from ...tool import TOOLS_DIR
self.deps_combo.clear()
# Get all installed tools
tools = []
if TOOLS_DIR.exists():
for item in TOOLS_DIR.iterdir():
if item.is_dir():
config = item / "config.yaml"
if config.exists():
tools.append(item.name)
# Sort and add to combo
for tool in sorted(tools):
self.deps_combo.addItem(tool)
def _add_dependency(self):
"""Add selected tool to dependencies."""
tool_ref = self.deps_combo.currentText().strip()
if not tool_ref:
return
if not self._tool:
# Create a minimal tool object if none exists
self._tool = Tool(name="", description="")
# Initialize dependencies list if needed
if not hasattr(self._tool, 'dependencies') or self._tool.dependencies is None:
self._tool.dependencies = []
# Check if already in list
if tool_ref in self._tool.dependencies:
self.main_window.show_status(f"'{tool_ref}' is already a dependency")
return
self._tool.dependencies.append(tool_ref)
self._refresh_dependencies()
self.deps_combo.setCurrentText("")
self.main_window.show_status(f"Added dependency: {tool_ref}")
def _delete_dependency(self):
"""Remove selected dependency."""
items = self.deps_list.selectedItems()
if not items:
return
dep = items[0].text()
if self._tool and self._tool.dependencies and dep in self._tool.dependencies:
self._tool.dependencies.remove(dep)
self._refresh_dependencies()
def _refresh_dependencies(self):
"""Refresh dependencies list widget."""
self.deps_list.clear()
if self._tool and self._tool.dependencies:
for dep in self._tool.dependencies:
self.deps_list.addItem(dep)
def _add_sys_dep(self):
"""Add a new system dependency."""
from ..dialogs.system_dep_dialog import SystemDependencyDialog
dialog = SystemDependencyDialog(self)
if dialog.exec():
dep = dialog.get_dependency()
if not self._tool:
from ...tool import Tool
self._tool = Tool(name="", description="")
if not hasattr(self._tool, 'system_dependencies') or self._tool.system_dependencies is None:
self._tool.system_dependencies = []
# Check if already exists
existing_names = [d.name for d in self._tool.system_dependencies]
if dep.name in existing_names:
self.main_window.show_status(f"'{dep.name}' is already a system dependency")
return
self._tool.system_dependencies.append(dep)
self._refresh_sys_deps()
self.main_window.show_status(f"Added system dependency: {dep.name}")
def _edit_sys_dep(self):
"""Edit selected system dependency."""
items = self.sys_deps_list.selectedItems()
if not items:
return
idx = self.sys_deps_list.row(items[0])
if not self._tool or idx >= len(self._tool.system_dependencies):
return
dep = self._tool.system_dependencies[idx]
from ..dialogs.system_dep_dialog import SystemDependencyDialog
dialog = SystemDependencyDialog(self, dep)
if dialog.exec():
self._tool.system_dependencies[idx] = dialog.get_dependency()
self._refresh_sys_deps()
def _delete_sys_dep(self):
"""Remove selected system dependency."""
items = self.sys_deps_list.selectedItems()
if not items:
return
idx = self.sys_deps_list.row(items[0])
if self._tool and self._tool.system_dependencies and idx < len(self._tool.system_dependencies):
del self._tool.system_dependencies[idx]
self._refresh_sys_deps()
def _refresh_sys_deps(self):
"""Refresh system dependencies list widget."""
self.sys_deps_list.clear()
if self._tool and self._tool.system_dependencies:
for dep in self._tool.system_dependencies:
# Show name and description if available
text = dep.name
if dep.description:
text += f" - {dep.description}"
self.sys_deps_list.addItem(text)
def _add_tool_dependency(self, tool_ref: str):
"""Add a tool reference to the dependencies list if not already present.
Args:
tool_ref: Tool reference (e.g., 'my-tool' or 'owner/tool-name')
"""
if not self._tool or not tool_ref:
return
# Initialize dependencies list if needed
if not hasattr(self._tool, 'dependencies') or self._tool.dependencies is None:
self._tool.dependencies = []
# Add if not already present
if tool_ref not in self._tool.dependencies:
self._tool.dependencies.append(tool_ref)
def _get_available_vars(self, up_to_step: int = -1) -> list:
"""Get available variables for a step.
Args:
up_to_step: Include variables from steps before this index.
-1 means include all existing steps (for new steps).
"""
vars_list = ["input"]
# Add argument variables
if self._tool and self._tool.arguments:
for arg in self._tool.arguments:
if arg.variable and arg.variable not in vars_list:
vars_list.append(arg.variable)
# Add output variables from previous steps
if self._tool and self._tool.steps:
steps_to_check = self._tool.steps if up_to_step < 0 else self._tool.steps[:up_to_step]
for step in steps_to_check:
if hasattr(step, 'output_var') and step.output_var:
# Handle comma-separated output vars
for var in step.output_var.split(','):
var = var.strip()
if var and var not in vars_list:
vars_list.append(var)
return vars_list
def _add_prompt_step(self):
"""Add a prompt step."""
from ..dialogs.step_dialog import PromptStepDialog
dialog = PromptStepDialog(self)
if dialog.exec():
step = dialog.get_step()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.steps.append(step)
self._refresh_steps()
def _add_code_step(self):
"""Add a code step."""
from ..dialogs.step_dialog import CodeStepDialog
available_vars = self._get_available_vars()
dialog = CodeStepDialog(self, available_vars=available_vars)
if dialog.exec():
step = dialog.get_step()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.steps.append(step)
self._refresh_steps()
def _add_tool_step(self):
"""Add a tool step (call another tool)."""
from ..dialogs.step_dialog import ToolStepDialog
available_vars = self._get_available_vars()
current_name = self._tool.name if self._tool else None
dialog = ToolStepDialog(self, available_vars=available_vars, current_tool_name=current_name)
if dialog.exec():
step = dialog.get_step()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.steps.append(step)
# Auto-add to dependencies if not already present
self._add_tool_dependency(step.tool)
self._refresh_steps()
def _edit_step(self):
"""Edit selected step from list view."""
items = self.steps_list.selectedItems()
if not items:
return
step = items[0].data(Qt.UserRole)
idx = self.steps_list.row(items[0])
self._edit_step_at_index(idx, step)
def _edit_step_at_index(self, idx: int, step):
"""Edit a step at a specific index."""
if isinstance(step, PromptStep):
from ..dialogs.step_dialog import PromptStepDialog
dialog = PromptStepDialog(self, step)
elif isinstance(step, CodeStep):
from ..dialogs.step_dialog import CodeStepDialog
available_vars = self._get_available_vars(up_to_step=idx)
dialog = CodeStepDialog(self, step, available_vars=available_vars)
elif isinstance(step, ToolStep):
from ..dialogs.step_dialog import ToolStepDialog
available_vars = self._get_available_vars(up_to_step=idx)
current_name = self._tool.name if self._tool else None
dialog = ToolStepDialog(self, step, available_vars=available_vars, current_tool_name=current_name)
else:
return
if dialog.exec():
new_step = dialog.get_step()
self._tool.steps[idx] = new_step
# Auto-add to dependencies if it's a ToolStep
if isinstance(new_step, ToolStep):
self._add_tool_dependency(new_step.tool)
self._refresh_steps()
def _test_step(self):
"""Test the selected step with custom input."""
items = self.steps_list.selectedItems()
if not items:
QMessageBox.information(self, "Test Step", "Please select a step to test")
return
step = items[0].data(Qt.UserRole)
idx = self.steps_list.row(items[0])
available_vars = self._get_available_vars(up_to_step=idx)
from ..dialogs.test_step_dialog import TestStepDialog
dialog = TestStepDialog(self, step, available_vars=available_vars)
dialog.exec()
def _delete_step(self):
"""Delete selected step."""
items = self.steps_list.selectedItems()
if not items:
return
idx = self.steps_list.row(items[0])
del self._tool.steps[idx]
self._refresh_steps()
def _on_steps_reordered(self, parent, start, end, dest, row):
"""Handle step reordering via drag-drop."""
if not self._tool or not self._tool.steps:
return
# Rebuild steps list from current list widget order
new_steps = []
for i in range(self.steps_list.count()):
item = self.steps_list.item(i)
step = item.data(Qt.UserRole)
if step:
new_steps.append(step)
# Validate the new order
warnings = self._validate_step_order(new_steps)
if warnings:
# Show warning but allow the reorder
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
QMessageBox.warning(self, "Dependency Warning", warning_msg)
# Apply the new order
self._tool.steps = new_steps
# Refresh to update numbering and flow view
self._refresh_steps()
def _validate_step_order(self, steps: list) -> list:
"""Validate step order for variable dependencies.
Returns a list of warning messages for broken dependencies.
"""
warnings = []
# Track available variables at each position
available = {"input"}
# Add argument variables
if self._tool and self._tool.arguments:
for arg in self._tool.arguments:
if arg.variable:
available.add(arg.variable)
for i, step in enumerate(steps):
# Get variables this step references
refs = self._get_step_variable_refs(step)
# Check for undefined references
for ref in refs:
if ref not in available:
step_name = step.name if step.name else f"Step {i+1}"
warnings.append(f"{step_name}: references '{{{ref}}}' which is not defined yet")
# Add this step's output to available vars
if hasattr(step, 'output_var') and step.output_var:
for var in step.output_var.split(','):
available.add(var.strip())
return warnings
def _get_step_variable_refs(self, step) -> set:
"""Extract variable references from a step.
Parses {variable} patterns from prompt templates.
Note: CodeSteps are NOT parsed because variables are available directly
as Python variables, and {var} in code is typically Python f-string
or .format() syntax, not CmdForge substitution.
"""
import re
refs = set()
# Pattern to match {variable} but not {{escaped}}
pattern = r'\{(\w+)\}'
if isinstance(step, PromptStep):
# Parse prompt template - these ARE CmdForge substitutions
matches = re.findall(pattern, step.prompt or "")
refs.update(matches)
# CodeStep: Don't parse - variables are available as Python vars directly
# and {var} syntax is typically Python string formatting
return refs
def _save(self):
"""Save the tool."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Validation", "Tool name is required")
return
# Validate name
is_valid, error = validate_tool_name(name)
if not is_valid:
QMessageBox.warning(self, "Validation", error)
return
description = self.desc_input.text().strip()
category = self.category_combo.currentText()
output = self.output_input.toPlainText().strip() or "{response}"
# Build tool object
tool = Tool(
name=name,
description=description,
category=category,
arguments=self._tool.arguments if self._tool else [],
steps=self._tool.steps if self._tool else [],
output=output,
dependencies=self._tool.dependencies if self._tool else [],
system_dependencies=self._tool.system_dependencies if self._tool else []
)
# Preserve source if editing
if self._tool and self._tool.source:
tool.source = self._tool.source
# Preserve version if editing
if self._tool and self._tool.version:
tool.version = self._tool.version
try:
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:
QMessageBox.critical(self, "Error", f"Failed to save tool:\n{e}")
def _cancel(self):
"""Cancel and return to tools page."""
self.main_window.close_tool_builder()
def save_tool(self):
"""Public method for keyboard shortcut to save the tool."""
self._save()