Add comprehensive documentation and 28 example tools

- Updated README with full feature overview and examples
- Added docs/INSTALL.md with detailed setup instructions
- Added docs/PROVIDERS.md with 12 profiled providers
- Added docs/EXAMPLES.md with all 28 tool configurations
- Added examples/install.py for one-command tool installation
- Updated pyproject.toml with better metadata
- Added urwid TUI with provider descriptions in dropdown
- Profiled all providers for speed/accuracy/cost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
rob
2025-12-04 12:42:04 -04:00
co-authored by Claude
parent 28dac465e6
commit 4c71dbded2
14 changed files with 6072 additions and 565 deletions
+145 -13
View File
@@ -4,7 +4,7 @@ import argparse
import sys
from . import __version__
from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolInput
from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolArgument, PromptStep, CodeStep
from .ui import run_ui
@@ -14,7 +14,7 @@ def cmd_list(args):
if not tools:
print("No tools found.")
print("Create your first tool with: smarttools create <name>")
print("Create your first tool with: smarttools ui")
return 0
print(f"Available tools ({len(tools)}):\n")
@@ -23,14 +23,29 @@ def cmd_list(args):
if tool:
print(f" {name}")
print(f" {tool.description or 'No description'}")
print(f" Provider: {tool.provider}")
# Show arguments
if tool.arguments:
args_str = ", ".join(arg.flag for arg in tool.arguments)
print(f" Arguments: {args_str}")
# Show steps
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
elif isinstance(step, CodeStep):
step_info.append("CODE")
print(f" Steps: {' -> '.join(step_info)}")
print()
return 0
def cmd_create(args):
"""Create a new tool."""
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
name = args.name
# Check if already exists
@@ -39,21 +54,28 @@ def cmd_create(args):
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
return 1
# Create minimal tool
# Create a tool with a single prompt step
steps = []
if args.prompt:
steps.append(PromptStep(
prompt=args.prompt,
provider=args.provider or "mock",
output_var="response"
))
tool = Tool(
name=name,
description=args.description or "",
prompt=args.prompt or "Process the following:\n\n{input}",
provider=args.provider or "codex",
provider_args=args.provider_args or "-p",
inputs=[],
post_process=None
arguments=[],
steps=steps,
output="{response}" if steps else "{input}"
)
path = save_tool(tool)
print(f"Created tool '{name}'")
print(f"Config: {path}")
print(f"\nEdit the config to customize, then run: {name} -i <file>")
print(f"\nUse 'smarttools ui' to add arguments, steps, and customize.")
print(f"Or run: {name} < input.txt")
return 0
@@ -141,12 +163,106 @@ def cmd_test(args):
return code
def cmd_run(args):
"""Run a tool."""
from pathlib import Path
from .runner import run_tool
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
return 1
# Read input
if args.input:
# Read from file
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
return 1
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args from remaining arguments
custom_args = {}
if args.tool_args:
# Parse tool-specific arguments
i = 0
while i < len(args.tool_args):
arg = args.tool_args[i]
if arg.startswith('--'):
key = arg[2:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
elif arg.startswith('-'):
key = arg[1:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
else:
i += 1
# Run tool
output, code = run_tool(
tool=tool,
input_text=input_text,
custom_args=custom_args,
provider_override=args.provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose
)
# Write output
if code == 0 and output:
if args.output:
Path(args.output).write_text(output)
else:
print(output)
return code
def cmd_ui(args):
"""Launch the interactive UI."""
run_ui()
return 0
def cmd_refresh(args):
"""Refresh all wrapper scripts with the current Python path."""
from .tool import list_tools, create_wrapper_script
tools = list_tools()
if not tools:
print("No tools found.")
return 0
print(f"Refreshing wrapper scripts for {len(tools)} tools...")
for name in tools:
path = create_wrapper_script(name)
print(f" {name} -> {path}")
print("\nDone. Wrapper scripts updated to use current Python interpreter.")
return 0
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
@@ -167,8 +283,7 @@ def main():
p_create.add_argument("name", help="Tool name")
p_create.add_argument("-d", "--description", help="Tool description")
p_create.add_argument("-p", "--prompt", help="Prompt template")
p_create.add_argument("--provider", help="AI provider (default: codex)")
p_create.add_argument("--provider-args", help="Provider arguments (default: -p)")
p_create.add_argument("--provider", help="AI provider (default: mock)")
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
p_create.set_defaults(func=cmd_create)
@@ -190,10 +305,27 @@ def main():
p_test.add_argument("--dry-run", action="store_true", help="Show prompt only")
p_test.set_defaults(func=cmd_test)
# 'run' command
p_run = subparsers.add_parser("run", help="Run a tool")
p_run.add_argument("name", help="Tool name")
p_run.add_argument("-i", "--input", help="Input file (reads from stdin if piped)")
p_run.add_argument("-o", "--output", help="Output file (writes to stdout if omitted)")
p_run.add_argument("--stdin", action="store_true", help="Read input interactively (type then Ctrl+D)")
p_run.add_argument("-p", "--provider", help="Override provider")
p_run.add_argument("--dry-run", action="store_true", help="Show what would happen without executing")
p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output")
p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information")
p_run.add_argument("tool_args", nargs="*", help="Additional tool-specific arguments")
p_run.set_defaults(func=cmd_run)
# 'ui' command (explicit)
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
p_ui.set_defaults(func=cmd_ui)
# 'refresh' command
p_refresh = subparsers.add_parser("refresh", help="Refresh all wrapper scripts")
p_refresh.set_defaults(func=cmd_refresh)
args = parser.parse_args()
# If no command, launch UI
+150 -27
View File
@@ -1,9 +1,40 @@
"""Provider abstraction for AI CLI tools."""
import os
import subprocess
import shutil
from dataclasses import dataclass
from typing import Optional
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List
import yaml
# Default providers config location
PROVIDERS_FILE = Path.home() / ".smarttools" / "providers.yaml"
@dataclass
class Provider:
"""Definition of an AI provider."""
name: str
command: str
description: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"command": self.command,
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> "Provider":
return cls(
name=data["name"],
command=data["command"],
description=data.get("description", ""),
)
@dataclass
@@ -14,29 +45,137 @@ class ProviderResult:
error: Optional[str] = None
def call_provider(provider: str, args: str, prompt: str, timeout: int = 300) -> ProviderResult:
# Default providers that come pre-configured
# Profiled with 4-task test: Math, Code, Reasoning, Extract (Dec 2025)
DEFAULT_PROVIDERS = [
# TOP PICKS - best value/performance ratio
Provider("opencode-deepseek", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat", "13s 4/4 | BEST VALUE, cheap, fast, accurate"),
Provider("opencode-pickle", "$HOME/.opencode/bin/opencode run --model opencode/big-pickle", "13s 4/4 | BEST FREE, accurate"),
Provider("claude-haiku", "claude -p --model haiku", "14s 4/4 | fast, accurate, best paid option"),
Provider("codex", "codex exec -", "14s 4/4 | reliable, auto-routes"),
# CLAUDE - all accurate, paid, good for code
Provider("claude", "claude -p", "18s 4/4 | auto-routes to best model"),
Provider("claude-opus", "claude -p --model opus", "18s 4/4 | highest quality, expensive"),
Provider("claude-sonnet", "claude -p --model sonnet", "21s 4/4 | balanced quality/speed"),
# OPENCODE - additional models
Provider("opencode-nano", "$HOME/.opencode/bin/opencode run --model opencode/gpt-5-nano", "24s 4/4 | GPT-5 Nano, reliable"),
Provider("opencode-reasoner", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-reasoner", "33s 4/4 | complex reasoning, cheap"),
Provider("opencode-grok", "$HOME/.opencode/bin/opencode run --model opencode/grok-code", "11s 2/4 | fastest but unreliable, FREE"),
# GEMINI - slow CLI but good for large docs (1M token context)
Provider("gemini-flash", "gemini --model gemini-2.5-flash", "28s 4/4 | use this for quick tasks"),
Provider("gemini", "gemini --model gemini-2.5-pro", "91s 3/4 | slow CLI, best for large docs/PDFs"),
# Mock for testing
Provider("mock", "mock", "Mock provider for testing"),
]
def get_providers_file() -> Path:
"""Get the providers config file, creating default if needed."""
if not PROVIDERS_FILE.exists():
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
save_providers(DEFAULT_PROVIDERS)
return PROVIDERS_FILE
def load_providers() -> List[Provider]:
"""Load all defined providers."""
providers_file = get_providers_file()
try:
data = yaml.safe_load(providers_file.read_text())
if not data or "providers" not in data:
return DEFAULT_PROVIDERS.copy()
return [Provider.from_dict(p) for p in data["providers"]]
except Exception:
return DEFAULT_PROVIDERS.copy()
def save_providers(providers: List[Provider]):
"""Save providers to config file."""
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
data = {"providers": [p.to_dict() for p in providers]}
PROVIDERS_FILE.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
def get_provider(name: str) -> Optional[Provider]:
"""Get a provider by name."""
providers = load_providers()
for p in providers:
if p.name == name:
return p
return None
def add_provider(provider: Provider) -> bool:
"""Add or update a provider."""
providers = load_providers()
# Update if exists, otherwise add
for i, p in enumerate(providers):
if p.name == provider.name:
providers[i] = provider
save_providers(providers)
return True
providers.append(provider)
save_providers(providers)
return True
def delete_provider(name: str) -> bool:
"""Delete a provider by name."""
providers = load_providers()
original_len = len(providers)
providers = [p for p in providers if p.name != name]
if len(providers) < original_len:
save_providers(providers)
return True
return False
def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> ProviderResult:
"""
Call an AI CLI tool with the given prompt.
Call an AI provider with the given prompt.
Args:
provider: The CLI command (e.g., 'codex', 'claude', 'gemini')
args: Additional arguments for the provider (e.g., '-p')
provider_name: Name of the provider to use
prompt: The prompt to send
timeout: Maximum execution time in seconds
Returns:
ProviderResult with the response text or error
"""
# Check if provider exists
if not shutil.which(provider):
# Handle mock provider specially
if provider_name.lower() == "mock":
return mock_provider(prompt)
# Look up provider
provider = get_provider(provider_name)
if not provider:
return ProviderResult(
text="",
success=False,
error=f"Provider '{provider}' not found. Is it installed and in PATH?"
error=f"Provider '{provider_name}' not found. Use 'smarttools providers' to manage providers."
)
# Build command
cmd = f"{provider} {args}".strip()
# Parse command (expand environment variables)
cmd = os.path.expandvars(provider.command)
# Check if base command exists
base_cmd = cmd.split()[0]
# Expand ~ for the which check
base_cmd_expanded = os.path.expanduser(base_cmd)
if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded):
return ProviderResult(
text="",
success=False,
error=f"Command '{base_cmd}' not found. Is it installed and in PATH?"
)
try:
result = subprocess.run(
@@ -92,19 +231,3 @@ def mock_provider(prompt: str) -> ProviderResult:
f"This is a mock response. Use a real provider for actual output.",
success=True
)
def get_provider_func(provider: str):
"""
Get the appropriate provider function.
Args:
provider: Provider name ('mock' for mock, anything else for real)
Returns:
Callable that takes (prompt) or (provider, args, prompt)
"""
if provider.lower() == "mock":
return lambda prompt, **kwargs: mock_provider(prompt)
else:
return lambda prompt, args="", **kwargs: call_provider(provider, args, prompt, **kwargs)
+135 -91
View File
@@ -5,75 +5,86 @@ import sys
from pathlib import Path
from typing import Optional
from .tool import load_tool, Tool
from .providers import call_provider, mock_provider, ProviderResult
from .tool import load_tool, Tool, PromptStep, CodeStep
from .providers import call_provider, mock_provider
def build_prompt(tool: Tool, input_text: str, custom_args: dict) -> str:
def substitute_variables(template: str, variables: dict) -> str:
"""
Build the final prompt by substituting placeholders.
Substitute {variable} placeholders in a template.
Args:
tool: Tool definition
input_text: Content from input file or stdin
custom_args: Custom argument values
template: String with {var} placeholders
variables: Dict of variable name -> value
Returns:
Final prompt string
String with placeholders replaced
"""
prompt = tool.prompt
# Replace {input} placeholder
prompt = prompt.replace("{input}", input_text)
# Replace custom argument placeholders
for inp in tool.inputs:
value = custom_args.get(inp.name, inp.default)
if value is not None:
prompt = prompt.replace(f"{{{inp.name}}}", str(value))
return prompt
result = template
for name, value in variables.items():
result = result.replace(f"{{{name}}}", str(value) if value else "")
return result
def run_post_process(tool: Tool, output: str) -> str:
def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]:
"""
Run post-processing script if defined.
Execute a prompt step.
Args:
tool: Tool definition
output: Raw output from provider
step: The prompt step to execute
variables: Current variable values
provider_override: Override the step's provider
Returns:
Processed output
Tuple of (output_value, success)
"""
if not tool.post_process:
return output
# Build prompt with variable substitution
prompt = substitute_variables(step.prompt, variables)
from .tool import get_tools_dir
script_path = get_tools_dir() / tool.name / tool.post_process
# Call provider
provider = provider_override or step.provider
if not script_path.exists():
print(f"Warning: Post-process script not found: {script_path}", file=sys.stderr)
return output
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, prompt)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
return "", False
return result.text, True
def execute_code_step(step: CodeStep, variables: dict) -> tuple[dict, bool]:
"""
Execute a code step.
Args:
step: The code step to execute
variables: Current variable values (available in code)
Returns:
Tuple of (output_vars_dict, success)
"""
# Create execution environment with variables
local_vars = dict(variables)
# Execute post-process script with output as input
import subprocess
try:
result = subprocess.run(
["python3", str(script_path)],
input=output,
capture_output=True,
text=True,
timeout=60
)
if result.returncode == 0:
return result.stdout
else:
print(f"Warning: Post-process failed: {result.stderr}", file=sys.stderr)
return output
# Execute the code
exec(step.code, {"__builtins__": __builtins__}, local_vars)
# Support comma-separated output vars (e.g., "a, b, c")
output_vars = [v.strip() for v in step.output_var.split(',')]
outputs = {}
for var in output_vars:
outputs[var] = str(local_vars.get(var, ""))
return outputs, True
except Exception as e:
print(f"Warning: Post-process error: {e}", file=sys.stderr)
return output
print(f"Error in code step: {e}", file=sys.stderr)
return {}, False
def run_tool(
@@ -92,48 +103,73 @@ def run_tool(
tool: Tool definition
input_text: Input content
custom_args: Custom argument values
provider_override: Override the tool's provider
dry_run: Just show prompt, don't call provider
show_prompt: Show prompt in addition to output
provider_override: Override all providers
dry_run: Just show what would happen
show_prompt: Show prompts in addition to output
verbose: Show debug info
Returns:
Tuple of (output_text, exit_code)
"""
# Build prompt
prompt = build_prompt(tool, input_text, custom_args)
# Initialize variables with input and arguments
variables = {"input": input_text}
# Add argument values (with defaults)
for arg in tool.arguments:
value = custom_args.get(arg.variable, arg.default)
variables[arg.variable] = value
if verbose:
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
print(f"[verbose] Provider: {provider_override or tool.provider}", file=sys.stderr)
print(f"[verbose] Prompt length: {len(prompt)} chars", file=sys.stderr)
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
# Dry run - just show prompt
if dry_run:
return prompt, 0
# If no steps, just substitute output template
if not tool.steps:
output = substitute_variables(tool.output, variables)
return output, 0
# Show prompt if requested
if show_prompt:
print("=== PROMPT ===", file=sys.stderr)
print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr)
# Execute each step
for i, step in enumerate(tool.steps):
if verbose:
step_type = "PROMPT" if isinstance(step, PromptStep) else "CODE"
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
# Call provider
provider = provider_override or tool.provider
if isinstance(step, PromptStep):
# Show prompt if requested
if show_prompt or dry_run:
prompt = substitute_variables(step.prompt, variables)
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr)
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, tool.provider_args, prompt)
if dry_run:
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
else:
output, success = execute_prompt_step(step, variables, provider_override)
if not success:
return "", 2
variables[step.output_var] = output
if not result.success:
print(f"Error: {result.error}", file=sys.stderr)
return "", 2 # Exit code 2 for provider errors
elif isinstance(step, CodeStep):
if verbose or dry_run:
print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(step.code, file=sys.stderr)
print("=== END CODE ===", file=sys.stderr)
output = result.text
if dry_run:
# Handle comma-separated output vars for dry run
for var in [v.strip() for v in step.output_var.split(',')]:
variables[var] = "[DRY RUN - would execute code]"
else:
outputs, success = execute_code_step(step, variables)
if not success:
return "", 1
# Merge all output vars into variables
variables.update(outputs)
# Post-process if defined
output = run_post_process(tool, output)
# Generate final output
output = substitute_variables(tool.output, variables)
return output, 0
@@ -155,25 +191,27 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
# Universal flags
parser.add_argument("-i", "--input", dest="input_file",
help="Input file (reads from stdin if omitted)")
help="Input file (reads from stdin if piped)")
parser.add_argument("--stdin", action="store_true",
help="Read input interactively from stdin (type then Ctrl+D)")
parser.add_argument("-o", "--output", dest="output_file",
help="Output file (writes to stdout if omitted)")
parser.add_argument("--dry-run", action="store_true",
help="Show prompt without calling AI provider")
help="Show what would happen without executing")
parser.add_argument("--show-prompt", action="store_true",
help="Show prompt in addition to output")
help="Show prompts in addition to output")
parser.add_argument("-p", "--provider",
help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information")
# Tool-specific flags
for inp in tool.inputs:
# Tool-specific flags from arguments
for arg in tool.arguments:
parser.add_argument(
inp.flag,
dest=inp.name,
default=inp.default,
help=inp.description or f"{inp.name} (default: {inp.default})"
arg.flag,
dest=arg.variable,
default=arg.default,
help=arg.description or f"{arg.variable} (default: {arg.default})"
)
return parser
@@ -198,23 +236,29 @@ def main():
# Read input
if args.input_file:
# Read from file
input_path = Path(args.input_file)
if not input_path.exists():
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
input_text = input_path.read_text()
else:
# Read from stdin
if sys.stdin.isatty():
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args
custom_args = {}
for inp in tool.inputs:
value = getattr(args, inp.name, None)
for arg in tool.arguments:
value = getattr(args, arg.variable, None)
if value is not None:
custom_args[inp.name] = value
custom_args[arg.variable] = value
# Run tool
output, exit_code = run_tool(
+186 -80
View File
@@ -4,7 +4,7 @@ import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from typing import Optional, List, Literal
import yaml
@@ -17,74 +17,145 @@ BIN_DIR = Path.home() / ".local" / "bin"
@dataclass
class ToolInput:
class ToolArgument:
"""Definition of a custom input argument."""
name: str
flag: str
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
@dataclass
class Tool:
"""A SmartTools tool definition."""
name: str
description: str
prompt: str
provider: str
provider_args: str = ""
inputs: list[ToolInput] = field(default_factory=list)
post_process: Optional[str] = None
description: str = ""
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":
"""Create a Tool from a dictionary (parsed YAML)."""
inputs = []
for inp in data.get("inputs", []):
inputs.append(ToolInput(
name=inp["name"],
flag=inp.get("flag", f"--{inp['name']}"),
default=inp.get("default"),
description=inp.get("description", "")
))
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", ""),
prompt=data["prompt"],
provider=data["provider"],
provider_args=data.get("provider_args", ""),
inputs=inputs,
post_process=data.get("post_process")
arguments=arguments,
steps=steps,
output=data.get("output", "{input}")
)
def to_dict(self) -> dict:
"""Convert Tool to dictionary for YAML serialization."""
data = {
return {
"name": self.name,
"description": self.description,
"prompt": self.prompt,
"provider": self.provider,
"arguments": [arg.to_dict() for arg in self.arguments],
"steps": [step.to_dict() for step in self.steps],
"output": self.output
}
if self.provider_args:
data["provider_args"] = self.provider_args
def get_available_variables(self) -> List[str]:
"""Get all variables available for use in templates."""
variables = ["input"] # Always available
if self.inputs:
data["inputs"] = [
{
"name": inp.name,
"flag": inp.flag,
"default": inp.default,
"description": inp.description,
}
for inp in self.inputs
]
# Add argument variables
for arg in self.arguments:
variables.append(arg.variable)
if self.post_process:
data["post_process"] = self.post_process
# Add step output variables
for step in self.steps:
variables.append(step.output_var)
return data
return variables
# Legacy support - map old ToolInput to new ToolArgument
ToolInput = ToolArgument
def get_tools_dir() -> Path:
@@ -114,15 +185,7 @@ def list_tools() -> list[str]:
def load_tool(name: str) -> Optional[Tool]:
"""
Load a tool by name.
Args:
name: Tool name
Returns:
Tool object or None if not found
"""
"""Load a tool by name."""
config_path = get_tools_dir() / name / "config.yaml"
if not config_path.exists():
@@ -130,21 +193,44 @@ def load_tool(name: str) -> Optional[Tool]:
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:
except Exception as e:
print(f"Error loading tool {name}: {e}")
return None
def save_tool(tool: Tool) -> Path:
"""
Save a tool to disk.
Args:
tool: Tool object to save
Returns:
Path to the saved config file
"""
"""Save a tool to disk."""
tool_dir = get_tools_dir() / tool.name
tool_dir.mkdir(parents=True, exist_ok=True)
@@ -158,15 +244,7 @@ def save_tool(tool: Tool) -> Path:
def delete_tool(name: str) -> bool:
"""
Delete a tool.
Args:
name: Tool name
Returns:
True if deleted, False if not found
"""
"""Delete a tool."""
tool_dir = get_tools_dir() / name
if not tool_dir.exists():
@@ -185,22 +263,19 @@ def delete_tool(name: str) -> bool:
def create_wrapper_script(name: str) -> Path:
"""
Create a wrapper script for a tool in ~/.local/bin.
"""Create a wrapper script for a tool in ~/.local/bin."""
import sys
Args:
name: Tool name
Returns:
Path to the wrapper script
"""
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 python3 -m smarttools.runner {name} "$@"
exec {python_path} -m smarttools.runner {name} "$@"
"""
wrapper_path.write_text(script)
@@ -212,3 +287,34 @@ exec python3 -m smarttools.runner {name} "$@"
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, ""
+633 -148
View File
@@ -2,9 +2,34 @@
import subprocess
import sys
from typing import Optional, Tuple
import tempfile
from typing import Optional, Tuple, List
from .tool import Tool, ToolInput, list_tools, load_tool, save_tool, delete_tool, tool_exists
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
def _check_urwid() -> bool:
"""Check if urwid is available (preferred - has mouse support)."""
try:
import urwid
return True
except ImportError:
return False
def _check_snack() -> bool:
"""Check if snack (python3-newt) is available."""
try:
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
return True
except ImportError:
return False
def check_dialog() -> str:
@@ -19,35 +44,30 @@ def check_dialog() -> str:
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
"""
Run a dialog command and return (exit_code, output).
Args:
args: Dialog arguments
dialog_prog: Dialog program to use
Returns:
Tuple of (exit_code, captured_output)
"""
cmd = [dialog_prog] + args
result = subprocess.run(cmd, capture_output=True, text=True)
# dialog outputs to stderr
return result.returncode, result.stderr.strip()
"""Run a dialog command and return (exit_code, output)."""
try:
if dialog_prog == "whiptail":
result = subprocess.run(
[dialog_prog] + args,
stderr=subprocess.PIPE,
text=True
)
return result.returncode, result.stderr.strip()
else:
cmd_with_stdout = [dialog_prog, "--stdout"] + args
result = subprocess.run(
cmd_with_stdout,
stdout=subprocess.PIPE,
text=True
)
return result.returncode, result.stdout.strip()
except Exception as e:
return 1, ""
def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]:
"""
Show a menu and return the selected item.
Args:
title: Menu title
choices: List of (tag, description) tuples
dialog_prog: Dialog program to use
Returns:
Selected tag or None if cancelled
"""
args = ["--title", title, "--menu", "", "15", "60", str(len(choices))]
"""Show a menu and return the selected item."""
args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))]
for tag, desc in choices:
args.extend([tag, desc])
@@ -56,61 +76,29 @@ def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> O
def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]:
"""
Show an input box and return the entered text.
Args:
title: Dialog title
prompt: Prompt text
initial: Initial value
dialog_prog: Dialog program to use
Returns:
Entered text or None if cancelled
"""
"""Show an input box and return the entered text."""
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
"""
Show a text editor for multi-line input.
Args:
title: Dialog title
text: Initial text
dialog_prog: Dialog program to use
Returns:
Edited text or None if cancelled
"""
import tempfile
"""Show a text editor for multi-line input."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(text)
temp_path = f.name
args = ["--title", title, "--editbox", temp_path, "20", "70"]
code, output = run_dialog(args, dialog_prog)
import os
os.unlink(temp_path)
return output if code == 0 else None
try:
args = ["--title", title, "--editbox", temp_path, "20", "75"]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
finally:
import os
os.unlink(temp_path)
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
"""
Show a yes/no dialog.
Args:
title: Dialog title
text: Question text
dialog_prog: Dialog program to use
Returns:
True if yes, False if no
"""
"""Show a yes/no dialog."""
args = ["--title", title, "--yesno", text, "10", "60"]
code, _ = run_dialog(args, dialog_prog)
return code == 0
@@ -118,84 +106,525 @@ def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
"""Show a message box."""
args = ["--title", title, "--msgbox", text, "10", "60"]
args = ["--title", title, "--msgbox", text, "15", "70"]
run_dialog(args, dialog_prog)
def create_tool_form(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
"""
Show form for creating/editing a tool.
def show_mixed_form(title: str, fields: dict, dialog_prog: str, height: int = 20) -> Optional[dict]:
"""Show a form with multiple fields using --mixedform."""
args = ["--title", title, "--mixedform",
"Tab: next field | Enter: submit | Esc: cancel",
str(height), "75", "0"]
Args:
dialog_prog: Dialog program to use
existing: Existing tool to edit (None for new)
field_names = list(fields.keys())
y = 1
for name in field_names:
label, initial, field_type = fields[name]
args.extend([
label, str(y), "1",
initial, str(y), "18",
"52", "256", str(field_type)
])
y += 1
code, output = run_dialog(args, dialog_prog)
if code != 0:
return None
values = output.split('\n')
result = {}
for i, name in enumerate(field_names):
result[name] = values[i] if i < len(values) else ""
return result
# ============ Provider Management ============
def select_provider(dialog_prog: str) -> Optional[str]:
"""Show provider selection menu with option to create new."""
providers = load_providers()
choices = [(p.name, f"{p.description} ({p.command})") for p in providers]
choices.append(("__new__", "[ + Add New Provider ]"))
selected = show_menu("Select Provider", choices, dialog_prog)
if selected == "__new__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
return provider.name
return None
return selected
def create_provider_form(dialog_prog: str, existing: Optional[Provider] = None) -> Optional[Provider]:
"""Show form for creating/editing a provider."""
title = f"Edit Provider: {existing.name}" if existing else "Add New Provider"
fields = {
"name": (
"Name:",
existing.name if existing else "",
2 if existing else 0 # readonly if editing
),
"command": (
"Command:",
existing.command if existing else "",
0
),
"description": (
"Description:",
existing.description if existing else "",
0
),
}
result = show_mixed_form(title, fields, dialog_prog, height=12)
if not result:
return None
name = result["name"].strip()
command = result["command"].strip()
Returns:
Tool object or None if cancelled
"""
# Get basic info
name = show_input(
"Tool Name",
"Enter tool name (used as command):",
existing.name if existing else "",
dialog_prog
)
if not name:
show_message("Error", "Provider name is required.", dialog_prog)
return None
# Check if name already exists (for new tools)
if not existing and tool_exists(name):
if not show_yesno("Overwrite?", f"Tool '{name}' already exists. Overwrite?", dialog_prog):
return None
description = show_input(
"Description",
"Enter tool description:",
existing.description if existing else "",
dialog_prog
)
if description is None:
if not command:
show_message("Error", "Command is required.", dialog_prog)
return None
provider = show_input(
"Provider",
"Enter AI provider command (e.g., codex, claude, gemini):",
existing.provider if existing else "codex",
dialog_prog
)
if not provider:
return None
provider_args = show_input(
"Provider Args",
"Enter provider arguments (e.g., -p):",
existing.provider_args if existing else "-p",
dialog_prog
)
if provider_args is None:
return None
# Edit prompt
default_prompt = existing.prompt if existing else "Process the following input:\n\n{input}"
prompt = show_textbox("Prompt Template", default_prompt, dialog_prog)
if not prompt:
return None
# For simplicity, skip custom inputs in the UI for now
# They can be added by editing config.yaml directly
inputs = existing.inputs if existing else []
return Tool(
return Provider(
name=name,
description=description,
command=command,
description=result["description"].strip()
)
def ui_manage_providers(dialog_prog: str):
"""Manage providers menu."""
while True:
providers = load_providers()
choices = [(p.name, f"{p.command}") for p in providers]
choices.append(("__add__", "[ + Add New Provider ]"))
choices.append(("__back__", "[ <- Back to Main Menu ]"))
selected = show_menu("Manage Providers", choices, dialog_prog)
if selected is None or selected == "__back__":
break
elif selected == "__add__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
show_message("Success", f"Provider '{provider.name}' added.", dialog_prog)
else:
# Edit or delete existing provider
provider = get_provider(selected)
if provider:
action = show_menu(
f"Provider: {selected}",
[
("edit", "Edit provider"),
("delete", "Delete provider"),
("back", "Back"),
],
dialog_prog
)
if action == "edit":
updated = create_provider_form(dialog_prog, provider)
if updated:
add_provider(updated)
show_message("Success", f"Provider '{updated.name}' updated.", dialog_prog)
elif action == "delete":
if show_yesno("Confirm", f"Delete provider '{selected}'?", dialog_prog):
delete_provider(selected)
show_message("Deleted", f"Provider '{selected}' deleted.", dialog_prog)
# ============ Tool Builder UI ============
def format_tool_summary(tool: Tool) -> str:
"""Format a summary of the tool's components."""
lines = []
lines.append(f"Name: {tool.name}")
lines.append(f"Description: {tool.description or '(none)'}")
lines.append("")
if tool.arguments:
lines.append("Arguments:")
for arg in tool.arguments:
default = f" = {arg.default}" if arg.default else ""
lines.append(f" {arg.flag} -> {{{arg.variable}}}{default}")
lines.append("")
if tool.steps:
lines.append("Steps:")
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
preview = step.prompt[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}")
lines.append(f" {preview}")
elif isinstance(step, CodeStep):
preview = step.code[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. CODE -> {{{step.output_var}}}")
lines.append(f" {preview}")
lines.append("")
lines.append(f"Output: {tool.output}")
return "\n".join(lines)
def get_available_variables(tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get list of available variables at a given point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def edit_argument(dialog_prog: str, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Edit or create an argument."""
title = f"Edit Argument: {existing.flag}" if existing else "Add Argument"
fields = {
"flag": ("Flag:", existing.flag if existing else "--", 0),
"variable": ("Variable:", existing.variable if existing else "", 0),
"default": ("Default:", existing.default or "" if existing else "", 0),
"description": ("Description:", existing.description if existing else "", 0),
}
result = show_mixed_form(title, fields, dialog_prog, height=14)
if not result:
return None
flag = result["flag"].strip()
variable = result["variable"].strip()
if not flag:
show_message("Error", "Flag is required (e.g., --max-size).", dialog_prog)
return None
if not variable:
# Auto-generate variable name from flag
variable = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=variable,
default=result["default"].strip() or None,
description=result["description"].strip()
)
def edit_prompt_step(dialog_prog: str, existing: Optional[PromptStep] = None,
available_vars: List[str] = None) -> Optional[PromptStep]:
"""Edit or create a prompt step."""
title = "Edit Prompt Step" if existing else "Add Prompt Step"
# First, select provider
provider = select_provider(dialog_prog)
if not provider:
provider = existing.provider if existing else "mock"
# Show variable help
var_help = "Available: " + ", ".join(f"{{{v}}}" for v in (available_vars or ["input"]))
# Edit prompt text
default_prompt = existing.prompt if existing else f"Process this input:\n\n{{input}}"
prompt = show_textbox(f"Prompt Template\n{var_help}", default_prompt, dialog_prog)
if prompt is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "result",
dialog_prog
)
if not output_var:
return None
return PromptStep(
prompt=prompt,
provider=provider,
provider_args=provider_args,
inputs=inputs,
post_process=existing.post_process if existing else None
output_var=output_var.strip()
)
def edit_code_step(dialog_prog: str, existing: Optional[CodeStep] = None,
available_vars: List[str] = None) -> Optional[CodeStep]:
"""Edit or create a code step."""
title = "Edit Code Step" if existing else "Add Code Step"
# Show variable help
var_help = "Variables: " + ", ".join(available_vars or ["input"])
var_help += "\nSet 'result' variable for output"
# Edit code
default_code = existing.code if existing else "# Available variables: " + ", ".join(available_vars or ["input"]) + "\n# Set 'result' for output\nresult = input.upper()"
code = show_textbox(f"Python Code\n{var_help}", default_code, dialog_prog)
if code is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "processed",
dialog_prog
)
if not output_var:
return None
return CodeStep(
code=code,
output_var=output_var.strip()
)
def edit_tool_info(tool: Tool, is_edit: bool, dialog_prog: str) -> None:
"""Edit basic tool info (name, description, output)."""
while True:
# Build info section menu
output_preview = tool.output[:35] + "..." if len(tool.output) > 35 else tool.output
args_count = len(tool.arguments)
args_summary = f"({args_count} defined)" if args_count else "(none)"
choices = [
("name", f"Name: {tool.name or '(not set)'}"),
("desc", f"Description: {tool.description[:35] + '...' if len(tool.description) > 35 else tool.description or '(none)'}"),
("---1", "─" * 40),
]
# Show arguments
if tool.arguments:
for i, arg in enumerate(tool.arguments):
default = f" = {arg.default}" if arg.default else ""
choices.append((f"arg_{i}", f" {arg.flag} -> {{{arg.variable}}}{default}"))
choices.append(("add_arg", " [ + Add Argument ]"))
choices.append(("---2", "─" * 40))
choices.append(("output", f"Output Template: {output_preview}"))
choices.append(("---3", "─" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Tool Info & Arguments", choices, dialog_prog)
if selected is None or selected == "back":
break
elif selected == "name":
if is_edit:
show_message("Info", "Cannot change tool name after creation.", dialog_prog)
else:
new_name = show_input("Tool Name", "Enter tool name:", tool.name, dialog_prog)
if new_name:
tool.name = new_name.strip()
elif selected == "desc":
new_desc = show_input("Description", "Enter tool description:", tool.description, dialog_prog)
if new_desc is not None:
tool.description = new_desc.strip()
elif selected == "add_arg":
arg = edit_argument(dialog_prog)
if arg:
tool.arguments.append(arg)
elif selected.startswith("arg_"):
idx = int(selected[4:])
arg = tool.arguments[idx]
action = show_menu(
f"Argument: {arg.flag}",
[("edit", "Edit"), ("delete", "Delete"), ("back", "Back")],
dialog_prog
)
if action == "edit":
updated = edit_argument(dialog_prog, arg)
if updated:
tool.arguments[idx] = updated
elif action == "delete":
if show_yesno("Delete", f"Delete argument {arg.flag}?", dialog_prog):
tool.arguments.pop(idx)
elif selected == "output":
available = get_available_variables(tool)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
new_output = show_textbox(f"Output Template\n{var_help}", tool.output, dialog_prog)
if new_output is not None:
tool.output = new_output
def edit_tool_steps(tool: Tool, dialog_prog: str) -> None:
"""Edit tool processing steps."""
while True:
# Build steps section menu
choices = []
if tool.steps:
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
choices.append((f"step_{i}", f"{i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}"))
elif isinstance(step, CodeStep):
choices.append((f"step_{i}", f"{i+1}. CODE -> {{{step.output_var}}}"))
else:
choices.append(("none", "(no steps defined)"))
choices.append(("---1", "─" * 40))
choices.append(("add_prompt", "[ + Add Prompt Step ]"))
choices.append(("add_code", "[ + Add Code Step ]"))
choices.append(("---2", "─" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Processing Steps", choices, dialog_prog)
if selected is None or selected == "back" or selected == "none":
if selected == "none":
continue
break
elif selected == "add_prompt":
available = get_available_variables(tool)
step = edit_prompt_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected == "add_code":
available = get_available_variables(tool)
step = edit_code_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected.startswith("step_"):
idx = int(selected[5:])
step = tool.steps[idx]
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
move_choices = [("edit", "Edit"), ("delete", "Delete")]
if idx > 0:
move_choices.insert(1, ("move_up", "Move Up"))
if idx < len(tool.steps) - 1:
move_choices.insert(2 if idx > 0 else 1, ("move_down", "Move Down"))
move_choices.append(("back", "Back"))
action = show_menu(f"Step {idx+1}: {step_type}", move_choices, dialog_prog)
if action == "edit":
available = get_available_variables(tool, idx)
if isinstance(step, PromptStep):
updated = edit_prompt_step(dialog_prog, step, available)
else:
updated = edit_code_step(dialog_prog, step, available)
if updated:
tool.steps[idx] = updated
elif action == "move_up" and idx > 0:
tool.steps[idx], tool.steps[idx-1] = tool.steps[idx-1], tool.steps[idx]
elif action == "move_down" and idx < len(tool.steps) - 1:
tool.steps[idx], tool.steps[idx+1] = tool.steps[idx+1], tool.steps[idx]
elif action == "delete":
if show_yesno("Delete", f"Delete step {idx+1}?", dialog_prog):
tool.steps.pop(idx)
def tool_builder(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder interface with tabbed sections."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Build main menu with section summaries
args_count = len(tool.arguments)
steps_count = len(tool.steps)
# Info summary
name_display = tool.name or "(not set)"
info_summary = f"{name_display}"
if tool.arguments:
info_summary += f" | {args_count} arg{'s' if args_count != 1 else ''}"
# Steps summary
if tool.steps:
step_types = []
for s in tool.steps:
if isinstance(s, PromptStep):
step_types.append(f"P:{s.provider}")
else:
step_types.append("C")
steps_summary = " -> ".join(step_types)
else:
steps_summary = "(none)"
choices = [
("info", f"[1] Info & Args : {info_summary}"),
("steps", f"[2] Steps : {steps_summary}"),
("---", "─" * 50),
("preview", "Preview Full Summary"),
("save", "Save Tool"),
("cancel", "Cancel"),
]
title = f"Tool Builder: {tool.name}" if tool.name else "Tool Builder: New Tool"
selected = show_menu(title, choices, dialog_prog)
if selected is None or selected == "cancel":
if show_yesno("Cancel", "Discard changes?", dialog_prog):
return None
continue
elif selected == "info":
edit_tool_info(tool, is_edit, dialog_prog)
elif selected == "steps":
edit_tool_steps(tool, dialog_prog)
elif selected == "preview":
summary = format_tool_summary(tool)
show_message("Tool Summary", summary, dialog_prog)
elif selected == "save":
if not tool.name:
show_message("Error", "Tool name is required. Go to Info & Args to set it.", dialog_prog)
continue
if not is_edit and tool_exists(tool.name):
if not show_yesno("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", dialog_prog):
continue
return tool
# ============ Main Menu Functions ============
def ui_list_tools(dialog_prog: str):
"""Show list of tools."""
tools = list_tools()
@@ -208,17 +637,42 @@ def ui_list_tools(dialog_prog: str):
tool = load_tool(name)
if tool:
text += f" {name}: {tool.description or 'No description'}\n"
text += f" Provider: {tool.provider}\n\n"
if tool.arguments:
args = ", ".join(arg.flag for arg in tool.arguments)
text += f" Arguments: {args}\n"
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
else:
step_info.append("CODE")
text += f" Steps: {' -> '.join(step_info)}\n"
text += "\n"
show_message("Tools", text, dialog_prog)
def ui_create_tool(dialog_prog: str):
"""Create a new tool."""
tool = create_tool_form(dialog_prog)
tool = tool_builder(dialog_prog)
if tool:
path = save_tool(tool)
show_message("Success", f"Tool '{tool.name}' created!\n\nConfig: {path}\n\nRun it with: {tool.name} -i <file>", dialog_prog)
# Build usage example
usage = f"{tool.name}"
for arg in tool.arguments:
if arg.default:
usage += f" [{arg.flag} <{arg.variable}>]"
else:
usage += f" {arg.flag} <{arg.variable}>"
usage += " < input.txt"
show_message("Success",
f"Tool '{tool.name}' created!\n\n"
f"Config: {path}\n\n"
f"Usage: {usage}",
dialog_prog)
def ui_edit_tool(dialog_prog: str):
@@ -228,13 +682,18 @@ def ui_edit_tool(dialog_prog: str):
show_message("Edit Tool", "No tools found.", dialog_prog)
return
choices = [(name, load_tool(name).description or "No description") for name in tools]
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Edit", choices, dialog_prog)
if selected:
existing = load_tool(selected)
if existing:
tool = create_tool_form(dialog_prog, existing)
tool = tool_builder(dialog_prog, existing)
if tool:
save_tool(tool)
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
@@ -247,7 +706,12 @@ def ui_delete_tool(dialog_prog: str):
show_message("Delete Tool", "No tools found.", dialog_prog)
return
choices = [(name, load_tool(name).description or "No description") for name in tools]
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Delete", choices, dialog_prog)
if selected:
@@ -265,13 +729,17 @@ def ui_test_tool(dialog_prog: str):
show_message("Test Tool", "No tools found.", dialog_prog)
return
choices = [(name, load_tool(name).description or "No description") for name in tools]
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Test", choices, dialog_prog)
if selected:
tool = load_tool(selected)
if tool:
# Get test input
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
if test_input:
from .runner import run_tool
@@ -284,11 +752,10 @@ def ui_test_tool(dialog_prog: str):
show_prompt=False,
verbose=False
)
show_message(
"Test Result",
f"Exit code: {code}\n\n--- Output ---\n{output[:500]}",
dialog_prog
)
result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}"
if len(output) > 1000:
result_text += "\n... (truncated)"
show_message("Test Result", result_text, dialog_prog)
def main_menu(dialog_prog: str):
@@ -302,6 +769,7 @@ def main_menu(dialog_prog: str):
("edit", "Edit existing tool"),
("delete", "Delete tool"),
("test", "Test tool (mock provider)"),
("providers", "Manage providers"),
("exit", "Exit"),
],
dialog_prog
@@ -319,15 +787,33 @@ def main_menu(dialog_prog: str):
ui_delete_tool(dialog_prog)
elif choice == "test":
ui_test_tool(dialog_prog)
elif choice == "providers":
ui_manage_providers(dialog_prog)
def run_ui():
"""Entry point for the UI."""
# Prefer urwid (has mouse support)
if _check_urwid():
from .ui_urwid import run_ui as run_urwid_ui
run_urwid_ui()
return
# Fallback to snack (BIOS-style)
if _check_snack():
from .ui_snack import run_ui as run_snack_ui
run_snack_ui()
return
# Fallback to dialog/whiptail
dialog_prog = check_dialog()
if not dialog_prog:
print("Error: Neither 'dialog' nor 'whiptail' found.", file=sys.stderr)
print("Install with: sudo apt install dialog", file=sys.stderr)
print("Error: No TUI library found.", file=sys.stderr)
print("Install one of:", file=sys.stderr)
print(" pip install urwid (recommended - has mouse support)", file=sys.stderr)
print(" sudo apt install python3-newt", file=sys.stderr)
print(" sudo apt install dialog", file=sys.stderr)
sys.exit(1)
try:
@@ -335,7 +821,6 @@ def run_ui():
except KeyboardInterrupt:
pass
finally:
# Clear screen on exit
subprocess.run(["clear"], check=False)
+706
View File
@@ -0,0 +1,706 @@
"""BIOS-style TUI for SmartTools using snack (python3-newt)."""
import sys
# Ensure system packages are accessible
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
from typing import Optional, List, Tuple
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
class SmartToolsUI:
"""BIOS-style UI for SmartTools."""
def __init__(self):
self.screen = None
def run(self):
"""Run the UI."""
self.screen = snack.SnackScreen()
# Enable mouse support
self.screen.pushHelpLine(" Tab/Arrow:Navigate | Enter:Select | Mouse:Click | Esc:Back ")
try:
# Enable mouse - newt supports GPM and xterm mouse
import os
os.environ.setdefault('NEWT_MONO', '0')
self.main_menu()
finally:
self.screen.finish()
def main_menu(self):
"""Show the main menu."""
while True:
items = [
("Create New Tool", "create"),
("Edit Tool", "edit"),
("Delete Tool", "delete"),
("List Tools", "list"),
("Test Tool", "test"),
("Manage Providers", "providers"),
("Exit", "exit"),
]
listbox = snack.Listbox(height=7, width=30, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, "SmartTools Manager", 1, 1)
grid.add(listbox, 0, 0)
result = grid.runOnce()
selected = listbox.current()
if selected == "exit" or result == "ESC":
break
elif selected == "create":
self.tool_builder(None)
elif selected == "edit":
self.select_and_edit_tool()
elif selected == "delete":
self.select_and_delete_tool()
elif selected == "list":
self.show_tools_list()
elif selected == "test":
self.select_and_test_tool()
elif selected == "providers":
self.manage_providers()
def message_box(self, title: str, message: str):
"""Show a message box."""
snack.ButtonChoiceWindow(self.screen, title, message, ["OK"])
def yes_no(self, title: str, message: str) -> bool:
"""Show a yes/no dialog."""
result = snack.ButtonChoiceWindow(self.screen, title, message, ["Yes", "No"])
return result == "yes"
def input_box(self, title: str, prompt: str, initial: str = "", width: int = 40) -> Optional[str]:
"""Show an input dialog."""
entry = snack.Entry(width, initial)
grid = snack.GridForm(self.screen, title, 1, 3)
grid.add(snack.Label(prompt), 0, 0)
grid.add(entry, 0, 1, padding=(0, 1, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def text_edit(self, title: str, initial: str = "", width: int = 60, height: int = 10) -> Optional[str]:
"""Show a multi-line text editor."""
text = snack.Textbox(width, height, initial, scroll=1, wrap=1)
# snack doesn't have a true multi-line editor, so we use Entry for now
# For multi-line, we'll use a workaround with a simple entry
entry = snack.Entry(width, initial, scroll=1)
grid = snack.GridForm(self.screen, title, 1, 2)
grid.add(entry, 0, 0, padding=(0, 0, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def select_provider(self) -> Optional[str]:
"""Show provider selection dialog."""
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 1, 8), width=50, returnExit=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add New Provider ]", "__new__")
grid = snack.GridForm(self.screen, "Select Provider", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
selected = listbox.current()
if selected == "__new__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
return provider.name
return None
return selected
def add_provider_dialog(self) -> Optional[Provider]:
"""Dialog to add a new provider."""
name_entry = snack.Entry(30, "")
cmd_entry = snack.Entry(40, "")
desc_entry = snack.Entry(40, "")
grid = snack.GridForm(self.screen, "Add Provider", 2, 4)
grid.add(snack.Label("Name:"), 0, 0, anchorLeft=1)
grid.add(name_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Command:"), 0, 1, anchorLeft=1)
grid.add(cmd_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 2, anchorLeft=1)
grid.add(desc_entry, 1, 2, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 3, growx=1, growy=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
name = name_entry.value().strip()
cmd = cmd_entry.value().strip()
if name and cmd:
return Provider(name=name, command=cmd, description=desc_entry.value().strip())
self.message_box("Error", "Name and command are required.")
return None
def add_argument_dialog(self, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Dialog to add/edit an argument."""
flag_entry = snack.Entry(20, existing.flag if existing else "--")
var_entry = snack.Entry(20, existing.variable if existing else "")
default_entry = snack.Entry(20, existing.default or "" if existing else "")
desc_entry = snack.Entry(40, existing.description if existing else "")
title = "Edit Argument" if existing else "Add Argument"
grid = snack.GridForm(self.screen, title, 2, 5)
grid.add(snack.Label("Flag:"), 0, 0, anchorLeft=1)
grid.add(flag_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Variable:"), 0, 1, anchorLeft=1)
grid.add(var_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Default:"), 0, 2, anchorLeft=1)
grid.add(default_entry, 1, 2, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 3, anchorLeft=1)
grid.add(desc_entry, 1, 3, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 4, growx=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
flag = flag_entry.value().strip()
var = var_entry.value().strip()
if not flag:
self.message_box("Error", "Flag is required.")
return None
if not var:
var = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=var,
default=default_entry.value().strip() or None,
description=desc_entry.value().strip()
)
return None
def add_step_dialog(self, tool: Tool, existing_step: Optional[Step] = None, step_idx: int = -1) -> Optional[Step]:
"""Dialog to choose and add a step."""
if existing_step:
# Edit existing step
if isinstance(existing_step, PromptStep):
return self.add_prompt_dialog(tool, existing_step, step_idx)
else:
return self.add_code_dialog(tool, existing_step, step_idx)
# Choose step type
listbox = snack.Listbox(height=2, width=30, returnExit=1)
listbox.append("Prompt (AI call)", "prompt")
listbox.append("Code (Python)", "code")
grid = snack.GridForm(self.screen, "Add Step", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
step_type = listbox.current()
if step_type == "prompt":
return self.add_prompt_dialog(tool, None, step_idx)
else:
return self.add_code_dialog(tool, None, step_idx)
def get_available_variables(self, tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get available variables at a point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def add_prompt_dialog(self, tool: Tool, existing: Optional[PromptStep] = None, step_idx: int = -1) -> Optional[PromptStep]:
"""Dialog to add/edit a prompt step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
# Provider selection first
provider = self.select_provider()
if not provider:
provider = existing.provider if existing else "mock"
prompt_entry = snack.Entry(60, existing.prompt if existing else f"Process this:\n\n{{input}}", scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "result")
title = "Edit Prompt Step" if existing else "Add Prompt Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Provider: {provider}"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 0)
grid.add(snack.Label(f"Prompt ({var_help}):"), 0, 1, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 1)
grid.add(prompt_entry, 0, 2, growx=1)
grid.add(snack.Label(""), 1, 2)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 3, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 3)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
prompt = prompt_entry.value().strip()
output_var = output_entry.value().strip()
if not prompt:
self.message_box("Error", "Prompt is required.")
return None
if not output_var:
output_var = "result"
return PromptStep(prompt=prompt, provider=provider, output_var=output_var)
return None
def add_code_dialog(self, tool: Tool, existing: Optional[CodeStep] = None, step_idx: int = -1) -> Optional[CodeStep]:
"""Dialog to add/edit a code step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(available)
default_code = existing.code if existing else "# Set 'result' for output\nresult = input.upper()"
code_entry = snack.Entry(60, default_code, scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "processed")
title = "Edit Code Step" if existing else "Add Code Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Python Code ({var_help}):"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label("Set 'result' variable for output"), 1, 0)
grid.add(code_entry, 0, 1, growx=1)
grid.add(snack.Label(""), 1, 1)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 2, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
code = code_entry.value().strip()
output_var = output_entry.value().strip()
if not code:
self.message_box("Error", "Code is required.")
return None
if not output_var:
output_var = "processed"
return CodeStep(code=code, output_var=output_var)
return None
def tool_builder(self, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder - BIOS-style unified form."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Create form elements
name_entry = snack.Entry(25, tool.name, scroll=0)
desc_entry = snack.Entry(25, tool.description, scroll=1)
output_entry = snack.Entry(25, tool.output, scroll=1)
# Arguments listbox
args_listbox = snack.Listbox(height=4, width=35, returnExit=0, scroll=1)
for i, arg in enumerate(tool.arguments):
args_listbox.append(f"{arg.flag} -> {{{arg.variable}}}", i)
args_listbox.append("[ + Add ]", "add")
# Steps listbox
steps_listbox = snack.Listbox(height=5, width=35, returnExit=0, scroll=1)
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
steps_listbox.append(f"P:{step.provider} -> {{{step.output_var}}}", i)
else:
steps_listbox.append(f"C: -> {{{step.output_var}}}", i)
steps_listbox.append("[ + Add ]", "add")
# Build the grid layout using nested grids for better control
title = f"Edit Tool: {tool.name}" if is_edit and tool.name else "New Tool"
# Left column grid: Name, Description, Output
left_grid = snack.Grid(1, 6)
left_grid.setField(snack.Label("Name:"), 0, 0, anchorLeft=1)
left_grid.setField(name_entry, 0, 1, anchorLeft=1)
left_grid.setField(snack.Label("Description:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(desc_entry, 0, 3, anchorLeft=1)
left_grid.setField(snack.Label("Output:"), 0, 4, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(output_entry, 0, 5, anchorLeft=1)
# Right column grid: Arguments and Steps
right_grid = snack.Grid(1, 4)
right_grid.setField(snack.Label("Arguments:"), 0, 0, anchorLeft=1)
right_grid.setField(args_listbox, 0, 1, anchorLeft=1)
right_grid.setField(snack.Label("Execution Steps:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
right_grid.setField(steps_listbox, 0, 3, anchorLeft=1)
# Main grid
grid = snack.GridForm(self.screen, title, 2, 2)
grid.add(left_grid, 0, 0, anchorTop=1, padding=(0, 0, 2, 0))
grid.add(right_grid, 1, 0, anchorTop=1)
# Buttons at bottom
buttons = snack.ButtonBar(self.screen, [("Save", "save"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1, growx=1)
# Handle hotkeys for listbox interaction
form = grid.form
while True:
result = form.run()
# Update tool from entries
if not is_edit:
tool.name = name_entry.value().strip()
tool.description = desc_entry.value().strip()
tool.output = output_entry.value().strip()
# Check what was activated
if result == args_listbox:
selected = args_listbox.current()
if selected == "add":
new_arg = self.add_argument_dialog()
if new_arg:
tool.arguments.append(new_arg)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing argument
action = self.arg_action_menu(tool.arguments[selected])
if action == "edit":
updated = self.add_argument_dialog(tool.arguments[selected])
if updated:
tool.arguments[selected] = updated
elif action == "delete":
tool.arguments.pop(selected)
break # Refresh form
elif result == steps_listbox:
selected = steps_listbox.current()
if selected == "add":
new_step = self.add_step_dialog(tool)
if new_step:
tool.steps.append(new_step)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing step
action = self.step_action_menu(tool.steps[selected], selected, len(tool.steps))
if action == "edit":
updated = self.add_step_dialog(tool, tool.steps[selected], selected)
if updated:
tool.steps[selected] = updated
elif action == "delete":
tool.steps.pop(selected)
elif action == "move_up" and selected > 0:
tool.steps[selected], tool.steps[selected-1] = tool.steps[selected-1], tool.steps[selected]
elif action == "move_down" and selected < len(tool.steps) - 1:
tool.steps[selected], tool.steps[selected+1] = tool.steps[selected+1], tool.steps[selected]
break # Refresh form
elif buttons.buttonPressed(result) == "save":
if not tool.name:
self.message_box("Error", "Tool name is required.")
break
if not is_edit and tool_exists(tool.name):
if not self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?"):
break
self.screen.popWindow()
save_tool(tool)
self.message_box("Success", f"Tool '{tool.name}' saved!")
return tool
elif buttons.buttonPressed(result) == "cancel" or result == "ESC":
self.screen.popWindow()
return None
else:
# Tab between fields, continue
continue
self.screen.popWindow()
def arg_action_menu(self, arg: ToolArgument) -> Optional[str]:
"""Show action menu for an argument."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Argument: {arg.flag}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def step_action_menu(self, step: Step, idx: int, total: int) -> Optional[str]:
"""Show action menu for a step."""
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
items = [("Edit", "edit")]
if idx > 0:
items.append(("Move Up", "move_up"))
if idx < total - 1:
items.append(("Move Down", "move_down"))
items.append(("Delete", "delete"))
items.append(("Cancel", "cancel"))
listbox = snack.Listbox(height=len(items), width=20, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, f"Step {idx+1}: {step_type}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def select_and_edit_tool(self):
"""Select a tool to edit."""
tools = list_tools()
if not tools:
self.message_box("Edit Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
tool = load_tool(name)
desc = tool.description[:30] if tool and tool.description else "No description"
listbox.append(f"{name}: {desc}", name)
grid = snack.GridForm(self.screen, "Select Tool to Edit", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
self.tool_builder(tool)
def select_and_delete_tool(self):
"""Select a tool to delete."""
tools = list_tools()
if not tools:
self.message_box("Delete Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Delete", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Delete", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
if self.yes_no("Confirm", f"Delete tool '{selected}'?"):
if delete_tool(selected):
self.message_box("Deleted", f"Tool '{selected}' deleted.")
def show_tools_list(self):
"""Show list of all tools."""
tools = list_tools()
if not tools:
self.message_box("Tools", "No tools found.\n\nCreate one from the main menu.")
return
text = ""
for name in tools:
tool = load_tool(name)
if tool:
text += f"{name}\n"
text += f" {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(a.flag for a in tool.arguments)
text += f" Args: {args}\n"
if tool.steps:
steps = []
for s in tool.steps:
if isinstance(s, PromptStep):
steps.append(f"P:{s.provider}")
else:
steps.append("C")
text += f" Steps: {' -> '.join(steps)}\n"
text += "\n"
snack.ButtonChoiceWindow(self.screen, "Available Tools", text.strip(), ["OK"])
def select_and_test_tool(self):
"""Select a tool to test."""
tools = list_tools()
if not tools:
self.message_box("Test Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Test", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Test", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
test_input = self.input_box("Test Input", "Enter test input:", "Hello world")
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\nOutput:\n{output[:500]}"
self.message_box("Test Result", result_text)
def manage_providers(self):
"""Manage providers menu."""
while True:
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 2, 10), width=50, returnExit=1, scroll=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add Provider ]", "__add__")
listbox.append("[ <- Back ]", "__back__")
grid = snack.GridForm(self.screen, "Manage Providers", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
selected = listbox.current()
if selected == "__back__":
break
elif selected == "__add__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
self.message_box("Success", f"Provider '{provider.name}' added.")
else:
# Edit or delete
provider = get_provider(selected)
if provider:
action = self.provider_action_menu(provider)
if action == "edit":
updated = self.add_provider_dialog() # TODO: pass existing
if updated:
add_provider(updated)
elif action == "delete":
if self.yes_no("Confirm", f"Delete provider '{selected}'?"):
delete_provider(selected)
def provider_action_menu(self, provider: Provider) -> Optional[str]:
"""Show action menu for a provider."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Provider: {provider.name}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def run_ui():
"""Entry point for the snack UI."""
ui = SmartToolsUI()
ui.run()
if __name__ == "__main__":
run_ui()
File diff suppressed because it is too large Load Diff