327 lines
9.2 KiB
Python
327 lines
9.2 KiB
Python
"""Tool loading, saving, and management."""
|
|
|
|
import os
|
|
import stat
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional, List, Literal
|
|
|
|
import yaml
|
|
|
|
|
|
# Default tools directory
|
|
TOOLS_DIR = Path.home() / ".smarttools"
|
|
|
|
# Default bin directory for wrapper scripts
|
|
BIN_DIR = Path.home() / ".local" / "bin"
|
|
|
|
|
|
@dataclass
|
|
class ToolArgument:
|
|
"""Definition of a custom input argument."""
|
|
flag: str # e.g., "--max-size"
|
|
variable: str # e.g., "max_size"
|
|
default: Optional[str] = None
|
|
description: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
d = {"flag": self.flag, "variable": self.variable}
|
|
if self.default:
|
|
d["default"] = self.default
|
|
if self.description:
|
|
d["description"] = self.description
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "ToolArgument":
|
|
return cls(
|
|
flag=data["flag"],
|
|
variable=data["variable"],
|
|
default=data.get("default"),
|
|
description=data.get("description", "")
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PromptStep:
|
|
"""A prompt step that calls an AI provider."""
|
|
prompt: str # The prompt template
|
|
provider: str # Provider name
|
|
output_var: str # Variable to store output
|
|
prompt_file: Optional[str] = None # Optional filename for external prompt
|
|
|
|
def to_dict(self) -> dict:
|
|
d = {
|
|
"type": "prompt",
|
|
"prompt": self.prompt,
|
|
"provider": self.provider,
|
|
"output_var": self.output_var
|
|
}
|
|
if self.prompt_file:
|
|
d["prompt_file"] = self.prompt_file
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "PromptStep":
|
|
return cls(
|
|
prompt=data["prompt"],
|
|
provider=data["provider"],
|
|
output_var=data["output_var"],
|
|
prompt_file=data.get("prompt_file")
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class CodeStep:
|
|
"""A code step that runs Python code."""
|
|
code: str # Python code (inline or loaded from file)
|
|
output_var: str # Variable name(s) to capture (comma-separated for multiple)
|
|
code_file: Optional[str] = None # Optional filename for external code
|
|
|
|
def to_dict(self) -> dict:
|
|
d = {
|
|
"type": "code",
|
|
"code": self.code,
|
|
"output_var": self.output_var
|
|
}
|
|
if self.code_file:
|
|
d["code_file"] = self.code_file
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "CodeStep":
|
|
return cls(
|
|
code=data.get("code", ""),
|
|
output_var=data["output_var"],
|
|
code_file=data.get("code_file")
|
|
)
|
|
|
|
|
|
Step = PromptStep | CodeStep
|
|
|
|
|
|
# Default categories for organizing tools
|
|
DEFAULT_CATEGORIES = ["Text", "Developer", "Data", "Other"]
|
|
|
|
|
|
@dataclass
|
|
class Tool:
|
|
"""A SmartTools tool definition."""
|
|
name: str
|
|
description: str = ""
|
|
category: str = "Other" # Tool category for organization
|
|
arguments: List[ToolArgument] = field(default_factory=list)
|
|
steps: List[Step] = field(default_factory=list)
|
|
output: str = "{input}" # Output template
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "Tool":
|
|
arguments = []
|
|
for arg in data.get("arguments", []):
|
|
arguments.append(ToolArgument.from_dict(arg))
|
|
|
|
steps = []
|
|
for step in data.get("steps", []):
|
|
if step.get("type") == "prompt":
|
|
steps.append(PromptStep.from_dict(step))
|
|
elif step.get("type") == "code":
|
|
steps.append(CodeStep.from_dict(step))
|
|
|
|
return cls(
|
|
name=data["name"],
|
|
description=data.get("description", ""),
|
|
category=data.get("category", "Other"),
|
|
arguments=arguments,
|
|
steps=steps,
|
|
output=data.get("output", "{input}")
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
d = {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
}
|
|
# Only include category if it's not the default
|
|
if self.category and self.category != "Other":
|
|
d["category"] = self.category
|
|
d["arguments"] = [arg.to_dict() for arg in self.arguments]
|
|
d["steps"] = [step.to_dict() for step in self.steps]
|
|
d["output"] = self.output
|
|
return d
|
|
|
|
def get_available_variables(self) -> List[str]:
|
|
"""Get all variables available for use in templates."""
|
|
variables = ["input"] # Always available
|
|
|
|
# Add argument variables
|
|
for arg in self.arguments:
|
|
variables.append(arg.variable)
|
|
|
|
# Add step output variables
|
|
for step in self.steps:
|
|
variables.append(step.output_var)
|
|
|
|
return variables
|
|
|
|
|
|
def get_tools_dir() -> Path:
|
|
"""Get the tools directory, creating it if needed."""
|
|
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
return TOOLS_DIR
|
|
|
|
|
|
def get_bin_dir() -> Path:
|
|
"""Get the bin directory for wrapper scripts, creating it if needed."""
|
|
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
|
return BIN_DIR
|
|
|
|
|
|
def list_tools() -> list[str]:
|
|
"""List all available tools."""
|
|
tools_dir = get_tools_dir()
|
|
tools = []
|
|
|
|
for item in tools_dir.iterdir():
|
|
if item.is_dir():
|
|
config = item / "config.yaml"
|
|
if config.exists():
|
|
tools.append(item.name)
|
|
|
|
return sorted(tools)
|
|
|
|
|
|
def load_tool(name: str) -> Optional[Tool]:
|
|
"""Load a tool by name."""
|
|
config_path = get_tools_dir() / name / "config.yaml"
|
|
|
|
if not config_path.exists():
|
|
return None
|
|
|
|
try:
|
|
data = yaml.safe_load(config_path.read_text())
|
|
|
|
# Handle legacy format (prompt/provider/provider_args/inputs)
|
|
if "prompt" in data and "steps" not in data:
|
|
# Convert to new format
|
|
steps = []
|
|
if data.get("prompt"):
|
|
steps.append({
|
|
"type": "prompt",
|
|
"prompt": data["prompt"],
|
|
"provider": data.get("provider", "mock"),
|
|
"output_var": "response"
|
|
})
|
|
|
|
arguments = []
|
|
for inp in data.get("inputs", []):
|
|
arguments.append({
|
|
"flag": inp.get("flag", f"--{inp['name']}"),
|
|
"variable": inp["name"],
|
|
"default": inp.get("default"),
|
|
"description": inp.get("description", "")
|
|
})
|
|
|
|
data = {
|
|
"name": data["name"],
|
|
"description": data.get("description", ""),
|
|
"arguments": arguments,
|
|
"steps": steps,
|
|
"output": "{response}" if steps else "{input}"
|
|
}
|
|
|
|
return Tool.from_dict(data)
|
|
except Exception as e:
|
|
print(f"Error loading tool {name}: {e}")
|
|
return None
|
|
|
|
|
|
def save_tool(tool: Tool) -> Path:
|
|
"""Save a tool to disk."""
|
|
tool_dir = get_tools_dir() / tool.name
|
|
tool_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
config_path = tool_dir / "config.yaml"
|
|
config_path.write_text(yaml.dump(tool.to_dict(), default_flow_style=False, sort_keys=False))
|
|
|
|
# Create wrapper script
|
|
create_wrapper_script(tool.name)
|
|
|
|
return config_path
|
|
|
|
|
|
def delete_tool(name: str) -> bool:
|
|
"""Delete a tool."""
|
|
tool_dir = get_tools_dir() / name
|
|
|
|
if not tool_dir.exists():
|
|
return False
|
|
|
|
# Remove wrapper script
|
|
wrapper = get_bin_dir() / name
|
|
if wrapper.exists():
|
|
wrapper.unlink()
|
|
|
|
# Remove tool directory
|
|
import shutil
|
|
shutil.rmtree(tool_dir)
|
|
|
|
return True
|
|
|
|
|
|
def create_wrapper_script(name: str) -> Path:
|
|
"""Create a wrapper script for a tool in ~/.local/bin."""
|
|
import sys
|
|
|
|
bin_dir = get_bin_dir()
|
|
wrapper_path = bin_dir / name
|
|
|
|
# Use the current Python interpreter to ensure smarttools is available
|
|
python_path = sys.executable
|
|
|
|
script = f"""#!/bin/bash
|
|
# SmartTools wrapper for '{name}'
|
|
# Auto-generated - do not edit
|
|
exec {python_path} -m smarttools.runner {name} "$@"
|
|
"""
|
|
|
|
wrapper_path.write_text(script)
|
|
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
|
|
return wrapper_path
|
|
|
|
|
|
def tool_exists(name: str) -> bool:
|
|
"""Check if a tool exists."""
|
|
return (get_tools_dir() / name / "config.yaml").exists()
|
|
|
|
|
|
def validate_tool_name(name: str) -> tuple[bool, str]:
|
|
"""
|
|
Validate a tool name.
|
|
|
|
Returns:
|
|
(is_valid, error_message) - error_message is empty if valid
|
|
"""
|
|
if not name:
|
|
return False, "Tool name cannot be empty"
|
|
|
|
if ' ' in name:
|
|
return False, "Tool name cannot contain spaces"
|
|
|
|
# Check for shell-problematic characters
|
|
bad_chars = set('/\\|&;$`"\'<>(){}[]!?*#~')
|
|
found = [c for c in name if c in bad_chars]
|
|
if found:
|
|
return False, f"Tool name cannot contain: {' '.join(found)}"
|
|
|
|
# Must start with letter or underscore
|
|
if not (name[0].isalpha() or name[0] == '_'):
|
|
return False, "Tool name must start with a letter or underscore"
|
|
|
|
# Check it's a valid identifier-ish (alphanumeric, underscore, dash)
|
|
for c in name:
|
|
if not (c.isalnum() or c in '_-'):
|
|
return False, f"Tool name can only contain letters, numbers, underscore, and dash"
|
|
|
|
return True, ""
|