Add tool composition UI with auto-dependency management

- Add ToolStepDialog for configuring tool steps (tool selection, input mapping, args)
- Add "Add Tool" button in Tool Builder alongside Add Prompt/Add Code
- Add ToolNode in flow graph visualization (purple puzzle piece icon)
- Auto-populate dependencies when adding ToolStep in GUI
- Add --auto-install flag for automatic runtime dependency installation
- Add check_dependencies() and auto_install_dependencies() functions
- Update runner to pass auto_install parameter through execution chain

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-17 05:27:54 -04:00
parent 05a2fae94c
commit 3f259449d4
5 changed files with 435 additions and 11 deletions

View File

@ -9,7 +9,7 @@ from PySide6.QtWidgets import (
) )
from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtCore import Qt, QThread, Signal
from ...tool import PromptStep, CodeStep from ...tool import PromptStep, CodeStep, ToolStep, list_tools, load_tool
from ...providers import load_providers, call_provider from ...providers import load_providers, call_provider
from ...profiles import list_profiles from ...profiles import list_profiles
@ -424,3 +424,218 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
output_var=self.output_input.text().strip(), output_var=self.output_input.text().strip(),
name=name name=name
) )
class ToolStepDialog(QDialog):
"""Dialog for adding/editing tool steps (calling another tool)."""
def __init__(self, parent, step: ToolStep = None, available_vars: list = None, current_tool_name: str = None):
super().__init__(parent)
self.setWindowTitle("Edit Tool Step" if step else "Add Tool Step")
self.setMinimumSize(550, 500)
self._step = step
self._available_vars = available_vars or ["input"]
self._current_tool_name = current_tool_name # To prevent self-referencing
self._tool_args = {} # Cache of tool arguments
self._setup_ui()
if step:
self._load_step(step)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Form
form = QFormLayout()
form.setSpacing(12)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
# Tool selection
self.tool_combo = QComboBox()
self._populate_tools()
self.tool_combo.currentTextChanged.connect(self._on_tool_changed)
form.addRow("Tool:", self.tool_combo)
# Tool description (read-only)
self.tool_desc = QLabel("")
self.tool_desc.setStyleSheet("color: #718096; font-size: 11px;")
self.tool_desc.setWordWrap(True)
form.addRow("", self.tool_desc)
# Output variable
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("tool_output")
self.output_input.setText("tool_output")
form.addRow("Output variable:", self.output_input)
# Provider override (optional)
self.provider_combo = QComboBox()
self.provider_combo.addItem("(use tool's default)")
providers = load_providers()
for provider in sorted(providers, key=lambda p: p.name):
self.provider_combo.addItem(provider.name)
form.addRow("Provider override:", self.provider_combo)
layout.addLayout(form)
# Input template
input_group = QGroupBox("Input")
input_layout = QVBoxLayout(input_group)
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
input_help = QLabel(f"Available variables: {vars_text}")
input_help.setStyleSheet("color: #718096; font-size: 11px;")
input_layout.addWidget(input_help)
self.input_template = QPlainTextEdit()
self.input_template.setPlaceholderText("{input}")
self.input_template.setPlainText("{input}")
self.input_template.setMaximumHeight(80)
input_layout.addWidget(self.input_template)
layout.addWidget(input_group)
# Tool arguments
self.args_group = QGroupBox("Tool Arguments")
self.args_layout = QFormLayout(self.args_group)
self.args_layout.setSpacing(8)
self._arg_inputs = {} # variable -> QLineEdit
layout.addWidget(self.args_group)
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("OK")
self.btn_ok.clicked.connect(self._validate_and_accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
# Trigger initial tool selection
if self.tool_combo.count() > 0:
self._on_tool_changed(self.tool_combo.currentText())
def _populate_tools(self):
"""Populate the tool dropdown."""
tools = list_tools()
for tool_name in sorted(tools):
# Skip the current tool to prevent self-referencing
if tool_name != self._current_tool_name:
self.tool_combo.addItem(tool_name)
def _on_tool_changed(self, tool_name: str):
"""Handle tool selection change."""
# Clear existing arg inputs
for widget in self._arg_inputs.values():
self.args_layout.removeRow(widget)
self._arg_inputs.clear()
if not tool_name:
self.tool_desc.setText("")
return
tool = load_tool(tool_name)
if not tool:
self.tool_desc.setText("(Tool not found)")
return
# Update description
self.tool_desc.setText(tool.description or "(No description)")
# Cache arguments
self._tool_args[tool_name] = tool.arguments
# Create input fields for each argument
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
for arg in tool.arguments:
line = QLineEdit()
line.setPlaceholderText(f"Default: {arg.default}" if arg.default else f"Variable: {vars_text}")
if arg.default:
line.setText(arg.default)
self._arg_inputs[arg.variable] = line
label = f"{arg.flag}:"
if arg.description:
label = f"{arg.flag} ({arg.description}):"
self.args_layout.addRow(label, line)
def _load_step(self, step: ToolStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
# Select tool
idx = self.tool_combo.findText(step.tool)
if idx >= 0:
self.tool_combo.setCurrentIndex(idx)
else:
# Tool might be qualified name - try to add it
self.tool_combo.addItem(step.tool)
self.tool_combo.setCurrentText(step.tool)
self.output_input.setText(step.output_var)
self.input_template.setPlainText(step.input_template)
# Set provider override
if step.provider:
idx = self.provider_combo.findText(step.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
# Set argument values (after tool is selected and args are loaded)
for var, value in step.args.items():
if var in self._arg_inputs:
self._arg_inputs[var].setText(str(value))
def _validate_and_accept(self):
"""Validate and accept."""
tool = self.tool_combo.currentText().strip()
if not tool:
self.tool_combo.setFocus()
return
output = self.output_input.text().strip()
if not output:
self.output_input.setFocus()
return
self.accept()
def get_step(self) -> ToolStep:
"""Get the step from form data."""
# Collect arguments
args = {}
for var, line in self._arg_inputs.items():
value = line.text().strip()
if value:
args[var] = value
# Get provider override
provider = self.provider_combo.currentText()
if provider == "(use tool's default)":
provider = None
# Get name, use None if empty
name = self.name_input.text().strip() or None
return ToolStep(
tool=self.tool_combo.currentText(),
output_var=self.output_input.text().strip(),
input_template=self.input_template.toPlainText() or "{input}",
args=args,
provider=provider,
name=name
)

View File

@ -10,10 +10,10 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from ...tool import ( from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
) )
from ..widgets.icons import get_prompt_icon, get_code_icon from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
class ToolBuilderPage(QWidget): class ToolBuilderPage(QWidget):
@ -190,6 +190,10 @@ class ToolBuilderPage(QWidget):
self.btn_add_code.clicked.connect(self._add_code_step) self.btn_add_code.clicked.connect(self._add_code_step)
steps_btns.addWidget(self.btn_add_code) steps_btns.addWidget(self.btn_add_code)
self.btn_add_tool = QPushButton("Add 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 = QPushButton("Edit")
self.btn_edit_step.setObjectName("secondary") self.btn_edit_step.setObjectName("secondary")
self.btn_edit_step.clicked.connect(self._edit_step) self.btn_edit_step.clicked.connect(self._edit_step)
@ -379,6 +383,9 @@ class ToolBuilderPage(QWidget):
if arg.variable: if arg.variable:
available_vars.add(arg.variable) available_vars.add(arg.variable)
# Count for default naming
tool_count = 0
for i, step in enumerate(self._tool.steps, 1): for i, step in enumerate(self._tool.steps, 1):
if isinstance(step, PromptStep): if isinstance(step, PromptStep):
prompt_count += 1 prompt_count += 1
@ -391,6 +398,11 @@ class ToolBuilderPage(QWidget):
step_name = step.name if step.name else f"Code {code_count}" step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}" text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20) 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: else:
text = f"Unknown step" text = f"Unknown step"
icon = None icon = None
@ -430,6 +442,7 @@ class ToolBuilderPage(QWidget):
# Count steps by type for default naming # Count steps by type for default naming
prompt_count = 0 prompt_count = 0
code_count = 0 code_count = 0
tool_count = 0
# Track available variables for dependency checking # Track available variables for dependency checking
available_vars = {"input"} available_vars = {"input"}
@ -449,6 +462,11 @@ class ToolBuilderPage(QWidget):
step_name = step.name if step.name else f"Code {code_count}" step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}" text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20) 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: else:
text = f"Unknown step" text = f"Unknown step"
icon = None icon = None
@ -508,6 +526,23 @@ class ToolBuilderPage(QWidget):
del self._tool.arguments[idx] del self._tool.arguments[idx]
self._refresh_arguments() self._refresh_arguments()
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: def _get_available_vars(self, up_to_step: int = -1) -> list:
"""Get available variables for a step. """Get available variables for a step.
@ -559,6 +594,21 @@ class ToolBuilderPage(QWidget):
self._tool.steps.append(step) self._tool.steps.append(step)
self._refresh_steps() 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): def _edit_step(self):
"""Edit selected step from list view.""" """Edit selected step from list view."""
items = self.steps_list.selectedItems() items = self.steps_list.selectedItems()
@ -578,11 +628,20 @@ class ToolBuilderPage(QWidget):
from ..dialogs.step_dialog import CodeStepDialog from ..dialogs.step_dialog import CodeStepDialog
available_vars = self._get_available_vars(up_to_step=idx) available_vars = self._get_available_vars(up_to_step=idx)
dialog = CodeStepDialog(self, step, available_vars=available_vars) 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: else:
return return
if dialog.exec(): if dialog.exec():
self._tool.steps[idx] = dialog.get_step() 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() self._refresh_steps()
def _delete_step(self): def _delete_step(self):

View File

@ -8,10 +8,10 @@ from PySide6.QtGui import QKeyEvent, QAction
from NodeGraphQt import NodeGraph, BaseNode from NodeGraphQt import NodeGraph, BaseNode
from ...tool import Tool, PromptStep, CodeStep from ...tool import Tool, PromptStep, CodeStep, ToolStep
from .icons import ( from .icons import (
get_prompt_icon_path, get_code_icon_path, get_prompt_icon_path, get_code_icon_path,
get_input_icon_path, get_output_icon_path get_input_icon_path, get_output_icon_path, get_tool_icon_path
) )
@ -105,6 +105,33 @@ class CodeNode(CmdForgeBaseNode):
self.output_ports()[0]._name = step.output_var or 'result' self.output_ports()[0]._name = step.output_var or 'result'
class ToolNode(CmdForgeBaseNode):
"""Node representing a tool step (calling another tool)."""
NODE_NAME = 'Tool'
def __init__(self):
super().__init__()
self.set_color(159, 122, 234) # Purple
self.set_icon(get_tool_icon_path(16))
self.add_input('in', color=(220, 180, 250))
self.add_output('out', color=(250, 180, 220))
# Add properties
self.add_text_input('tool_ref', 'Tool', text='')
self.add_text_input('output_var', 'Output', text='tool_output')
def set_step(self, step: ToolStep, index: int):
"""Configure node from a ToolStep."""
self._step_data = step
self._step_index = index
self.set_property('tool_ref', step.tool or '')
self.set_property('output_var', step.output_var or 'tool_output')
# Update output port name
if self.output_ports():
self.output_ports()[0]._name = step.output_var or 'tool_output'
class OutputNode(CmdForgeBaseNode): class OutputNode(CmdForgeBaseNode):
"""Node representing tool output.""" """Node representing tool output."""
@ -168,6 +195,7 @@ class FlowGraphWidget(QWidget):
self._graph.register_node(InputNode) self._graph.register_node(InputNode)
self._graph.register_node(PromptNode) self._graph.register_node(PromptNode)
self._graph.register_node(CodeNode) self._graph.register_node(CodeNode)
self._graph.register_node(ToolNode)
self._graph.register_node(OutputNode) self._graph.register_node(OutputNode)
# Connect signals # Connect signals
@ -265,6 +293,7 @@ class FlowGraphWidget(QWidget):
# Count steps by type for default naming # Count steps by type for default naming
prompt_count = 0 prompt_count = 0
code_count = 0 code_count = 0
tool_count = 0
for i, step in enumerate(self._tool.steps or []): for i, step in enumerate(self._tool.steps or []):
if isinstance(step, PromptStep): if isinstance(step, PromptStep):
@ -287,6 +316,16 @@ class FlowGraphWidget(QWidget):
pos=[x_pos, 0] pos=[x_pos, 0]
) )
node.set_step(step, i) node.set_step(step, i)
elif isinstance(step, ToolStep):
tool_count += 1
# Use custom name if set, otherwise default to "Tool N" (per type)
node_name = step.name if step.name else f'Tool {tool_count}'
node = self._graph.create_node(
'cmdforge.ToolNode',
name=node_name,
pos=[x_pos, 0]
)
node.set_step(step, i)
else: else:
continue continue
@ -443,7 +482,12 @@ class FlowGraphWidget(QWidget):
def _on_node_double_clicked(self, node): def _on_node_double_clicked(self, node):
"""Handle node double-click.""" """Handle node double-click."""
if hasattr(node, '_step_index') and node._step_index >= 0: if hasattr(node, '_step_index') and node._step_index >= 0:
step_type = 'prompt' if isinstance(node, PromptNode) else 'code' if isinstance(node, PromptNode):
step_type = 'prompt'
elif isinstance(node, ToolNode):
step_type = 'tool'
else:
step_type = 'code'
self.node_double_clicked.emit(node._step_index, step_type) self.node_double_clicked.emit(node._step_index, step_type)
def _on_nodes_deleted(self, nodes): def _on_nodes_deleted(self, nodes):

View File

@ -273,3 +273,63 @@ def get_output_icon(size: int = 24) -> QIcon:
if key not in _icon_cache: if key not in _icon_cache:
_icon_cache[key] = create_output_icon(size) _icon_cache[key] = create_output_icon(size)
return _icon_cache[key] return _icon_cache[key]
def create_tool_icon(size: int = 24, color: QColor = None) -> QIcon:
"""Create a puzzle piece icon for tool steps (calling another tool).
Args:
size: Icon size in pixels
color: Icon color (defaults to purple #9f7aea)
"""
if color is None:
color = QColor(159, 122, 234) # Purple
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
pen = QPen(color, 1.5)
painter.setPen(pen)
painter.setBrush(QBrush(color))
# Draw a simple puzzle piece shape
margin = size * 0.12
w = size - 2 * margin
h = size - 2 * margin
path = QPainterPath()
# Start from top-left, going clockwise
path.moveTo(margin, margin + h * 0.3)
# Top edge with bump
path.lineTo(margin + w * 0.35, margin + h * 0.3)
path.arcTo(QRectF(margin + w * 0.35 - w * 0.1, margin, w * 0.3, h * 0.3), 180, -180)
path.lineTo(margin + w, margin + h * 0.3)
# Right edge
path.lineTo(margin + w, margin + h * 0.7)
# Bottom edge with notch
path.lineTo(margin + w * 0.65, margin + h * 0.7)
path.arcTo(QRectF(margin + w * 0.35, margin + h * 0.7, w * 0.3, h * 0.3), 0, 180)
path.lineTo(margin, margin + h * 0.7)
# Left edge back to start
path.closeSubpath()
painter.drawPath(path)
painter.end()
return QIcon(pixmap)
def get_tool_icon(size: int = 24) -> QIcon:
"""Get cached tool icon."""
key = ('tool', size)
if key not in _icon_cache:
_icon_cache[key] = create_tool_icon(size)
return _icon_cache[key]
def get_tool_icon_path(size: int = 16) -> str:
"""Get file path to tool icon (for NodeGraphQt)."""
return _get_icon_path('tool', size, create_tool_icon)

View File

@ -7,7 +7,7 @@ from typing import Optional
from .tool import Tool, PromptStep, CodeStep, ToolStep from .tool import Tool, PromptStep, CodeStep, ToolStep
from .providers import call_provider, mock_provider from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
from .manifest import load_manifest from .manifest import load_manifest
from .profiles import load_profile from .profiles import load_profile
@ -15,6 +15,36 @@ from .profiles import load_profile
MAX_TOOL_DEPTH = 10 MAX_TOOL_DEPTH = 10
def auto_install_dependencies(tool: Tool, verbose: bool = False) -> list[str]:
"""
Automatically install missing dependencies for a tool.
Args:
tool: Tool to check and install dependencies for
verbose: Show installation progress
Returns:
List of successfully installed tool references
"""
missing = check_dependencies(tool)
if not missing:
return []
installed = []
for dep in missing:
try:
if verbose:
print(f"[auto-install] Installing {dep}...", file=sys.stderr)
install_from_registry(dep)
installed.append(dep)
if verbose:
print(f"[auto-install] Installed {dep}", file=sys.stderr)
except Exception as e:
print(f"Warning: Failed to auto-install {dep}: {e}", file=sys.stderr)
return installed
def check_dependencies(tool: Tool, checked: set = None) -> list[str]: def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
""" """
Check if all dependencies for a tool are available. Check if all dependencies for a tool are available.
@ -293,6 +323,7 @@ def run_tool(
dry_run: bool = False, dry_run: bool = False,
show_prompt: bool = False, show_prompt: bool = False,
verbose: bool = False, verbose: bool = False,
auto_install: bool = False,
_depth: int = 0, _depth: int = 0,
_call_stack: Optional[list] = None _call_stack: Optional[list] = None
) -> tuple[str, int]: ) -> tuple[str, int]:
@ -307,6 +338,7 @@ def run_tool(
dry_run: Just show what would happen dry_run: Just show what would happen
show_prompt: Show prompts in addition to output show_prompt: Show prompts in addition to output
verbose: Show debug info verbose: Show debug info
auto_install: Automatically install missing dependencies
_depth: Internal recursion depth tracker _depth: Internal recursion depth tracker
_call_stack: Internal call stack for error reporting _call_stack: Internal call stack for error reporting
@ -322,8 +354,19 @@ def run_tool(
if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)): if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)):
missing = check_dependencies(tool) missing = check_dependencies(tool)
if missing: if missing:
if auto_install:
# Try to auto-install missing dependencies
installed = auto_install_dependencies(tool, verbose=verbose)
# Re-check after installation
still_missing = check_dependencies(tool)
if still_missing:
print(f"Warning: Could not install all dependencies. Missing: {', '.join(still_missing)}", file=sys.stderr)
elif installed:
print(f"Auto-installed dependencies: {', '.join(installed)}", file=sys.stderr)
else:
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr) print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr) print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr)
print(f"Or use --auto-install to install automatically", file=sys.stderr)
# Continue anyway - the actual step execution will fail with a better error # Continue anyway - the actual step execution will fail with a better error
# Initialize variables with input and arguments # Initialize variables with input and arguments
@ -448,6 +491,8 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
help="Override provider (e.g., --provider mock)") help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true", parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information") help="Show debug information")
parser.add_argument("--auto-install", action="store_true",
help="Automatically install missing tool dependencies")
# Tool-specific flags from arguments # Tool-specific flags from arguments
for arg in tool.arguments: for arg in tool.arguments:
@ -527,7 +572,8 @@ def main():
provider_override=effective_provider, provider_override=effective_provider,
dry_run=args.dry_run, dry_run=args.dry_run,
show_prompt=args.show_prompt, show_prompt=args.show_prompt,
verbose=args.verbose verbose=args.verbose,
auto_install=args.auto_install
) )
# Write output # Write output