"""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()