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:
+145
-13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user