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 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-04 12:42:04 -04:00
parent 28dac465e6
commit 4c71dbded2
14 changed files with 6072 additions and 565 deletions

353
README.md
View File

@ -1,79 +1,334 @@
# SmartTools
A lightweight personal tool builder for AI-powered CLI commands.
**A lightweight personal tool builder for AI-powered CLI commands.**
## What is this?
SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, prompt, provider), then use it like any Linux command.
Turn any AI model into a Unix-style pipe command. Build once, use forever.
```bash
# Create a summarizer, then use it like any command:
sum -i document.txt -o summary.txt --max 512
# Fix grammar in any file
echo "teh cat sat on teh mat" | fix-grammar
# Output: The cat sat on the mat
# Explain errors instantly
cat error.log | explain-error
# Generate commit messages
git diff --staged | commit-msg
# Extract data as validated JSON
echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku"
# Output: {"price": 49.99, "sku": "ABC-123"}
```
## Quick Example
## Why SmartTools?
A tool is just a YAML config:
- **Unix Philosophy** - Tools that do one thing well, composable with pipes
- **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models
- **No Lock-in** - Your tools are YAML files, your prompts are yours
- **Multi-step Pipelines** - Chain AI prompts with Python code for validation
- **12+ Providers Profiled** - We tested them so you don't have to
```yaml
# ~/.smarttools/sum/config.yaml
name: sum
description: "Summarize documents"
prompt: |
Summarize the following text in {max} words or less:
{input}
provider: codex
provider_args: "-p"
inputs:
- name: max
flag: --max
default: 500
```
Then run it:
## Quick Start
```bash
sum -i myfile.txt -o summary.txt
sum -i myfile.txt --dry-run # preview prompt
sum -i myfile.txt --provider mock # test without API
# Install
pip install smarttools
# Ensure ~/.local/bin is in PATH
export PATH="$HOME/.local/bin:$PATH"
# Launch the UI
smarttools ui
# Or create your first tool
smarttools create summarize
```
## Features
- **Simple UI** - Create/edit/delete tools with `dialog`-based interface
- **CLI-first** - Tools work like regular Linux commands
- **Provider-agnostic** - Use Codex, Claude, Gemini, or any CLI AI tool
- **Testing built-in** - `--dry-run`, `--show-prompt`, `--provider mock`
- **Minimal** - ~430 lines of Python, minimal dependencies
## Installation
### From PyPI (Recommended)
```bash
pip install smarttools
```
## Usage
### From Source
```bash
# Launch UI to manage tools
smarttools
# Or use CLI:
smarttools list
smarttools create mytool
smarttools edit mytool
smarttools delete mytool
smarttools test mytool
git clone https://github.com/yourusername/smarttools.git
cd smarttools
pip install -e .
```
## Documentation
### Requirements
See [docs/DESIGN.md](docs/DESIGN.md) for the full design document.
- Python 3.10+
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
- Optional: `urwid` for the TUI (`pip install urwid`)
### Post-Install
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
Then refresh wrapper scripts:
```bash
smarttools refresh
```
## Usage
### UI Mode (Recommended for Beginners)
```bash
smarttools ui
```
Navigate with arrow keys, Tab, Enter, and mouse. Create tools visually with the built-in prompt editor.
### CLI Mode
```bash
smarttools list # List all tools
smarttools create mytool # Create new tool
smarttools edit mytool # Edit in $EDITOR
smarttools delete mytool # Delete tool
smarttools run mytool # Run a tool
smarttools test mytool # Test with mock provider
smarttools refresh # Update executable wrappers
```
### Running Tools
Once created, tools work like any Unix command:
```bash
# Direct invocation
mytool -i input.txt -o output.txt
# Pipe input (most common)
cat file.txt | mytool
# With arguments
echo "hello" | translate --lang French
# Preview without calling AI
cat file.txt | mytool --dry-run
# Test with mock provider
cat file.txt | mytool --provider mock
```
## Example Tools
SmartTools comes with 28 pre-built examples you can install:
### Text Processing
| Tool | Description | Example |
|------|-------------|---------|
| `summarize` | Condense documents | `cat article.txt \| summarize` |
| `translate` | Translate text | `echo "Hello" \| translate --lang Spanish` |
| `fix-grammar` | Fix spelling/grammar | `cat draft.txt \| fix-grammar` |
| `simplify` | Rewrite for clarity | `cat legal.txt \| simplify --level "5th grade"` |
| `tone-shift` | Change tone | `cat email.txt \| tone-shift --tone professional` |
| `eli5` | Explain like I'm 5 | `cat quantum.txt \| eli5` |
| `tldr` | One-line summary | `cat readme.txt \| tldr` |
| `expand` | Expand bullet points | `cat notes.txt \| expand` |
### Developer Tools
| Tool | Description | Example |
|------|-------------|---------|
| `explain-error` | Explain stack traces | `cat error.log \| explain-error` |
| `explain-code` | Explain what code does | `cat script.py \| explain-code` |
| `review-code` | Quick code review | `cat pr.diff \| review-code --focus security` |
| `gen-tests` | Generate unit tests | `cat module.py \| gen-tests --framework pytest` |
| `docstring` | Add docstrings | `cat functions.py \| docstring` |
| `commit-msg` | Generate commit message | `git diff --staged \| commit-msg` |
### Data Tools
| Tool | Description | Example |
|------|-------------|---------|
| `json-extract` | Extract as validated JSON | `cat text.txt \| json-extract --fields "name, email"` |
| `json2csv` | Convert JSON to CSV | `cat data.json \| json2csv` |
| `extract-emails` | Extract email addresses | `cat page.html \| extract-emails` |
| `extract-contacts` | Extract contacts as CSV | `cat notes.txt \| extract-contacts` |
| `sql-from-text` | Natural language to SQL | `echo "get active users" \| sql-from-text` |
| `safe-sql` | SQL with safety checks | `echo "delete old records" \| safe-sql` |
| `parse-log` | Analyze log files | `cat app.log \| parse-log --focus errors` |
| `csv-insights` | Analyze CSV data | `cat sales.csv \| csv-insights --question "trends?"` |
### Advanced Multi-Step Tools
| Tool | Description | Pattern |
|------|-------------|---------|
| `log-errors` | Extract & explain errors from huge logs | Code→AI |
| `diff-focus` | Review only added lines | Code→AI |
| `changelog` | Git log to formatted changelog | Code→AI→Code |
| `code-validate` | Generate syntax-checked Python | AI→Code |
### Install Example Tools
```bash
# Download and run the example installer
curl -sSL https://raw.githubusercontent.com/yourusername/smarttools/main/examples/install.py | python3
# Or manually copy from examples/
```
## Providers
SmartTools works with any AI CLI tool. We've profiled 12 providers:
| Provider | Speed | Accuracy | Cost | Best For |
|----------|-------|----------|------|----------|
| `opencode-deepseek` | 13s | 4/4 | Cheap | **Best value** - daily use |
| `opencode-pickle` | 13s | 4/4 | FREE | **Best free** - accurate |
| `claude-haiku` | 14s | 4/4 | Paid | Fast + accurate |
| `codex` | 14s | 4/4 | Paid | Reliable |
| `claude-opus` | 18s | 4/4 | $$$ | Highest quality |
| `gemini` | 91s | 3/4 | Paid | Large docs (1M tokens) |
See [docs/PROVIDERS.md](docs/PROVIDERS.md) for setup instructions.
## Tool Anatomy
A tool is a YAML config file in `~/.smarttools/<name>/config.yaml`:
```yaml
name: summarize
description: Condense documents to key points
arguments:
- flag: --length
variable: length
default: "3-5 bullet points"
steps:
- type: prompt
prompt: |
Summarize this text into {length}:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
### Multi-Step Tools
Chain AI prompts with Python code for powerful workflows:
```yaml
name: json-extract
description: Extract data as validated JSON
steps:
# Step 1: AI extracts data
- type: prompt
prompt: "Extract {fields} as JSON from: {input}"
provider: opencode-deepseek
output_var: raw_json
# Step 2: Code validates JSON
- type: code
code: |
import json
try:
parsed = json.loads(raw_json)
validated = json.dumps(parsed, indent=2)
except:
validated = "ERROR: Invalid JSON"
output_var: validated
output: "{validated}"
```
### Variables
| Variable | Description |
|----------|-------------|
| `{input}` | The piped/file input |
| `{argname}` | Custom argument value |
| `{step_output}` | Output from previous step |
## Shell Integration
### Git Hooks
```bash
# .git/hooks/prepare-commit-msg
#!/bin/bash
git diff --cached | commit-msg > "$1"
```
### Aliases
```bash
# ~/.bashrc
alias gc='git diff --staged | commit-msg'
alias wtf='explain-error'
alias fixme='fix-grammar'
```
### Vim Integration
```vim
" Fix grammar in selection
vnoremap <leader>fg :!fix-grammar<CR>
" Explain selected code
vnoremap <leader>ec :!explain-code<CR>
```
## UI Navigation
| Action | Keys |
|--------|------|
| Cycle sections | `Tab` |
| Go back | `Escape` |
| Select | `Enter` or click |
| Navigate | Arrow keys |
| **Select text** | `Shift` + mouse drag |
| **Copy** | Terminal native (with Shift) |
| **Paste** | `Ctrl+Shift+V` |
**Tip:** Hold `Shift` while using mouse for terminal-native text selection.
## Philosophy
This is a **personal power tool**. You write the tools, you run the tools, you accept the responsibility. No trust tiers, no sandboxing, no signing - just like any bash script you write.
SmartTools is a **personal power tool**:
- **You own your tools** - YAML files you can read, edit, share
- **You choose the AI** - Any provider, swap anytime
- **You accept responsibility** - No sandboxing, like any script you write
- **Unix philosophy** - Small tools that compose
## Contributing
```bash
git clone https://github.com/yourusername/smarttools.git
cd smarttools
pip install -e ".[dev]"
pytest
```
## Support
If SmartTools saves you time, consider:
- [GitHub Sponsors](https://github.com/sponsors/yourusername)
- [Buy Me a Coffee](https://buymeacoffee.com/yourusername)
## License
MIT
MIT - Use it, modify it, share it.
## Links
- [Installation Guide](docs/INSTALL.md)
- [Provider Setup](docs/PROVIDERS.md)
- [Example Tools](docs/EXAMPLES.md)
- [Design Document](docs/DESIGN.md)

View File

@ -1,10 +1,10 @@
# SmartTools - Simple Design
# SmartTools - Design Document
> A lightweight personal tool builder for AI-powered CLI commands
## Overview
SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, prompt, provider), then use it like any Linux command.
SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, steps, provider), then use it like any Linux command.
**Example:**
```bash
@ -20,44 +20,91 @@ sum -i text.txt -o summary.txt --max 512
~/.smarttools/
sum/
config.yaml
post.py # optional post-processing script
processed.py # Optional external code file
reviewer/
config.yaml
translator/
config.yaml
```
### Minimal config.yaml
### config.yaml Format
```yaml
name: sum
description: "Summarize documents"
prompt: |
Summarize the following text in {max} words or less:
arguments:
- flag: --max
variable: max
default: "500"
description: "Maximum words in summary"
steps:
- type: prompt
prompt: |
Summarize the following text in {max} words or less:
{input}
provider: codex
provider_args: "-p"
# Optional
inputs:
- name: max
flag: --max
default: 500
# Optional post-processing script
post_process: post.py
{input}
provider: claude
output_var: response
output: "{response}"
```
That's it. No trust tiers, no signing, no containers.
### Step Types
**Prompt Step** - Calls an AI provider:
```yaml
- type: prompt
prompt: "Your prompt template with {variables}"
provider: claude
output_var: response
```
**Code Step** - Runs Python code:
```yaml
- type: code
code: |
processed = input.upper()
count = len(processed.split())
output_var: processed, count
code_file: processed.py # Optional: external file storage
```
Steps execute in order. Each step's `output_var` becomes available to subsequent steps.
### Variables
- `{input}` - Always available, contains stdin or input file content (empty string if no input)
- `{variable_name}` - From arguments (e.g., `{max}`)
- `{output_var}` - From previous steps (e.g., `{response}`, `{processed}`)
### Output Variables
The `output_var` field specifies which Python variable(s) to capture from your code:
**Single variable:**
```yaml
output_var: processed
```
```python
processed = input.upper() # This gets captured
```
**Multiple variables (comma-separated):**
```yaml
output_var: processed, count, summary
```
```python
processed = input.upper()
count = len(processed.split())
summary = f"Processed {count} words"
# All three are captured and available as {processed}, {count}, {summary}
```
## CLI Interface
### Running Tools
```bash
# Basic usage
# Basic usage (wrapper script in ~/.local/bin)
sum -i document.txt -o summary.txt
# With custom args
@ -74,14 +121,33 @@ sum -i document.txt --provider mock
# Read from stdin, write to stdout
cat doc.txt | sum | less
# Interactive stdin input
sum --stdin
# No input (empty string) - useful for tools using only arguments
sum --max 100
# Or via smarttools run
smarttools run sum -i document.txt
```
### Input Handling
| Scenario | Behavior |
|----------|----------|
| Piped stdin | Automatically read (`cat file.txt \| mytool`) |
| `-i file.txt` | Read from file |
| `--stdin` | Interactive input (type then Ctrl+D) |
| No input | Empty string (useful for argument-only tools) |
### Universal Flags (all tools)
| Flag | Short | Description |
|------|-------|-------------|
| `--input` | `-i` | Input file (or stdin if omitted) |
| `--input` | `-i` | Input file |
| `--output` | `-o` | Output file (or stdout if omitted) |
| `--stdin` | | Read input interactively (type then Ctrl+D) |
| `--dry-run` | | Show prompt, don't call AI |
| `--show-prompt` | | Call AI but also print prompt to stderr |
| `--provider` | `-p` | Override provider (e.g., `--provider mock`) |
@ -96,15 +162,18 @@ smarttools
# Or use CLI directly:
smarttools list # List all tools
smarttools create sum # Create new tool
smarttools edit sum # Edit existing tool
smarttools create sum # Create new tool (basic)
smarttools edit sum # Edit tool config in $EDITOR
smarttools delete sum # Delete tool
smarttools test sum # Test with mock provider
smarttools run sum # Run tool for real
smarttools refresh # Refresh all wrapper scripts
smarttools ui # Launch interactive UI
```
## Lightweight UI
## Terminal UI
A simple terminal UI using `dialog` or `whiptail` (available on most Linux systems).
A BIOS-style terminal UI using `urwid` with full mouse support.
### Main Menu
@ -112,66 +181,84 @@ A simple terminal UI using `dialog` or `whiptail` (available on most Linux syste
┌──────────────────────────────────────┐
│ SmartTools Manager │
├──────────────────────────────────────┤
│ > List Tools │
│ Create New Tool │
│ Edit Tool │
│ Delete Tool │
│ Test Tool │
│ Exit │
│ Tools: │
│ ┌────────────────────────────────┐ │
│ │ sum │ │
│ │ reviewer │ │
│ │ translator │ │
│ └────────────────────────────────┘ │
│ │
│ [Create] [Edit] [Delete] [Test] │
│ │
│ Tool Info: │
│ Name: sum │
│ Description: Summarize documents │
│ Arguments: --max │
│ Steps: PROMPT[claude] -> {response} │
│ │
│ [EXIT] │
└──────────────────────────────────────┘
```
### Create/Edit Tool Form
### Tool Builder
```
┌──────────────────────────────────────┐
│ Create New Tool │
├──────────────────────────────────────┤
│ Name: [sum________________] │
│ Description: [Summarize documents_] │
│ Provider: [codex_____________] │
│ Provider Args: [-p________________] │
│ │
│ Prompt: │
│ ┌──────────────────────────────────┐ │
│ │ Summarize the following text │ │
│ │ in {max} words or less: │ │
│ │ │ │
│ │ {input} │ │
│ └──────────────────────────────────┘ │
│ │
│ Custom Arguments: │
│ --max (default: 500) │
│ [Add Argument] │
│ │
│ Post-process Script: [none______▼] │
│ │
│ [Save] [Test] [Cancel] │
└──────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ New Tool │
├──────────────────────────────────────────────────────────────┤
│ ┌─[ Tool Info ]──────────┐ ┌─ Arguments ─────────────────┐ │
│ │ Name: [sum__________] │ │ --max -> {max} │ │
│ │ │ │ │ │
│ │ Desc: [Summarize____] │ └─────────────────────────────┘ │
│ │ │ [Add] [Edit] [Delete] │
│ │ Output: [{response}_] │ │
│ └────────────────────────┘ ┌─ Execution Steps ───────────┐ │
│ │ P:claude -> {response} │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ [Add] [Edit] [Delete] │
│ │
│ [Save] [Cancel] │
└──────────────────────────────────────────────────────────────┘
```
### Test Tool
**Navigation:**
- **Tab** - Cycle between sections (Tool Info -> Arguments -> Steps -> Buttons)
- **Arrow keys** - Navigate within lists
- **Enter/Click** - Select/activate
- **Escape** - Cancel/back
- **Shift + mouse** - Terminal-native text selection (bypasses UI mouse handling)
- **Ctrl+Shift+V** - Paste
The current section's title is highlighted with brackets: `[ Tool Info ]`
**Copy/Paste tip:** Hold `Shift` while dragging with mouse to select text, then use your terminal's copy function (usually right-click or Ctrl+Shift+C).
### Code Step Dialog
```
┌──────────────────────────────────────┐
│ Test Tool: sum │
├──────────────────────────────────────┤
│ Input: [Select file...] or [Paste] │
│ │
│ ┌─ Final Prompt ───────────────────┐ │
│ │ Summarize the following text │ │
│ │ in 500 words or less: │ │
│ │ │ │
│ │ Lorem ipsum dolor sit amet... │ │
│ └──────────────────────────────────┘ │
│ │
│ Provider: [mock_____▼] │
│ │
│ [Run Test] [Copy Command] │
└──────────────────────────────────────┘
┌──────────────── Add Code Step ─────────────────┐
│ Variables: input, max, response │
│ │
│ File: [processed.py______________] < Load >
│ │
│ ┌─ Code ─────────────────────────────────────┐ │
│ │processed = input.upper() │ │
│ │count = len(processed.split()) │ │
│ │ │ │
│ └────────────────────────────────────────────┘ │
│ │
│ Output var: [processed, count____] │
├────────────────────────────────────────────────┤
< OK > < Cancel >
└────────────────────────────────────────────────┘
```
"Copy Command" shows: `sum -i test.txt --dry-run`
**Code Step Features:**
- **Multiline editor** - Write multi-line Python code
- **External file storage** - Code is auto-saved to `~/.smarttools/<toolname>/<filename>` on OK
- **Load button** - Load code from external file (with confirmation)
- **Multiple output vars** - Capture multiple variables (comma-separated)
## Implementation
@ -181,79 +268,54 @@ A simple terminal UI using `dialog` or `whiptail` (available on most Linux syste
smarttools/
__init__.py
cli.py # Entry point, argument parsing
ui.py # Dialog-based UI
tool.py # Tool loading/saving
ui.py # UI dispatcher (chooses urwid/snack/dialog)
ui_urwid.py # Urwid-based BIOS-style UI
ui_snack.py # Snack/newt fallback UI
tool.py # Tool loading/saving/wrapper scripts
runner.py # Execute tools
providers.py # Provider abstraction (minimal)
providers.py # Provider abstraction
```
### Provider Abstraction (Simple)
### Provider System
```python
# providers.py
import subprocess
Providers are defined in `~/.smarttools/providers.yaml`:
def call_provider(provider: str, args: str, prompt: str) -> str:
"""Call an AI CLI tool with the given prompt."""
cmd = f"{provider} {args}"
result = subprocess.run(
cmd,
shell=True,
input=prompt,
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"Provider failed: {result.stderr}")
return result.stdout
def mock_provider(prompt: str) -> str:
"""Return a mock response for testing."""
return f"[MOCK RESPONSE]\nReceived prompt of {len(prompt)} chars"
```yaml
providers:
- name: claude
command: "claude -p"
description: "Anthropic Claude"
- name: codex
command: "codex -p"
description: "OpenAI Codex"
- name: gemini
command: "gemini"
description: "Google Gemini"
```
### Tool Runner (Simple)
The provider command receives the prompt via stdin and outputs to stdout.
```python
# runner.py
import yaml
from pathlib import Path
### Wrapper Scripts
TOOLS_DIR = Path.home() / ".smarttools"
def load_tool(name: str) -> dict:
config_path = TOOLS_DIR / name / "config.yaml"
return yaml.safe_load(config_path.read_text())
def build_prompt(tool: dict, input_text: str, args: dict) -> str:
prompt = tool["prompt"]
prompt = prompt.replace("{input}", input_text)
for key, value in args.items():
prompt = prompt.replace(f"{{{key}}}", str(value))
return prompt
def run_tool(name: str, input_text: str, args: dict, provider_override: str = None) -> str:
tool = load_tool(name)
prompt = build_prompt(tool, input_text, args)
provider = provider_override or tool["provider"]
if provider == "mock":
return mock_provider(prompt)
return call_provider(provider, tool.get("provider_args", ""), prompt)
```
### Generated Wrapper Script
When you create a tool, SmartTools generates a wrapper script:
When you save a tool, SmartTools creates a wrapper script in `~/.local/bin/`:
```bash
#!/bin/bash
# ~/.smarttools/sum/sum (auto-generated)
exec python3 -m smarttools.run sum "$@"
# SmartTools wrapper for 'sum'
# Auto-generated - do not edit
exec /path/to/python -m smarttools.runner sum "$@"
```
And symlinks it to `~/.local/bin/sum` so you can call `sum` directly.
The wrapper uses the full Python path to ensure the correct environment is used.
Use `smarttools refresh` to regenerate all wrapper scripts (e.g., after changing Python environments).
### Tool Name Validation
Tool names must:
- Start with a letter or underscore
- Contain only letters, numbers, underscores, and dashes
- Not contain spaces or shell-problematic characters (`/\|&;$`"'<>(){}[]!?*#~`)
## What This Design Doesn't Include
@ -272,33 +334,31 @@ Intentionally omitted (not needed for personal use):
## Dependencies
Minimal:
Required:
- Python 3.10+
- PyYAML
- `dialog` or `whiptail` (pre-installed on most Linux)
- urwid (for TUI)
Optional:
- `textual` (if you want a fancier TUI later)
## File Sizes
Estimated implementation:
- `cli.py`: ~100 lines
- `ui.py`: ~150 lines
- `tool.py`: ~80 lines
- `runner.py`: ~60 lines
- `providers.py`: ~40 lines
**Total: ~430 lines of Python**
Optional fallbacks:
- python3-newt/snack (simpler TUI)
- dialog/whiptail (basic TUI)
## Example Workflow
1. Run `smarttools` to open UI
2. Select "Create New Tool"
3. Fill in: name=`sum`, prompt, provider=`codex`
4. Click "Test" to verify with mock provider
5. Click "Save"
6. Exit UI
7. Run `sum -i myfile.txt -o summary.txt`
2. Select "Create" to create a new tool
3. Fill in: name, description, output template
4. Add arguments (e.g., `--max` with default `500`)
5. Add a prompt step with your prompt template and provider
6. Click "Save"
7. Exit UI
8. Run `sum -i myfile.txt -o summary.txt`
Done. No containers, no signing, no certification. Just a tool that works.
## Default Values
| Step Type | Default Output Var | Default File |
|-----------|-------------------|--------------|
| Prompt | `response` | n/a |
| Code | `processed` | `processed.py` |

960
docs/EXAMPLES.md Normal file
View File

@ -0,0 +1,960 @@
# Example Tools
This document contains all 28 pre-built SmartTools with their full configurations.
## Quick Install
```bash
# Install all example tools
curl -sSL https://raw.githubusercontent.com/yourusername/smarttools/main/examples/install.py | python3
smarttools refresh
```
## Text Processing Tools
### summarize
Condense long documents to key points.
```yaml
# ~/.smarttools/summarize/config.yaml
name: summarize
description: Condense long documents to key points
arguments:
- flag: --length
variable: length
default: "3-5 bullet points"
steps:
- type: prompt
prompt: |
Summarize the following text into {length}. Be concise and capture the key points:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat article.txt | summarize
cat book.txt | summarize --length "10 bullet points"
```
---
### translate
Translate text to any language.
```yaml
name: translate
description: Translate text to any language
arguments:
- flag: --lang
variable: lang
default: Spanish
steps:
- type: prompt
prompt: |
Translate the following text to {lang}. Only output the translation, nothing else:
{input}
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
echo "Hello, world!" | translate --lang French
cat readme.md | translate --lang Japanese
```
---
### fix-grammar
Fix grammar and spelling errors.
```yaml
name: fix-grammar
description: Fix grammar and spelling errors
arguments: []
steps:
- type: prompt
prompt: |
Fix all grammar, spelling, and punctuation errors in the following text. Only output the corrected text, no explanations:
{input}
provider: opencode-deepseek
output_var: response
output: "{response}"
```
**Usage:**
```bash
echo "teh cat sat on teh mat" | fix-grammar
cat draft.txt | fix-grammar > fixed.txt
```
---
### simplify
Rewrite text for easier understanding.
```yaml
name: simplify
description: Rewrite text for easier understanding
arguments:
- flag: --level
variable: level
default: "5th grade reading level"
steps:
- type: prompt
prompt: |
Rewrite the following text for a {level}. Keep the meaning but use simpler words and shorter sentences:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat legal_document.txt | simplify
cat technical.md | simplify --level "non-technical reader"
```
---
### tone-shift
Change the tone of text.
```yaml
name: tone-shift
description: Change the tone of text
arguments:
- flag: --tone
variable: tone
default: professional
steps:
- type: prompt
prompt: |
Rewrite the following text in a {tone} tone. Keep the core message but adjust the style:
{input}
provider: opencode-deepseek
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat angry_email.txt | tone-shift --tone "calm and professional"
cat casual_note.txt | tone-shift --tone formal
```
---
### eli5
Explain like I'm 5.
```yaml
name: eli5
description: Explain like I'm 5
arguments: []
steps:
- type: prompt
prompt: |
Explain this like I'm 5 years old. Use simple words and fun analogies:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
**Usage:**
```bash
echo "What is quantum computing?" | eli5
cat whitepaper.txt | eli5
```
---
### tldr
One-line summary.
```yaml
name: tldr
description: One-line summary
arguments: []
steps:
- type: prompt
prompt: |
Give a one-line TL;DR summary of this text:
{input}
provider: opencode-grok
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat long_article.txt | tldr
curl -s https://example.com | tldr
```
---
### expand
Expand bullet points to paragraphs.
```yaml
name: expand
description: Expand bullet points to paragraphs
arguments: []
steps:
- type: prompt
prompt: |
Expand these bullet points into well-written paragraphs:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat notes.txt | expand
echo "- Fast\n- Reliable\n- Easy to use" | expand
```
---
## Developer Tools
### explain-error
Explain error messages and stack traces.
```yaml
name: explain-error
description: Explain error messages and stack traces
arguments: []
steps:
- type: prompt
prompt: |
Explain this error/stack trace in plain English. What went wrong and how to fix it:
{input}
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat error.log | explain-error
python script.py 2>&1 | explain-error
```
---
### explain-code
Explain what code does.
```yaml
name: explain-code
description: Explain what code does
arguments:
- flag: --detail
variable: detail
default: moderate
steps:
- type: prompt
prompt: |
Explain what this code does at a {detail} level of detail:
```
{input}
```
provider: opencode-pickle
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat script.py | explain-code
cat complex.js | explain-code --detail "very detailed"
```
---
### review-code
Quick code review with suggestions.
```yaml
name: review-code
description: Quick code review with suggestions
arguments:
- flag: --focus
variable: focus
default: "bugs, security, and improvements"
steps:
- type: prompt
prompt: |
Review this code focusing on {focus}. Be concise and actionable:
```
{input}
```
provider: claude-sonnet
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat pull_request.diff | review-code
cat auth.py | review-code --focus "security vulnerabilities"
```
---
### gen-tests
Generate unit tests for code.
```yaml
name: gen-tests
description: Generate unit tests for code
arguments:
- flag: --framework
variable: framework
default: pytest
steps:
- type: prompt
prompt: |
Generate comprehensive unit tests for this code using {framework}. Include edge cases:
```
{input}
```
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat utils.py | gen-tests
cat api.js | gen-tests --framework jest
```
---
### docstring
Add docstrings to functions/classes.
```yaml
name: docstring
description: Add docstrings to functions/classes
arguments:
- flag: --style
variable: style
default: Google style
steps:
- type: prompt
prompt: |
Add {style} docstrings to all functions and classes in this code. Output the complete code with docstrings:
```
{input}
```
provider: opencode-deepseek
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat module.py | docstring
cat functions.py | docstring --style "NumPy style"
```
---
### commit-msg
Generate commit message from diff.
```yaml
name: commit-msg
description: Generate commit message from diff
arguments:
- flag: --style
variable: style
default: conventional commits
steps:
- type: prompt
prompt: |
Generate a concise {style} commit message for this diff. Just the message, no explanation:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
**Usage:**
```bash
git diff --staged | commit-msg
git diff HEAD~1 | commit-msg --style "simple"
```
---
## Data Tools
### json-extract
Extract structured data as validated JSON.
```yaml
name: json-extract
description: Extract structured data as validated JSON
arguments:
- flag: --fields
variable: fields
default: any relevant fields
steps:
- type: prompt
prompt: |
Extract {fields} from this text as a JSON object. Output ONLY valid JSON, no markdown, no explanation:
{input}
provider: opencode-deepseek
output_var: raw_json
- type: code
code: |
import json
import re
text = raw_json.strip()
text = re.sub(r'^```json?\s*', '', text)
text = re.sub(r'\s*```$', '', text)
try:
parsed = json.loads(text)
validated = json.dumps(parsed, indent=2)
except json.JSONDecodeError as e:
validated = f"ERROR: Invalid JSON - {e}\nRaw output: {text[:500]}"
output_var: validated
output: "{validated}"
```
**Usage:**
```bash
echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku"
cat invoice.txt | json-extract --fields "total, date, items"
```
---
### json2csv
Convert JSON to CSV format.
```yaml
name: json2csv
description: Convert JSON to CSV format
arguments: []
steps:
- type: prompt
prompt: |
Convert this JSON to CSV format. Output only the CSV, no explanation:
{input}
provider: opencode-deepseek
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat data.json | json2csv
echo '[{"name":"John","age":30}]' | json2csv
```
---
### extract-emails
Extract email addresses from text.
```yaml
name: extract-emails
description: Extract email addresses from text
arguments: []
steps:
- type: prompt
prompt: |
Extract all email addresses from this text. Output one email per line, nothing else:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat webpage.html | extract-emails
cat contacts.txt | extract-emails | sort -u
```
---
### extract-contacts
Extract contact info as structured CSV.
```yaml
name: extract-contacts
description: Extract contact info as structured CSV
arguments: []
steps:
- type: prompt
prompt: |
Extract all contact information (name, email, phone, company) from this text. Output as JSON array with objects having keys: name, email, phone, company. Use null for missing fields. Output ONLY the JSON array:
{input}
provider: opencode-pickle
output_var: contacts_json
- type: code
code: |
import json
import re
text = contacts_json.strip()
text = re.sub(r'^```json?\s*', '', text)
text = re.sub(r'\s*```$', '', text)
try:
contacts = json.loads(text)
lines = ['name,email,phone,company']
for c in contacts:
row = [
str(c.get('name') or ''),
str(c.get('email') or ''),
str(c.get('phone') or ''),
str(c.get('company') or '')
]
lines.append(','.join(f'"{f}"' for f in row))
csv_output = '\n'.join(lines)
except:
csv_output = f"Error parsing contacts: {text[:200]}"
output_var: csv_output
output: "{csv_output}"
```
**Usage:**
```bash
cat business_cards.txt | extract-contacts
cat meeting_notes.txt | extract-contacts > contacts.csv
```
---
### sql-from-text
Generate SQL from natural language.
```yaml
name: sql-from-text
description: Generate SQL from natural language
arguments:
- flag: --dialect
variable: dialect
default: PostgreSQL
steps:
- type: prompt
prompt: |
Generate a {dialect} SQL query for this request. Output only the SQL, no explanation:
{input}
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
echo "get all users who signed up last month" | sql-from-text
echo "count orders by status" | sql-from-text --dialect MySQL
```
---
### safe-sql
Generate SQL with safety checks.
```yaml
name: safe-sql
description: Generate SQL with safety checks
arguments:
- flag: --dialect
variable: dialect
default: PostgreSQL
steps:
- type: prompt
prompt: |
Generate a {dialect} SELECT query for: {input}
Output ONLY the SQL query, no explanation.
provider: claude-haiku
output_var: raw_sql
- type: code
code: |
import re
sql = raw_sql.strip()
sql = re.sub(r'^```sql?\s*', '', sql)
sql = re.sub(r'\s*```$', '', sql)
dangerous = ['DROP', 'DELETE', 'TRUNCATE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT']
warnings = []
for keyword in dangerous:
if re.search(r'\b' + keyword + r'\b', sql, re.I):
warnings.append(f"WARNING: Contains {keyword}")
if warnings:
validated = '\n'.join(warnings) + '\n\n' + sql
else:
validated = sql
output_var: validated
output: "{validated}"
```
**Usage:**
```bash
echo "get active users" | safe-sql
echo "remove inactive accounts" | safe-sql # Will show WARNING
```
---
### parse-log
Summarize and analyze log files.
```yaml
name: parse-log
description: Summarize and analyze log files
arguments:
- flag: --focus
variable: focus
default: errors and warnings
steps:
- type: prompt
prompt: |
Analyze these logs focusing on {focus}. Summarize key issues and patterns:
{input}
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat app.log | parse-log
tail -1000 /var/log/syslog | parse-log --focus "security events"
```
---
### csv-insights
Analyze CSV with sampled data for large files.
```yaml
name: csv-insights
description: Analyze CSV with sampled data for large files
arguments:
- flag: --question
variable: question
default: What patterns and insights can you find?
steps:
- type: code
code: |
import random
lines = input.strip().split('\n')
header = lines[0] if lines else ''
data_lines = lines[1:] if len(lines) > 1 else []
if len(data_lines) > 50:
sampled = random.sample(data_lines, 50)
sample_note = f"(Sampled 50 of {len(data_lines)} rows)"
else:
sampled = data_lines
sample_note = f"({len(data_lines)} rows)"
sampled_csv = header + '\n' + '\n'.join(sampled)
stats = f"Columns: {len(header.split(','))}, Rows: {len(data_lines)} {sample_note}"
output_var: sampled_csv,stats
- type: prompt
prompt: |
Analyze this CSV data. {stats}
Question: {question}
Data:
{sampled_csv}
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat sales.csv | csv-insights
cat large_data.csv | csv-insights --question "What are the top trends?"
```
---
## Advanced Multi-Step Tools
### log-errors
Extract and explain errors from large log files.
```yaml
name: log-errors
description: Extract and explain errors from large log files
arguments: []
steps:
- type: code
code: |
import re
lines = input.split('\n')
errors = [l for l in lines if re.search(r'\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\b', l, re.I)]
result = []
for i, line in enumerate(lines):
if re.search(r'\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\b', line, re.I):
result.extend(lines[i:i+5])
extracted = '\n'.join(result[:200])
output_var: extracted
- type: prompt
prompt: |
Analyze these error log entries. Group by error type, explain likely causes, and suggest fixes:
{extracted}
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
cat huge_app.log | log-errors
zcat archived.log.gz | log-errors
```
---
### diff-focus
Review only the added/changed code in a diff.
```yaml
name: diff-focus
description: Review only the added/changed code in a diff
arguments: []
steps:
- type: code
code: |
lines = input.split('\n')
result = []
for i, line in enumerate(lines):
if line.startswith('@@') or line.startswith('+++') or line.startswith('---'):
result.append(line)
elif line.startswith('+') and not line.startswith('+++'):
result.append(line)
extracted = '\n'.join(result)
output_var: extracted
- type: prompt
prompt: |
Review these added lines of code. Focus on bugs, security issues, and improvements:
{extracted}
provider: claude-haiku
output_var: response
output: "{response}"
```
**Usage:**
```bash
git diff | diff-focus
git diff HEAD~5 | diff-focus
```
---
### changelog
Generate changelog from git commits.
```yaml
name: changelog
description: Generate changelog from git commits
arguments:
- flag: --since
variable: since
default: last week
steps:
- type: code
code: |
lines = input.strip().split('\n')
commits = []
for line in lines:
if line.strip() and not line.startswith('commit '):
commits.append(line.strip())
git_log = '\n'.join(commits[:100])
output_var: git_log
- type: prompt
prompt: |
Generate a user-friendly changelog from these git commits. Group by category (Features, Fixes, Improvements, etc). Use markdown with bullet points:
{git_log}
provider: opencode-pickle
output_var: changelog_raw
- type: code
code: |
from datetime import datetime
header = f"""# Changelog
Generated: {datetime.now().strftime('%Y-%m-%d')}
"""
formatted = header + changelog_raw
output_var: formatted
output: "{formatted}"
```
**Usage:**
```bash
git log --oneline -50 | changelog
git log --since="2024-01-01" --oneline | changelog
```
---
### code-validate
Generate and validate Python code.
```yaml
name: code-validate
description: Generate and validate Python code
arguments:
- flag: --task
variable: task
default: ""
steps:
- type: prompt
prompt: |
Write Python code to: {task}
Context/input data:
{input}
Output ONLY the Python code, no markdown, no explanation.
provider: claude-haiku
output_var: raw_code
- type: code
code: |
import ast
import re
code = raw_code.strip()
code = re.sub(r'^```python?\s*', '', code)
code = re.sub(r'\s*```$', '', code)
try:
ast.parse(code)
validated = code
except SyntaxError as e:
validated = f"# SYNTAX ERROR at line {e.lineno}: {e.msg}\n# Fix needed:\n\n{code}"
output_var: validated
output: "{validated}"
```
**Usage:**
```bash
echo "list of numbers: 1,2,3,4,5" | code-validate --task "calculate the sum"
cat data.json | code-validate --task "parse and find the maximum value"
```
---
## Writing Your Own Tools
### Basic Structure
```yaml
name: my-tool
description: What this tool does
arguments:
- flag: --option
variable: option_name
default: "default value"
steps:
- type: prompt
prompt: |
Your prompt here with {input} and {option_name}
provider: opencode-pickle
output_var: response
output: "{response}"
```
### Multi-Step Structure
```yaml
name: my-pipeline
description: Multi-step tool
steps:
# Step 1: Preprocess with code
- type: code
code: |
# Python code here
processed = input.upper()
output_var: processed
# Step 2: AI processing
- type: prompt
prompt: "Analyze: {processed}"
provider: claude-haiku
output_var: analysis
# Step 3: Post-process with code
- type: code
code: |
result = f"RESULT:\n{analysis}"
output_var: result
output: "{result}"
```
### Tips
1. **Use cheap providers** for simple tasks (`opencode-pickle`)
2. **Use code steps** to validate AI output
3. **Preprocess large inputs** to reduce tokens
4. **Test with mock** before using real AI: `--provider mock`

247
docs/INSTALL.md Normal file
View File

@ -0,0 +1,247 @@
# Installation Guide
Complete installation instructions for SmartTools.
## Quick Install
```bash
pip install smarttools
export PATH="$HOME/.local/bin:$PATH"
smarttools refresh
```
## Detailed Installation
### Step 1: Install Python 3.10+
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install python3 python3-pip
```
**macOS:**
```bash
brew install python@3.11
```
**Verify:**
```bash
python3 --version # Should be 3.10 or higher
```
### Step 2: Install SmartTools
**From PyPI (recommended):**
```bash
pip install smarttools
```
**From source (for development):**
```bash
git clone https://github.com/yourusername/smarttools.git
cd smarttools
pip install -e .
```
**With TUI support (recommended):**
```bash
pip install smarttools urwid
```
### Step 3: Configure PATH
SmartTools creates executable wrappers in `~/.local/bin/`. Add this to your shell config:
**Bash (~/.bashrc):**
```bash
export PATH="$HOME/.local/bin:$PATH"
```
**Zsh (~/.zshrc):**
```bash
export PATH="$HOME/.local/bin:$PATH"
```
**Fish (~/.config/fish/config.fish):**
```fish
set -gx PATH $HOME/.local/bin $PATH
```
Then reload:
```bash
source ~/.bashrc # or restart terminal
```
### Step 4: Install an AI Provider
You need at least one AI CLI tool. Choose based on your needs:
#### Free Options
**OpenCode (recommended for free):**
```bash
# Install
curl -fsSL https://opencode.ai/install | bash
# Authenticate (uses browser)
~/.opencode/bin/opencode auth
```
#### Paid Options
**Claude CLI:**
```bash
# Install
npm install -g @anthropic-ai/claude-cli
# Authenticate
claude auth
```
**Gemini CLI:**
```bash
# Install
pip install google-generativeai
# Or the CLI wrapper
npm install -g @anthropic-ai/gemini-cli
```
**Codex (OpenAI):**
```bash
npm install -g codex-cli
codex auth
```
See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
### Step 5: Verify Installation
```bash
# Check SmartTools is installed
smarttools --version
# List available providers
smarttools providers
# Test with mock provider (no AI needed)
echo "hello world" | smarttools run --provider mock
```
### Step 6: Create Wrapper Scripts
After installation or any Python environment change:
```bash
smarttools refresh
```
This creates executable scripts in `~/.local/bin/` for all your tools.
## Installing Example Tools
### Quick Install (All 28 Tools)
```bash
curl -sSL https://raw.githubusercontent.com/yourusername/smarttools/main/examples/install.py | python3
smarttools refresh
```
### Manual Install
Copy individual tool configs to `~/.smarttools/<toolname>/config.yaml`.
See [EXAMPLES.md](EXAMPLES.md) for all tool configurations.
## Troubleshooting
### "smarttools: command not found"
PATH is not configured. Add to your shell config:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
### "Provider 'X' not found"
1. Check provider is installed: `which claude` or `which opencode`
2. Check provider is configured: `smarttools providers`
3. Add missing provider: `smarttools providers add`
### "Permission denied" on tool execution
Refresh wrapper scripts:
```bash
smarttools refresh
```
### TUI doesn't start / looks broken
Install urwid:
```bash
pip install urwid
```
Ensure your terminal supports mouse (most modern terminals do).
### Tools work with `smarttools run` but not directly
The wrapper scripts may be outdated. Refresh them:
```bash
smarttools refresh
```
### Python version mismatch
If you have multiple Python versions, ensure SmartTools uses the right one:
```bash
python3.11 -m pip install smarttools
```
## Uninstalling
```bash
# Remove package
pip uninstall smarttools
# Remove config and tools (optional)
rm -rf ~/.smarttools
# Remove wrapper scripts (optional)
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
```
## Upgrading
```bash
pip install --upgrade smarttools
smarttools refresh
```
## Directory Structure
After installation:
```
~/.smarttools/
├── providers.yaml # Provider configurations
├── summarize/
│ └── config.yaml # Tool config
├── translate/
│ ├── config.yaml
│ └── prompt.txt # External prompt file (optional)
└── ...
~/.local/bin/
├── smarttools # Main command
├── summarize # Tool wrapper script
├── translate # Tool wrapper script
└── ...
```
## Next Steps
1. [Set up providers](PROVIDERS.md)
2. [Browse example tools](EXAMPLES.md)
3. Run `smarttools ui` to create your first tool

312
docs/PROVIDERS.md Normal file
View File

@ -0,0 +1,312 @@
# Provider Setup Guide
SmartTools works with any AI CLI tool that accepts input via stdin or arguments. This guide covers setup for the most popular providers.
## Provider Comparison
We profiled 12 providers with a 4-task benchmark (Math, Code, Reasoning, Data Extraction):
| Provider | Speed | Score | Cost | Best For |
|----------|-------|-------|------|----------|
| **opencode-deepseek** | 13s | 4/4 | ~$0.28/M tokens | **Best value** - daily driver |
| **opencode-pickle** | 13s | 4/4 | FREE | **Best free** - accurate |
| **claude-haiku** | 14s | 4/4 | ~$0.25/M tokens | Fast + high quality |
| **codex** | 14s | 4/4 | ~$1.25/M tokens | Reliable, auto-routes |
| **claude** | 18s | 4/4 | Varies | Auto-routes to best |
| **claude-opus** | 18s | 4/4 | ~$15/M tokens | Highest quality |
| **claude-sonnet** | 21s | 4/4 | ~$3/M tokens | Balanced |
| **opencode-nano** | 24s | 4/4 | Paid | GPT-5 Nano |
| **gemini-flash** | 28s | 4/4 | ~$0.075/M tokens | Google, faster |
| **opencode-reasoner** | 33s | 4/4 | ~$0.28/M tokens | Complex reasoning |
| **gemini** | 91s | 3/4 | ~$1.25/M tokens | 1M token context |
| **opencode-grok** | 11s | 2/4 | FREE | Fastest but unreliable |
### Recommendations
- **Daily use:** `opencode-deepseek` or `opencode-pickle` (free)
- **Quality work:** `claude-haiku` or `claude-opus`
- **Complex reasoning:** `opencode-reasoner`
- **Large documents:** `gemini` (1M token context window)
- **Budget:** `opencode-pickle` (free) or `opencode-deepseek` (cheap)
## Provider Setup
### OpenCode (Recommended)
OpenCode provides access to multiple models including free options.
**Install:**
```bash
curl -fsSL https://opencode.ai/install | bash
```
**Authenticate:**
```bash
~/.opencode/bin/opencode auth
```
**Available Models:**
| Provider Name | Model | Cost |
|---------------|-------|------|
| `opencode-deepseek` | deepseek-chat | Cheap |
| `opencode-pickle` | big-pickle | FREE |
| `opencode-grok` | grok-code | FREE |
| `opencode-nano` | gpt-5-nano | Paid |
| `opencode-reasoner` | deepseek-reasoner | Cheap |
**Test:**
```bash
echo "Hello" | ~/.opencode/bin/opencode run --model opencode/big-pickle
```
### Claude CLI
Anthropic's official CLI for Claude models.
**Install:**
```bash
npm install -g @anthropic-ai/claude-cli
# or
brew install claude
```
**Authenticate:**
```bash
claude auth
```
**Available Models:**
| Provider Name | Model | Cost |
|---------------|-------|------|
| `claude` | Auto-routes | Varies |
| `claude-haiku` | Haiku 4.5 | Cheap |
| `claude-sonnet` | Sonnet 4.5 | Medium |
| `claude-opus` | Opus 4.5 | Expensive |
**Test:**
```bash
echo "Hello" | claude -p
```
### Codex (OpenAI)
OpenAI's Codex CLI with auto-routing.
**Install:**
```bash
npm install -g codex-cli
```
**Authenticate:**
```bash
codex auth # Uses ChatGPT account
```
**Test:**
```bash
echo "Hello" | codex exec -
```
### Gemini
Google's Gemini models. Best for large context (1M tokens).
**Install:**
```bash
npm install -g @anthropic-ai/gemini-cli
# or
pip install google-generativeai
```
**Authenticate:**
```bash
gemini auth # Uses Google account
```
**Available Models:**
| Provider Name | Model | Notes |
|---------------|-------|-------|
| `gemini` | gemini-2.5-pro | Quality, slow CLI |
| `gemini-flash` | gemini-2.5-flash | Faster |
**Note:** Gemini CLI has known performance issues. Use `gemini-flash` for interactive tasks, `gemini` for large documents.
**Test:**
```bash
echo "Hello" | gemini --model gemini-2.5-flash
```
## Managing Providers
### List Providers
```bash
smarttools providers
```
### Add Custom Provider
```bash
smarttools providers add
```
Or edit `~/.smarttools/providers.yaml`:
```yaml
providers:
- name: my-custom
command: my-ai-tool --prompt
description: My custom AI tool
```
### Provider Command Format
The command should:
1. Accept input via stdin
2. Output response to stdout
3. Exit 0 on success
Example commands:
```bash
# Claude
claude -p
# OpenCode
$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat
# Gemini
gemini --model gemini-2.5-flash
# Codex
codex exec -
# Custom (any tool that reads stdin)
my-tool --input -
```
### Environment Variables
Provider commands can use environment variables:
```yaml
providers:
- name: opencode
command: $HOME/.opencode/bin/opencode run
```
`$HOME` and `~` are expanded automatically.
## Using Providers in Tools
### In Tool Config
```yaml
steps:
- type: prompt
prompt: "Summarize: {input}"
provider: opencode-pickle # Use this provider
output_var: response
```
### Override at Runtime
```bash
# Use a different provider for this run
cat file.txt | summarize --provider claude-opus
```
### Provider Selection Strategy
1. **Tool default** - Set in tool's config.yaml
2. **Runtime override** - `--provider` flag
3. **Cost optimization** - Use cheap providers for simple tasks
4. **Quality needs** - Use opus/sonnet for important work
## Troubleshooting
### "Provider 'X' not found"
1. Check it's in your providers list: `smarttools providers`
2. Verify the command works: `echo "test" | <command>`
3. Add it: `smarttools providers add`
### "Command 'X' not found"
The AI CLI tool isn't installed or not in PATH:
```bash
which claude # Should show path
which opencode # Might need full path
```
For OpenCode, use full path in provider:
```yaml
command: $HOME/.opencode/bin/opencode run
```
### Slow Provider
- Use `gemini-flash` instead of `gemini`
- Use `claude-haiku` instead of `claude-opus`
- Use `opencode-deepseek` for best speed/quality ratio
### Provider Errors
Check the provider works directly:
```bash
echo "Say hello" | claude -p
echo "Say hello" | ~/.opencode/bin/opencode run
```
If it works directly but not in SmartTools, check:
1. Provider command in `~/.smarttools/providers.yaml`
2. Environment variables are expanded correctly
## Cost Optimization
### Free Providers
- `opencode-pickle` - Big Pickle model (FREE, accurate)
- `opencode-grok` - Grok Code (FREE, fast but less reliable)
### Cheap Providers
- `opencode-deepseek` - ~$0.28/M tokens
- `opencode-reasoner` - ~$0.28/M tokens
- `claude-haiku` - ~$0.25/M tokens
### Tips
1. Use `opencode-pickle` for simple tasks (free + accurate)
2. Use `claude-haiku` when you need reliability
3. Reserve `claude-opus` for important work
4. Use `gemini` only for large document analysis
## Adding New Providers
Any CLI tool that:
- Reads from stdin
- Writes to stdout
- Exits 0 on success
Can be a provider. Examples:
**Local LLM (Ollama):**
```yaml
- name: ollama-llama
command: ollama run llama3
description: Local Llama 3
```
**Custom API wrapper:**
```yaml
- name: my-api
command: curl -s -X POST https://my-api.com/chat -d @-
description: My custom API
```
**Python script:**
```yaml
- name: my-python
command: python3 ~/scripts/my_ai.py
description: Custom Python AI
```

342
examples/install.py Normal file
View File

@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Install all SmartTools example tools.
Usage:
python3 install.py # Install all tools
python3 install.py --list # List available tools
python3 install.py summarize # Install specific tool
python3 install.py --category dev # Install category
Run from anywhere:
curl -sSL https://raw.githubusercontent.com/yourusername/smarttools/main/examples/install.py | python3
"""
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("PyYAML required. Install with: pip install pyyaml")
sys.exit(1)
TOOLS_DIR = Path.home() / ".smarttools"
# All example tool configurations
TOOLS = {
# TEXT PROCESSING
"summarize": {
"category": "text",
"description": "Condense long documents to key points",
"arguments": [{"flag": "--length", "variable": "length", "default": "3-5 bullet points"}],
"steps": [{"type": "prompt", "prompt": "Summarize the following text into {length}. Be concise and capture the key points:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"translate": {
"category": "text",
"description": "Translate text to any language",
"arguments": [{"flag": "--lang", "variable": "lang", "default": "Spanish"}],
"steps": [{"type": "prompt", "prompt": "Translate the following text to {lang}. Only output the translation, nothing else:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"fix-grammar": {
"category": "text",
"description": "Fix grammar and spelling errors",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Fix all grammar, spelling, and punctuation errors in the following text. Only output the corrected text, no explanations:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"simplify": {
"category": "text",
"description": "Rewrite text for easier understanding",
"arguments": [{"flag": "--level", "variable": "level", "default": "5th grade reading level"}],
"steps": [{"type": "prompt", "prompt": "Rewrite the following text for a {level}. Keep the meaning but use simpler words and shorter sentences:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"tone-shift": {
"category": "text",
"description": "Change the tone of text",
"arguments": [{"flag": "--tone", "variable": "tone", "default": "professional"}],
"steps": [{"type": "prompt", "prompt": "Rewrite the following text in a {tone} tone. Keep the core message but adjust the style:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"eli5": {
"category": "text",
"description": "Explain like I'm 5",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Explain this like I'm 5 years old. Use simple words and fun analogies:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"tldr": {
"category": "text",
"description": "One-line summary",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Give a one-line TL;DR summary of this text:\n\n{input}", "provider": "opencode-grok", "output_var": "response"}],
"output": "{response}"
},
"expand": {
"category": "text",
"description": "Expand bullet points to paragraphs",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Expand these bullet points into well-written paragraphs:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
# DEVELOPER TOOLS
"explain-error": {
"category": "dev",
"description": "Explain error messages and stack traces",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Explain this error/stack trace in plain English. What went wrong and how to fix it:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"explain-code": {
"category": "dev",
"description": "Explain what code does",
"arguments": [{"flag": "--detail", "variable": "detail", "default": "moderate"}],
"steps": [{"type": "prompt", "prompt": "Explain what this code does at a {detail} level of detail:\n\n```\n{input}\n```", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"review-code": {
"category": "dev",
"description": "Quick code review with suggestions",
"arguments": [{"flag": "--focus", "variable": "focus", "default": "bugs, security, and improvements"}],
"steps": [{"type": "prompt", "prompt": "Review this code focusing on {focus}. Be concise and actionable:\n\n```\n{input}\n```", "provider": "claude-sonnet", "output_var": "response"}],
"output": "{response}"
},
"gen-tests": {
"category": "dev",
"description": "Generate unit tests for code",
"arguments": [{"flag": "--framework", "variable": "framework", "default": "pytest"}],
"steps": [{"type": "prompt", "prompt": "Generate comprehensive unit tests for this code using {framework}. Include edge cases:\n\n```\n{input}\n```", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"docstring": {
"category": "dev",
"description": "Add docstrings to functions/classes",
"arguments": [{"flag": "--style", "variable": "style", "default": "Google style"}],
"steps": [{"type": "prompt", "prompt": "Add {style} docstrings to all functions and classes in this code. Output the complete code with docstrings:\n\n```\n{input}\n```", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"commit-msg": {
"category": "dev",
"description": "Generate commit message from diff",
"arguments": [{"flag": "--style", "variable": "style", "default": "conventional commits"}],
"steps": [{"type": "prompt", "prompt": "Generate a concise {style} commit message for this diff. Just the message, no explanation:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
# DATA TOOLS
"json-extract": {
"category": "data",
"description": "Extract structured data as validated JSON",
"arguments": [{"flag": "--fields", "variable": "fields", "default": "any relevant fields"}],
"steps": [
{"type": "prompt", "prompt": "Extract {fields} from this text as a JSON object. Output ONLY valid JSON, no markdown, no explanation:\n\n{input}", "provider": "opencode-deepseek", "output_var": "raw_json"},
{"type": "code", "code": "import json\nimport re\ntext = raw_json.strip()\ntext = re.sub(r'^```json?\\s*', '', text)\ntext = re.sub(r'\\s*```$', '', text)\ntry:\n parsed = json.loads(text)\n validated = json.dumps(parsed, indent=2)\nexcept json.JSONDecodeError as e:\n validated = f\"ERROR: Invalid JSON - {e}\\nRaw output: {text[:500]}\"", "output_var": "validated"}
],
"output": "{validated}"
},
"json2csv": {
"category": "data",
"description": "Convert JSON to CSV format",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Convert this JSON to CSV format. Output only the CSV, no explanation:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"extract-emails": {
"category": "data",
"description": "Extract email addresses from text",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Extract all email addresses from this text. Output one email per line, nothing else:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"extract-contacts": {
"category": "data",
"description": "Extract contact info as structured CSV",
"arguments": [],
"steps": [
{"type": "prompt", "prompt": "Extract all contact information (name, email, phone, company) from this text. Output as JSON array with objects having keys: name, email, phone, company. Use null for missing fields. Output ONLY the JSON array:\n\n{input}", "provider": "opencode-pickle", "output_var": "contacts_json"},
{"type": "code", "code": "import json\nimport re\ntext = contacts_json.strip()\ntext = re.sub(r'^```json?\\s*', '', text)\ntext = re.sub(r'\\s*```$', '', text)\ntry:\n contacts = json.loads(text)\n lines = ['name,email,phone,company']\n for c in contacts:\n row = [str(c.get('name') or ''), str(c.get('email') or ''), str(c.get('phone') or ''), str(c.get('company') or '')]\n lines.append(','.join(f'\"{f}\"' for f in row))\n csv_output = '\\n'.join(lines)\nexcept:\n csv_output = f\"Error parsing contacts: {text[:200]}\"", "output_var": "csv_output"}
],
"output": "{csv_output}"
},
"sql-from-text": {
"category": "data",
"description": "Generate SQL from natural language",
"arguments": [{"flag": "--dialect", "variable": "dialect", "default": "PostgreSQL"}],
"steps": [{"type": "prompt", "prompt": "Generate a {dialect} SQL query for this request. Output only the SQL, no explanation:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"safe-sql": {
"category": "data",
"description": "Generate SQL with safety checks",
"arguments": [{"flag": "--dialect", "variable": "dialect", "default": "PostgreSQL"}],
"steps": [
{"type": "prompt", "prompt": "Generate a {dialect} SELECT query for: {input}\n\nOutput ONLY the SQL query, no explanation.", "provider": "claude-haiku", "output_var": "raw_sql"},
{"type": "code", "code": "import re\nsql = raw_sql.strip()\nsql = re.sub(r'^```sql?\\s*', '', sql)\nsql = re.sub(r'\\s*```$', '', sql)\ndangerous = ['DROP', 'DELETE', 'TRUNCATE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT']\nwarnings = []\nfor keyword in dangerous:\n if re.search(r'\\b' + keyword + r'\\b', sql, re.I):\n warnings.append(f\"WARNING: Contains {keyword}\")\nif warnings:\n validated = '\\n'.join(warnings) + '\\n\\n' + sql\nelse:\n validated = sql", "output_var": "validated"}
],
"output": "{validated}"
},
"parse-log": {
"category": "data",
"description": "Summarize and analyze log files",
"arguments": [{"flag": "--focus", "variable": "focus", "default": "errors and warnings"}],
"steps": [{"type": "prompt", "prompt": "Analyze these logs focusing on {focus}. Summarize key issues and patterns:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"csv-insights": {
"category": "data",
"description": "Analyze CSV with sampled data for large files",
"arguments": [{"flag": "--question", "variable": "question", "default": "What patterns and insights can you find?"}],
"steps": [
{"type": "code", "code": "import random\nlines = input.strip().split('\\n')\nheader = lines[0] if lines else ''\ndata_lines = lines[1:] if len(lines) > 1 else []\nif len(data_lines) > 50:\n sampled = random.sample(data_lines, 50)\n sample_note = f\"(Sampled 50 of {len(data_lines)} rows)\"\nelse:\n sampled = data_lines\n sample_note = f\"({len(data_lines)} rows)\"\nsampled_csv = header + '\\n' + '\\n'.join(sampled)\nstats = f\"Columns: {len(header.split(','))}, Rows: {len(data_lines)} {sample_note}\"", "output_var": "sampled_csv,stats"},
{"type": "prompt", "prompt": "Analyze this CSV data. {stats}\n\nQuestion: {question}\n\nData:\n{sampled_csv}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
# ADVANCED TOOLS
"log-errors": {
"category": "advanced",
"description": "Extract and explain errors from large log files",
"arguments": [],
"steps": [
{"type": "code", "code": "import re\nlines = input.split('\\n')\nerrors = [l for l in lines if re.search(r'\\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\\b', l, re.I)]\nresult = []\nfor i, line in enumerate(lines):\n if re.search(r'\\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\\b', line, re.I):\n result.extend(lines[i:i+5])\nextracted = '\\n'.join(result[:200])", "output_var": "extracted"},
{"type": "prompt", "prompt": "Analyze these error log entries. Group by error type, explain likely causes, and suggest fixes:\n\n{extracted}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
"diff-focus": {
"category": "advanced",
"description": "Review only the added/changed code in a diff",
"arguments": [],
"steps": [
{"type": "code", "code": "lines = input.split('\\n')\nresult = []\nfor i, line in enumerate(lines):\n if line.startswith('@@') or line.startswith('+++') or line.startswith('---'):\n result.append(line)\n elif line.startswith('+') and not line.startswith('+++'):\n result.append(line)\nextracted = '\\n'.join(result)", "output_var": "extracted"},
{"type": "prompt", "prompt": "Review these added lines of code. Focus on bugs, security issues, and improvements:\n\n{extracted}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
"changelog": {
"category": "advanced",
"description": "Generate changelog from git commits",
"arguments": [{"flag": "--since", "variable": "since", "default": "last week"}],
"steps": [
{"type": "code", "code": "lines = input.strip().split('\\n')\ncommits = []\nfor line in lines:\n if line.strip() and not line.startswith('commit '):\n commits.append(line.strip())\ngit_log = '\\n'.join(commits[:100])", "output_var": "git_log"},
{"type": "prompt", "prompt": "Generate a user-friendly changelog from these git commits. Group by category (Features, Fixes, Improvements, etc). Use markdown with bullet points:\n\n{git_log}", "provider": "opencode-pickle", "output_var": "changelog_raw"},
{"type": "code", "code": "from datetime import datetime\nheader = f\"\"\"# Changelog\\n\\nGenerated: {datetime.now().strftime('%Y-%m-%d')}\\n\\n\"\"\"\nformatted = header + changelog_raw", "output_var": "formatted"}
],
"output": "{formatted}"
},
"code-validate": {
"category": "advanced",
"description": "Generate and validate Python code",
"arguments": [{"flag": "--task", "variable": "task", "default": ""}],
"steps": [
{"type": "prompt", "prompt": "Write Python code to: {task}\n\nContext/input data:\n{input}\n\nOutput ONLY the Python code, no markdown, no explanation.", "provider": "claude-haiku", "output_var": "raw_code"},
{"type": "code", "code": "import ast\nimport re\ncode = raw_code.strip()\ncode = re.sub(r'^```python?\\s*', '', code)\ncode = re.sub(r'\\s*```$', '', code)\ntry:\n ast.parse(code)\n validated = code\nexcept SyntaxError as e:\n validated = f\"# SYNTAX ERROR at line {e.lineno}: {e.msg}\\n# Fix needed:\\n\\n{code}\"", "output_var": "validated"}
],
"output": "{validated}"
},
"reply-email": {
"category": "text",
"description": "Draft email replies",
"arguments": [{"flag": "--tone", "variable": "tone", "default": "professional and friendly"}],
"steps": [{"type": "prompt", "prompt": "Draft a {tone} reply to this email:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
}
CATEGORIES = {
"text": "Text Processing",
"dev": "Developer Tools",
"data": "Data Tools",
"advanced": "Advanced Multi-Step Tools",
}
def install_tool(name: str, config: dict, force: bool = False) -> bool:
"""Install a single tool."""
tool_dir = TOOLS_DIR / name
config_file = tool_dir / "config.yaml"
if config_file.exists() and not force:
print(f" {name}: already exists (use --force to overwrite)")
return False
tool_dir.mkdir(parents=True, exist_ok=True)
# Build config without category
tool_config = {
"name": name,
"description": config["description"],
"arguments": config.get("arguments", []),
"steps": config["steps"],
"output": config["output"]
}
with open(config_file, 'w') as f:
yaml.dump(tool_config, f, default_flow_style=False, sort_keys=False)
print(f" {name}: installed")
return True
def list_tools():
"""List all available tools."""
print("Available example tools:\n")
for cat_id, cat_name in CATEGORIES.items():
tools_in_cat = [(n, c) for n, c in TOOLS.items() if c.get("category") == cat_id]
if tools_in_cat:
print(f"{cat_name}:")
for name, config in tools_in_cat:
print(f" {name:<20} {config['description']}")
print()
def main():
parser = argparse.ArgumentParser(description="Install SmartTools example tools")
parser.add_argument("tools", nargs="*", help="Specific tools to install (default: all)")
parser.add_argument("--list", action="store_true", help="List available tools")
parser.add_argument("--category", "-c", choices=list(CATEGORIES.keys()), help="Install tools from category")
parser.add_argument("--force", "-f", action="store_true", help="Overwrite existing tools")
args = parser.parse_args()
if args.list:
list_tools()
return
# Determine which tools to install
if args.tools:
to_install = {n: c for n, c in TOOLS.items() if n in args.tools}
unknown = set(args.tools) - set(TOOLS.keys())
if unknown:
print(f"Unknown tools: {', '.join(unknown)}")
print("Use --list to see available tools")
return
elif args.category:
to_install = {n: c for n, c in TOOLS.items() if c.get("category") == args.category}
else:
to_install = TOOLS
if not to_install:
print("No tools to install")
return
print(f"Installing {len(to_install)} tools to {TOOLS_DIR}...\n")
installed = 0
for name, config in to_install.items():
if install_tool(name, config, args.force):
installed += 1
print(f"\nInstalled {installed} tools.")
print("\nRun 'smarttools refresh' to create executable wrappers.")
if __name__ == "__main__":
main()

View File

@ -12,27 +12,38 @@ requires-python = ">=3.10"
authors = [
{name = "Rob"}
]
keywords = ["ai", "cli", "tools", "automation"]
keywords = ["ai", "cli", "tools", "automation", "llm", "terminal", "unix", "pipe"]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Text Processing",
]
dependencies = [
"PyYAML>=6.0",
]
[project.optional-dependencies]
tui = [
"urwid>=2.1.0",
]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"urwid>=2.1.0",
]
all = [
"urwid>=2.1.0",
]
[project.scripts]
@ -40,6 +51,10 @@ smarttools = "smarttools.cli:main"
[project.urls]
Homepage = "https://github.com/rob/smarttools"
Documentation = "https://github.com/rob/smarttools#readme"
Repository = "https://github.com/rob/smarttools.git"
Issues = "https://github.com/rob/smarttools/issues"
Changelog = "https://github.com/rob/smarttools/releases"
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -4,7 +4,7 @@ import argparse
import sys
from . import __version__
from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolInput
from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolArgument, PromptStep, CodeStep
from .ui import run_ui
@ -14,7 +14,7 @@ def cmd_list(args):
if not tools:
print("No tools found.")
print("Create your first tool with: smarttools create <name>")
print("Create your first tool with: smarttools ui")
return 0
print(f"Available tools ({len(tools)}):\n")
@ -23,14 +23,29 @@ def cmd_list(args):
if tool:
print(f" {name}")
print(f" {tool.description or 'No description'}")
print(f" Provider: {tool.provider}")
# Show arguments
if tool.arguments:
args_str = ", ".join(arg.flag for arg in tool.arguments)
print(f" Arguments: {args_str}")
# Show steps
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
elif isinstance(step, CodeStep):
step_info.append("CODE")
print(f" Steps: {' -> '.join(step_info)}")
print()
return 0
def cmd_create(args):
"""Create a new tool."""
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
name = args.name
# Check if already exists
@ -39,21 +54,28 @@ def cmd_create(args):
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
return 1
# Create minimal tool
# Create a tool with a single prompt step
steps = []
if args.prompt:
steps.append(PromptStep(
prompt=args.prompt,
provider=args.provider or "mock",
output_var="response"
))
tool = Tool(
name=name,
description=args.description or "",
prompt=args.prompt or "Process the following:\n\n{input}",
provider=args.provider or "codex",
provider_args=args.provider_args or "-p",
inputs=[],
post_process=None
arguments=[],
steps=steps,
output="{response}" if steps else "{input}"
)
path = save_tool(tool)
print(f"Created tool '{name}'")
print(f"Config: {path}")
print(f"\nEdit the config to customize, then run: {name} -i <file>")
print(f"\nUse 'smarttools ui' to add arguments, steps, and customize.")
print(f"Or run: {name} < input.txt")
return 0
@ -141,12 +163,106 @@ def cmd_test(args):
return code
def cmd_run(args):
"""Run a tool."""
from pathlib import Path
from .runner import run_tool
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
return 1
# Read input
if args.input:
# Read from file
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
return 1
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args from remaining arguments
custom_args = {}
if args.tool_args:
# Parse tool-specific arguments
i = 0
while i < len(args.tool_args):
arg = args.tool_args[i]
if arg.startswith('--'):
key = arg[2:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
elif arg.startswith('-'):
key = arg[1:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
else:
i += 1
# Run tool
output, code = run_tool(
tool=tool,
input_text=input_text,
custom_args=custom_args,
provider_override=args.provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose
)
# Write output
if code == 0 and output:
if args.output:
Path(args.output).write_text(output)
else:
print(output)
return code
def cmd_ui(args):
"""Launch the interactive UI."""
run_ui()
return 0
def cmd_refresh(args):
"""Refresh all wrapper scripts with the current Python path."""
from .tool import list_tools, create_wrapper_script
tools = list_tools()
if not tools:
print("No tools found.")
return 0
print(f"Refreshing wrapper scripts for {len(tools)} tools...")
for name in tools:
path = create_wrapper_script(name)
print(f" {name} -> {path}")
print("\nDone. Wrapper scripts updated to use current Python interpreter.")
return 0
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
@ -167,8 +283,7 @@ def main():
p_create.add_argument("name", help="Tool name")
p_create.add_argument("-d", "--description", help="Tool description")
p_create.add_argument("-p", "--prompt", help="Prompt template")
p_create.add_argument("--provider", help="AI provider (default: codex)")
p_create.add_argument("--provider-args", help="Provider arguments (default: -p)")
p_create.add_argument("--provider", help="AI provider (default: mock)")
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
p_create.set_defaults(func=cmd_create)
@ -190,10 +305,27 @@ def main():
p_test.add_argument("--dry-run", action="store_true", help="Show prompt only")
p_test.set_defaults(func=cmd_test)
# 'run' command
p_run = subparsers.add_parser("run", help="Run a tool")
p_run.add_argument("name", help="Tool name")
p_run.add_argument("-i", "--input", help="Input file (reads from stdin if piped)")
p_run.add_argument("-o", "--output", help="Output file (writes to stdout if omitted)")
p_run.add_argument("--stdin", action="store_true", help="Read input interactively (type then Ctrl+D)")
p_run.add_argument("-p", "--provider", help="Override provider")
p_run.add_argument("--dry-run", action="store_true", help="Show what would happen without executing")
p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output")
p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information")
p_run.add_argument("tool_args", nargs="*", help="Additional tool-specific arguments")
p_run.set_defaults(func=cmd_run)
# 'ui' command (explicit)
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
p_ui.set_defaults(func=cmd_ui)
# 'refresh' command
p_refresh = subparsers.add_parser("refresh", help="Refresh all wrapper scripts")
p_refresh.set_defaults(func=cmd_refresh)
args = parser.parse_args()
# If no command, launch UI

View File

@ -1,9 +1,40 @@
"""Provider abstraction for AI CLI tools."""
import os
import subprocess
import shutil
from dataclasses import dataclass
from typing import Optional
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List
import yaml
# Default providers config location
PROVIDERS_FILE = Path.home() / ".smarttools" / "providers.yaml"
@dataclass
class Provider:
"""Definition of an AI provider."""
name: str
command: str
description: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"command": self.command,
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> "Provider":
return cls(
name=data["name"],
command=data["command"],
description=data.get("description", ""),
)
@dataclass
@ -14,29 +45,137 @@ class ProviderResult:
error: Optional[str] = None
def call_provider(provider: str, args: str, prompt: str, timeout: int = 300) -> ProviderResult:
# Default providers that come pre-configured
# Profiled with 4-task test: Math, Code, Reasoning, Extract (Dec 2025)
DEFAULT_PROVIDERS = [
# TOP PICKS - best value/performance ratio
Provider("opencode-deepseek", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat", "13s 4/4 | BEST VALUE, cheap, fast, accurate"),
Provider("opencode-pickle", "$HOME/.opencode/bin/opencode run --model opencode/big-pickle", "13s 4/4 | BEST FREE, accurate"),
Provider("claude-haiku", "claude -p --model haiku", "14s 4/4 | fast, accurate, best paid option"),
Provider("codex", "codex exec -", "14s 4/4 | reliable, auto-routes"),
# CLAUDE - all accurate, paid, good for code
Provider("claude", "claude -p", "18s 4/4 | auto-routes to best model"),
Provider("claude-opus", "claude -p --model opus", "18s 4/4 | highest quality, expensive"),
Provider("claude-sonnet", "claude -p --model sonnet", "21s 4/4 | balanced quality/speed"),
# OPENCODE - additional models
Provider("opencode-nano", "$HOME/.opencode/bin/opencode run --model opencode/gpt-5-nano", "24s 4/4 | GPT-5 Nano, reliable"),
Provider("opencode-reasoner", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-reasoner", "33s 4/4 | complex reasoning, cheap"),
Provider("opencode-grok", "$HOME/.opencode/bin/opencode run --model opencode/grok-code", "11s 2/4 | fastest but unreliable, FREE"),
# GEMINI - slow CLI but good for large docs (1M token context)
Provider("gemini-flash", "gemini --model gemini-2.5-flash", "28s 4/4 | use this for quick tasks"),
Provider("gemini", "gemini --model gemini-2.5-pro", "91s 3/4 | slow CLI, best for large docs/PDFs"),
# Mock for testing
Provider("mock", "mock", "Mock provider for testing"),
]
def get_providers_file() -> Path:
"""Get the providers config file, creating default if needed."""
if not PROVIDERS_FILE.exists():
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
save_providers(DEFAULT_PROVIDERS)
return PROVIDERS_FILE
def load_providers() -> List[Provider]:
"""Load all defined providers."""
providers_file = get_providers_file()
try:
data = yaml.safe_load(providers_file.read_text())
if not data or "providers" not in data:
return DEFAULT_PROVIDERS.copy()
return [Provider.from_dict(p) for p in data["providers"]]
except Exception:
return DEFAULT_PROVIDERS.copy()
def save_providers(providers: List[Provider]):
"""Save providers to config file."""
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
data = {"providers": [p.to_dict() for p in providers]}
PROVIDERS_FILE.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
def get_provider(name: str) -> Optional[Provider]:
"""Get a provider by name."""
providers = load_providers()
for p in providers:
if p.name == name:
return p
return None
def add_provider(provider: Provider) -> bool:
"""Add or update a provider."""
providers = load_providers()
# Update if exists, otherwise add
for i, p in enumerate(providers):
if p.name == provider.name:
providers[i] = provider
save_providers(providers)
return True
providers.append(provider)
save_providers(providers)
return True
def delete_provider(name: str) -> bool:
"""Delete a provider by name."""
providers = load_providers()
original_len = len(providers)
providers = [p for p in providers if p.name != name]
if len(providers) < original_len:
save_providers(providers)
return True
return False
def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> ProviderResult:
"""
Call an AI CLI tool with the given prompt.
Call an AI provider with the given prompt.
Args:
provider: The CLI command (e.g., 'codex', 'claude', 'gemini')
args: Additional arguments for the provider (e.g., '-p')
provider_name: Name of the provider to use
prompt: The prompt to send
timeout: Maximum execution time in seconds
Returns:
ProviderResult with the response text or error
"""
# Check if provider exists
if not shutil.which(provider):
# Handle mock provider specially
if provider_name.lower() == "mock":
return mock_provider(prompt)
# Look up provider
provider = get_provider(provider_name)
if not provider:
return ProviderResult(
text="",
success=False,
error=f"Provider '{provider}' not found. Is it installed and in PATH?"
error=f"Provider '{provider_name}' not found. Use 'smarttools providers' to manage providers."
)
# Build command
cmd = f"{provider} {args}".strip()
# Parse command (expand environment variables)
cmd = os.path.expandvars(provider.command)
# Check if base command exists
base_cmd = cmd.split()[0]
# Expand ~ for the which check
base_cmd_expanded = os.path.expanduser(base_cmd)
if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded):
return ProviderResult(
text="",
success=False,
error=f"Command '{base_cmd}' not found. Is it installed and in PATH?"
)
try:
result = subprocess.run(
@ -92,19 +231,3 @@ def mock_provider(prompt: str) -> ProviderResult:
f"This is a mock response. Use a real provider for actual output.",
success=True
)
def get_provider_func(provider: str):
"""
Get the appropriate provider function.
Args:
provider: Provider name ('mock' for mock, anything else for real)
Returns:
Callable that takes (prompt) or (provider, args, prompt)
"""
if provider.lower() == "mock":
return lambda prompt, **kwargs: mock_provider(prompt)
else:
return lambda prompt, args="", **kwargs: call_provider(provider, args, prompt, **kwargs)

View File

@ -5,75 +5,86 @@ import sys
from pathlib import Path
from typing import Optional
from .tool import load_tool, Tool
from .providers import call_provider, mock_provider, ProviderResult
from .tool import load_tool, Tool, PromptStep, CodeStep
from .providers import call_provider, mock_provider
def build_prompt(tool: Tool, input_text: str, custom_args: dict) -> str:
def substitute_variables(template: str, variables: dict) -> str:
"""
Build the final prompt by substituting placeholders.
Substitute {variable} placeholders in a template.
Args:
tool: Tool definition
input_text: Content from input file or stdin
custom_args: Custom argument values
template: String with {var} placeholders
variables: Dict of variable name -> value
Returns:
Final prompt string
String with placeholders replaced
"""
prompt = tool.prompt
# Replace {input} placeholder
prompt = prompt.replace("{input}", input_text)
# Replace custom argument placeholders
for inp in tool.inputs:
value = custom_args.get(inp.name, inp.default)
if value is not None:
prompt = prompt.replace(f"{{{inp.name}}}", str(value))
return prompt
result = template
for name, value in variables.items():
result = result.replace(f"{{{name}}}", str(value) if value else "")
return result
def run_post_process(tool: Tool, output: str) -> str:
def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]:
"""
Run post-processing script if defined.
Execute a prompt step.
Args:
tool: Tool definition
output: Raw output from provider
step: The prompt step to execute
variables: Current variable values
provider_override: Override the step's provider
Returns:
Processed output
Tuple of (output_value, success)
"""
if not tool.post_process:
return output
# Build prompt with variable substitution
prompt = substitute_variables(step.prompt, variables)
from .tool import get_tools_dir
script_path = get_tools_dir() / tool.name / tool.post_process
# Call provider
provider = provider_override or step.provider
if not script_path.exists():
print(f"Warning: Post-process script not found: {script_path}", file=sys.stderr)
return output
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, prompt)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
return "", False
return result.text, True
def execute_code_step(step: CodeStep, variables: dict) -> tuple[dict, bool]:
"""
Execute a code step.
Args:
step: The code step to execute
variables: Current variable values (available in code)
Returns:
Tuple of (output_vars_dict, success)
"""
# Create execution environment with variables
local_vars = dict(variables)
# Execute post-process script with output as input
import subprocess
try:
result = subprocess.run(
["python3", str(script_path)],
input=output,
capture_output=True,
text=True,
timeout=60
)
if result.returncode == 0:
return result.stdout
else:
print(f"Warning: Post-process failed: {result.stderr}", file=sys.stderr)
return output
# Execute the code
exec(step.code, {"__builtins__": __builtins__}, local_vars)
# Support comma-separated output vars (e.g., "a, b, c")
output_vars = [v.strip() for v in step.output_var.split(',')]
outputs = {}
for var in output_vars:
outputs[var] = str(local_vars.get(var, ""))
return outputs, True
except Exception as e:
print(f"Warning: Post-process error: {e}", file=sys.stderr)
return output
print(f"Error in code step: {e}", file=sys.stderr)
return {}, False
def run_tool(
@ -92,48 +103,73 @@ def run_tool(
tool: Tool definition
input_text: Input content
custom_args: Custom argument values
provider_override: Override the tool's provider
dry_run: Just show prompt, don't call provider
show_prompt: Show prompt in addition to output
provider_override: Override all providers
dry_run: Just show what would happen
show_prompt: Show prompts in addition to output
verbose: Show debug info
Returns:
Tuple of (output_text, exit_code)
"""
# Build prompt
prompt = build_prompt(tool, input_text, custom_args)
# Initialize variables with input and arguments
variables = {"input": input_text}
# Add argument values (with defaults)
for arg in tool.arguments:
value = custom_args.get(arg.variable, arg.default)
variables[arg.variable] = value
if verbose:
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
print(f"[verbose] Provider: {provider_override or tool.provider}", file=sys.stderr)
print(f"[verbose] Prompt length: {len(prompt)} chars", file=sys.stderr)
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
# Dry run - just show prompt
if dry_run:
return prompt, 0
# If no steps, just substitute output template
if not tool.steps:
output = substitute_variables(tool.output, variables)
return output, 0
# Show prompt if requested
if show_prompt:
print("=== PROMPT ===", file=sys.stderr)
print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr)
# Execute each step
for i, step in enumerate(tool.steps):
if verbose:
step_type = "PROMPT" if isinstance(step, PromptStep) else "CODE"
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
# Call provider
provider = provider_override or tool.provider
if isinstance(step, PromptStep):
# Show prompt if requested
if show_prompt or dry_run:
prompt = substitute_variables(step.prompt, variables)
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr)
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, tool.provider_args, prompt)
if dry_run:
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
else:
output, success = execute_prompt_step(step, variables, provider_override)
if not success:
return "", 2
variables[step.output_var] = output
if not result.success:
print(f"Error: {result.error}", file=sys.stderr)
return "", 2 # Exit code 2 for provider errors
elif isinstance(step, CodeStep):
if verbose or dry_run:
print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(step.code, file=sys.stderr)
print("=== END CODE ===", file=sys.stderr)
output = result.text
if dry_run:
# Handle comma-separated output vars for dry run
for var in [v.strip() for v in step.output_var.split(',')]:
variables[var] = "[DRY RUN - would execute code]"
else:
outputs, success = execute_code_step(step, variables)
if not success:
return "", 1
# Merge all output vars into variables
variables.update(outputs)
# Post-process if defined
output = run_post_process(tool, output)
# Generate final output
output = substitute_variables(tool.output, variables)
return output, 0
@ -155,25 +191,27 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
# Universal flags
parser.add_argument("-i", "--input", dest="input_file",
help="Input file (reads from stdin if omitted)")
help="Input file (reads from stdin if piped)")
parser.add_argument("--stdin", action="store_true",
help="Read input interactively from stdin (type then Ctrl+D)")
parser.add_argument("-o", "--output", dest="output_file",
help="Output file (writes to stdout if omitted)")
parser.add_argument("--dry-run", action="store_true",
help="Show prompt without calling AI provider")
help="Show what would happen without executing")
parser.add_argument("--show-prompt", action="store_true",
help="Show prompt in addition to output")
help="Show prompts in addition to output")
parser.add_argument("-p", "--provider",
help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information")
# Tool-specific flags
for inp in tool.inputs:
# Tool-specific flags from arguments
for arg in tool.arguments:
parser.add_argument(
inp.flag,
dest=inp.name,
default=inp.default,
help=inp.description or f"{inp.name} (default: {inp.default})"
arg.flag,
dest=arg.variable,
default=arg.default,
help=arg.description or f"{arg.variable} (default: {arg.default})"
)
return parser
@ -198,23 +236,29 @@ def main():
# Read input
if args.input_file:
# Read from file
input_path = Path(args.input_file)
if not input_path.exists():
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
input_text = input_path.read_text()
else:
# Read from stdin
if sys.stdin.isatty():
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args
custom_args = {}
for inp in tool.inputs:
value = getattr(args, inp.name, None)
for arg in tool.arguments:
value = getattr(args, arg.variable, None)
if value is not None:
custom_args[inp.name] = value
custom_args[arg.variable] = value
# Run tool
output, exit_code = run_tool(

View File

@ -4,7 +4,7 @@ import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from typing import Optional, List, Literal
import yaml
@ -17,74 +17,145 @@ BIN_DIR = Path.home() / ".local" / "bin"
@dataclass
class ToolInput:
class ToolArgument:
"""Definition of a custom input argument."""
name: str
flag: str
flag: str # e.g., "--max-size"
variable: str # e.g., "max_size"
default: Optional[str] = None
description: str = ""
def to_dict(self) -> dict:
d = {"flag": self.flag, "variable": self.variable}
if self.default:
d["default"] = self.default
if self.description:
d["description"] = self.description
return d
@classmethod
def from_dict(cls, data: dict) -> "ToolArgument":
return cls(
flag=data["flag"],
variable=data["variable"],
default=data.get("default"),
description=data.get("description", "")
)
@dataclass
class PromptStep:
"""A prompt step that calls an AI provider."""
prompt: str # The prompt template
provider: str # Provider name
output_var: str # Variable to store output
prompt_file: Optional[str] = None # Optional filename for external prompt
def to_dict(self) -> dict:
d = {
"type": "prompt",
"prompt": self.prompt,
"provider": self.provider,
"output_var": self.output_var
}
if self.prompt_file:
d["prompt_file"] = self.prompt_file
return d
@classmethod
def from_dict(cls, data: dict) -> "PromptStep":
return cls(
prompt=data["prompt"],
provider=data["provider"],
output_var=data["output_var"],
prompt_file=data.get("prompt_file")
)
@dataclass
class CodeStep:
"""A code step that runs Python code."""
code: str # Python code (inline or loaded from file)
output_var: str # Variable name(s) to capture (comma-separated for multiple)
code_file: Optional[str] = None # Optional filename for external code
def to_dict(self) -> dict:
d = {
"type": "code",
"code": self.code,
"output_var": self.output_var
}
if self.code_file:
d["code_file"] = self.code_file
return d
@classmethod
def from_dict(cls, data: dict) -> "CodeStep":
return cls(
code=data.get("code", ""),
output_var=data["output_var"],
code_file=data.get("code_file")
)
Step = PromptStep | CodeStep
@dataclass
class Tool:
"""A SmartTools tool definition."""
name: str
description: str
prompt: str
provider: str
provider_args: str = ""
inputs: list[ToolInput] = field(default_factory=list)
post_process: Optional[str] = None
description: str = ""
arguments: List[ToolArgument] = field(default_factory=list)
steps: List[Step] = field(default_factory=list)
output: str = "{input}" # Output template
@classmethod
def from_dict(cls, data: dict) -> "Tool":
"""Create a Tool from a dictionary (parsed YAML)."""
inputs = []
for inp in data.get("inputs", []):
inputs.append(ToolInput(
name=inp["name"],
flag=inp.get("flag", f"--{inp['name']}"),
default=inp.get("default"),
description=inp.get("description", "")
))
arguments = []
for arg in data.get("arguments", []):
arguments.append(ToolArgument.from_dict(arg))
steps = []
for step in data.get("steps", []):
if step.get("type") == "prompt":
steps.append(PromptStep.from_dict(step))
elif step.get("type") == "code":
steps.append(CodeStep.from_dict(step))
return cls(
name=data["name"],
description=data.get("description", ""),
prompt=data["prompt"],
provider=data["provider"],
provider_args=data.get("provider_args", ""),
inputs=inputs,
post_process=data.get("post_process")
arguments=arguments,
steps=steps,
output=data.get("output", "{input}")
)
def to_dict(self) -> dict:
"""Convert Tool to dictionary for YAML serialization."""
data = {
return {
"name": self.name,
"description": self.description,
"prompt": self.prompt,
"provider": self.provider,
"arguments": [arg.to_dict() for arg in self.arguments],
"steps": [step.to_dict() for step in self.steps],
"output": self.output
}
if self.provider_args:
data["provider_args"] = self.provider_args
def get_available_variables(self) -> List[str]:
"""Get all variables available for use in templates."""
variables = ["input"] # Always available
if self.inputs:
data["inputs"] = [
{
"name": inp.name,
"flag": inp.flag,
"default": inp.default,
"description": inp.description,
}
for inp in self.inputs
]
# Add argument variables
for arg in self.arguments:
variables.append(arg.variable)
if self.post_process:
data["post_process"] = self.post_process
# Add step output variables
for step in self.steps:
variables.append(step.output_var)
return data
return variables
# Legacy support - map old ToolInput to new ToolArgument
ToolInput = ToolArgument
def get_tools_dir() -> Path:
@ -114,15 +185,7 @@ def list_tools() -> list[str]:
def load_tool(name: str) -> Optional[Tool]:
"""
Load a tool by name.
Args:
name: Tool name
Returns:
Tool object or None if not found
"""
"""Load a tool by name."""
config_path = get_tools_dir() / name / "config.yaml"
if not config_path.exists():
@ -130,21 +193,44 @@ def load_tool(name: str) -> Optional[Tool]:
try:
data = yaml.safe_load(config_path.read_text())
# Handle legacy format (prompt/provider/provider_args/inputs)
if "prompt" in data and "steps" not in data:
# Convert to new format
steps = []
if data.get("prompt"):
steps.append({
"type": "prompt",
"prompt": data["prompt"],
"provider": data.get("provider", "mock"),
"output_var": "response"
})
arguments = []
for inp in data.get("inputs", []):
arguments.append({
"flag": inp.get("flag", f"--{inp['name']}"),
"variable": inp["name"],
"default": inp.get("default"),
"description": inp.get("description", "")
})
data = {
"name": data["name"],
"description": data.get("description", ""),
"arguments": arguments,
"steps": steps,
"output": "{response}" if steps else "{input}"
}
return Tool.from_dict(data)
except Exception:
except Exception as e:
print(f"Error loading tool {name}: {e}")
return None
def save_tool(tool: Tool) -> Path:
"""
Save a tool to disk.
Args:
tool: Tool object to save
Returns:
Path to the saved config file
"""
"""Save a tool to disk."""
tool_dir = get_tools_dir() / tool.name
tool_dir.mkdir(parents=True, exist_ok=True)
@ -158,15 +244,7 @@ def save_tool(tool: Tool) -> Path:
def delete_tool(name: str) -> bool:
"""
Delete a tool.
Args:
name: Tool name
Returns:
True if deleted, False if not found
"""
"""Delete a tool."""
tool_dir = get_tools_dir() / name
if not tool_dir.exists():
@ -185,22 +263,19 @@ def delete_tool(name: str) -> bool:
def create_wrapper_script(name: str) -> Path:
"""
Create a wrapper script for a tool in ~/.local/bin.
"""Create a wrapper script for a tool in ~/.local/bin."""
import sys
Args:
name: Tool name
Returns:
Path to the wrapper script
"""
bin_dir = get_bin_dir()
wrapper_path = bin_dir / name
# Use the current Python interpreter to ensure smarttools is available
python_path = sys.executable
script = f"""#!/bin/bash
# SmartTools wrapper for '{name}'
# Auto-generated - do not edit
exec python3 -m smarttools.runner {name} "$@"
exec {python_path} -m smarttools.runner {name} "$@"
"""
wrapper_path.write_text(script)
@ -212,3 +287,34 @@ exec python3 -m smarttools.runner {name} "$@"
def tool_exists(name: str) -> bool:
"""Check if a tool exists."""
return (get_tools_dir() / name / "config.yaml").exists()
def validate_tool_name(name: str) -> tuple[bool, str]:
"""
Validate a tool name.
Returns:
(is_valid, error_message) - error_message is empty if valid
"""
if not name:
return False, "Tool name cannot be empty"
if ' ' in name:
return False, "Tool name cannot contain spaces"
# Check for shell-problematic characters
bad_chars = set('/\\|&;$`"\'<>(){}[]!?*#~')
found = [c for c in name if c in bad_chars]
if found:
return False, f"Tool name cannot contain: {' '.join(found)}"
# Must start with letter or underscore
if not (name[0].isalpha() or name[0] == '_'):
return False, "Tool name must start with a letter or underscore"
# Check it's a valid identifier-ish (alphanumeric, underscore, dash)
for c in name:
if not (c.isalnum() or c in '_-'):
return False, f"Tool name can only contain letters, numbers, underscore, and dash"
return True, ""

View File

@ -2,9 +2,34 @@
import subprocess
import sys
from typing import Optional, Tuple
import tempfile
from typing import Optional, Tuple, List
from .tool import Tool, ToolInput, list_tools, load_tool, save_tool, delete_tool, tool_exists
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
def _check_urwid() -> bool:
"""Check if urwid is available (preferred - has mouse support)."""
try:
import urwid
return True
except ImportError:
return False
def _check_snack() -> bool:
"""Check if snack (python3-newt) is available."""
try:
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
return True
except ImportError:
return False
def check_dialog() -> str:
@ -19,35 +44,30 @@ def check_dialog() -> str:
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
"""
Run a dialog command and return (exit_code, output).
Args:
args: Dialog arguments
dialog_prog: Dialog program to use
Returns:
Tuple of (exit_code, captured_output)
"""
cmd = [dialog_prog] + args
result = subprocess.run(cmd, capture_output=True, text=True)
# dialog outputs to stderr
return result.returncode, result.stderr.strip()
"""Run a dialog command and return (exit_code, output)."""
try:
if dialog_prog == "whiptail":
result = subprocess.run(
[dialog_prog] + args,
stderr=subprocess.PIPE,
text=True
)
return result.returncode, result.stderr.strip()
else:
cmd_with_stdout = [dialog_prog, "--stdout"] + args
result = subprocess.run(
cmd_with_stdout,
stdout=subprocess.PIPE,
text=True
)
return result.returncode, result.stdout.strip()
except Exception as e:
return 1, ""
def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]:
"""
Show a menu and return the selected item.
Args:
title: Menu title
choices: List of (tag, description) tuples
dialog_prog: Dialog program to use
Returns:
Selected tag or None if cancelled
"""
args = ["--title", title, "--menu", "", "15", "60", str(len(choices))]
"""Show a menu and return the selected item."""
args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))]
for tag, desc in choices:
args.extend([tag, desc])
@ -56,61 +76,29 @@ def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> O
def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]:
"""
Show an input box and return the entered text.
Args:
title: Dialog title
prompt: Prompt text
initial: Initial value
dialog_prog: Dialog program to use
Returns:
Entered text or None if cancelled
"""
"""Show an input box and return the entered text."""
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
"""
Show a text editor for multi-line input.
Args:
title: Dialog title
text: Initial text
dialog_prog: Dialog program to use
Returns:
Edited text or None if cancelled
"""
import tempfile
"""Show a text editor for multi-line input."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(text)
temp_path = f.name
args = ["--title", title, "--editbox", temp_path, "20", "70"]
code, output = run_dialog(args, dialog_prog)
import os
os.unlink(temp_path)
return output if code == 0 else None
try:
args = ["--title", title, "--editbox", temp_path, "20", "75"]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
finally:
import os
os.unlink(temp_path)
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
"""
Show a yes/no dialog.
Args:
title: Dialog title
text: Question text
dialog_prog: Dialog program to use
Returns:
True if yes, False if no
"""
"""Show a yes/no dialog."""
args = ["--title", title, "--yesno", text, "10", "60"]
code, _ = run_dialog(args, dialog_prog)
return code == 0
@ -118,84 +106,525 @@ def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
"""Show a message box."""
args = ["--title", title, "--msgbox", text, "10", "60"]
args = ["--title", title, "--msgbox", text, "15", "70"]
run_dialog(args, dialog_prog)
def create_tool_form(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
"""
Show form for creating/editing a tool.
def show_mixed_form(title: str, fields: dict, dialog_prog: str, height: int = 20) -> Optional[dict]:
"""Show a form with multiple fields using --mixedform."""
args = ["--title", title, "--mixedform",
"Tab: next field | Enter: submit | Esc: cancel",
str(height), "75", "0"]
Args:
dialog_prog: Dialog program to use
existing: Existing tool to edit (None for new)
field_names = list(fields.keys())
y = 1
for name in field_names:
label, initial, field_type = fields[name]
args.extend([
label, str(y), "1",
initial, str(y), "18",
"52", "256", str(field_type)
])
y += 1
code, output = run_dialog(args, dialog_prog)
if code != 0:
return None
values = output.split('\n')
result = {}
for i, name in enumerate(field_names):
result[name] = values[i] if i < len(values) else ""
return result
# ============ Provider Management ============
def select_provider(dialog_prog: str) -> Optional[str]:
"""Show provider selection menu with option to create new."""
providers = load_providers()
choices = [(p.name, f"{p.description} ({p.command})") for p in providers]
choices.append(("__new__", "[ + Add New Provider ]"))
selected = show_menu("Select Provider", choices, dialog_prog)
if selected == "__new__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
return provider.name
return None
return selected
def create_provider_form(dialog_prog: str, existing: Optional[Provider] = None) -> Optional[Provider]:
"""Show form for creating/editing a provider."""
title = f"Edit Provider: {existing.name}" if existing else "Add New Provider"
fields = {
"name": (
"Name:",
existing.name if existing else "",
2 if existing else 0 # readonly if editing
),
"command": (
"Command:",
existing.command if existing else "",
0
),
"description": (
"Description:",
existing.description if existing else "",
0
),
}
result = show_mixed_form(title, fields, dialog_prog, height=12)
if not result:
return None
name = result["name"].strip()
command = result["command"].strip()
Returns:
Tool object or None if cancelled
"""
# Get basic info
name = show_input(
"Tool Name",
"Enter tool name (used as command):",
existing.name if existing else "",
dialog_prog
)
if not name:
show_message("Error", "Provider name is required.", dialog_prog)
return None
# Check if name already exists (for new tools)
if not existing and tool_exists(name):
if not show_yesno("Overwrite?", f"Tool '{name}' already exists. Overwrite?", dialog_prog):
return None
description = show_input(
"Description",
"Enter tool description:",
existing.description if existing else "",
dialog_prog
)
if description is None:
if not command:
show_message("Error", "Command is required.", dialog_prog)
return None
provider = show_input(
"Provider",
"Enter AI provider command (e.g., codex, claude, gemini):",
existing.provider if existing else "codex",
dialog_prog
)
if not provider:
return None
provider_args = show_input(
"Provider Args",
"Enter provider arguments (e.g., -p):",
existing.provider_args if existing else "-p",
dialog_prog
)
if provider_args is None:
return None
# Edit prompt
default_prompt = existing.prompt if existing else "Process the following input:\n\n{input}"
prompt = show_textbox("Prompt Template", default_prompt, dialog_prog)
if not prompt:
return None
# For simplicity, skip custom inputs in the UI for now
# They can be added by editing config.yaml directly
inputs = existing.inputs if existing else []
return Tool(
return Provider(
name=name,
description=description,
command=command,
description=result["description"].strip()
)
def ui_manage_providers(dialog_prog: str):
"""Manage providers menu."""
while True:
providers = load_providers()
choices = [(p.name, f"{p.command}") for p in providers]
choices.append(("__add__", "[ + Add New Provider ]"))
choices.append(("__back__", "[ <- Back to Main Menu ]"))
selected = show_menu("Manage Providers", choices, dialog_prog)
if selected is None or selected == "__back__":
break
elif selected == "__add__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
show_message("Success", f"Provider '{provider.name}' added.", dialog_prog)
else:
# Edit or delete existing provider
provider = get_provider(selected)
if provider:
action = show_menu(
f"Provider: {selected}",
[
("edit", "Edit provider"),
("delete", "Delete provider"),
("back", "Back"),
],
dialog_prog
)
if action == "edit":
updated = create_provider_form(dialog_prog, provider)
if updated:
add_provider(updated)
show_message("Success", f"Provider '{updated.name}' updated.", dialog_prog)
elif action == "delete":
if show_yesno("Confirm", f"Delete provider '{selected}'?", dialog_prog):
delete_provider(selected)
show_message("Deleted", f"Provider '{selected}' deleted.", dialog_prog)
# ============ Tool Builder UI ============
def format_tool_summary(tool: Tool) -> str:
"""Format a summary of the tool's components."""
lines = []
lines.append(f"Name: {tool.name}")
lines.append(f"Description: {tool.description or '(none)'}")
lines.append("")
if tool.arguments:
lines.append("Arguments:")
for arg in tool.arguments:
default = f" = {arg.default}" if arg.default else ""
lines.append(f" {arg.flag} -> {{{arg.variable}}}{default}")
lines.append("")
if tool.steps:
lines.append("Steps:")
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
preview = step.prompt[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}")
lines.append(f" {preview}")
elif isinstance(step, CodeStep):
preview = step.code[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. CODE -> {{{step.output_var}}}")
lines.append(f" {preview}")
lines.append("")
lines.append(f"Output: {tool.output}")
return "\n".join(lines)
def get_available_variables(tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get list of available variables at a given point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def edit_argument(dialog_prog: str, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Edit or create an argument."""
title = f"Edit Argument: {existing.flag}" if existing else "Add Argument"
fields = {
"flag": ("Flag:", existing.flag if existing else "--", 0),
"variable": ("Variable:", existing.variable if existing else "", 0),
"default": ("Default:", existing.default or "" if existing else "", 0),
"description": ("Description:", existing.description if existing else "", 0),
}
result = show_mixed_form(title, fields, dialog_prog, height=14)
if not result:
return None
flag = result["flag"].strip()
variable = result["variable"].strip()
if not flag:
show_message("Error", "Flag is required (e.g., --max-size).", dialog_prog)
return None
if not variable:
# Auto-generate variable name from flag
variable = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=variable,
default=result["default"].strip() or None,
description=result["description"].strip()
)
def edit_prompt_step(dialog_prog: str, existing: Optional[PromptStep] = None,
available_vars: List[str] = None) -> Optional[PromptStep]:
"""Edit or create a prompt step."""
title = "Edit Prompt Step" if existing else "Add Prompt Step"
# First, select provider
provider = select_provider(dialog_prog)
if not provider:
provider = existing.provider if existing else "mock"
# Show variable help
var_help = "Available: " + ", ".join(f"{{{v}}}" for v in (available_vars or ["input"]))
# Edit prompt text
default_prompt = existing.prompt if existing else f"Process this input:\n\n{{input}}"
prompt = show_textbox(f"Prompt Template\n{var_help}", default_prompt, dialog_prog)
if prompt is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "result",
dialog_prog
)
if not output_var:
return None
return PromptStep(
prompt=prompt,
provider=provider,
provider_args=provider_args,
inputs=inputs,
post_process=existing.post_process if existing else None
output_var=output_var.strip()
)
def edit_code_step(dialog_prog: str, existing: Optional[CodeStep] = None,
available_vars: List[str] = None) -> Optional[CodeStep]:
"""Edit or create a code step."""
title = "Edit Code Step" if existing else "Add Code Step"
# Show variable help
var_help = "Variables: " + ", ".join(available_vars or ["input"])
var_help += "\nSet 'result' variable for output"
# Edit code
default_code = existing.code if existing else "# Available variables: " + ", ".join(available_vars or ["input"]) + "\n# Set 'result' for output\nresult = input.upper()"
code = show_textbox(f"Python Code\n{var_help}", default_code, dialog_prog)
if code is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "processed",
dialog_prog
)
if not output_var:
return None
return CodeStep(
code=code,
output_var=output_var.strip()
)
def edit_tool_info(tool: Tool, is_edit: bool, dialog_prog: str) -> None:
"""Edit basic tool info (name, description, output)."""
while True:
# Build info section menu
output_preview = tool.output[:35] + "..." if len(tool.output) > 35 else tool.output
args_count = len(tool.arguments)
args_summary = f"({args_count} defined)" if args_count else "(none)"
choices = [
("name", f"Name: {tool.name or '(not set)'}"),
("desc", f"Description: {tool.description[:35] + '...' if len(tool.description) > 35 else tool.description or '(none)'}"),
("---1", "" * 40),
]
# Show arguments
if tool.arguments:
for i, arg in enumerate(tool.arguments):
default = f" = {arg.default}" if arg.default else ""
choices.append((f"arg_{i}", f" {arg.flag} -> {{{arg.variable}}}{default}"))
choices.append(("add_arg", " [ + Add Argument ]"))
choices.append(("---2", "" * 40))
choices.append(("output", f"Output Template: {output_preview}"))
choices.append(("---3", "" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Tool Info & Arguments", choices, dialog_prog)
if selected is None or selected == "back":
break
elif selected == "name":
if is_edit:
show_message("Info", "Cannot change tool name after creation.", dialog_prog)
else:
new_name = show_input("Tool Name", "Enter tool name:", tool.name, dialog_prog)
if new_name:
tool.name = new_name.strip()
elif selected == "desc":
new_desc = show_input("Description", "Enter tool description:", tool.description, dialog_prog)
if new_desc is not None:
tool.description = new_desc.strip()
elif selected == "add_arg":
arg = edit_argument(dialog_prog)
if arg:
tool.arguments.append(arg)
elif selected.startswith("arg_"):
idx = int(selected[4:])
arg = tool.arguments[idx]
action = show_menu(
f"Argument: {arg.flag}",
[("edit", "Edit"), ("delete", "Delete"), ("back", "Back")],
dialog_prog
)
if action == "edit":
updated = edit_argument(dialog_prog, arg)
if updated:
tool.arguments[idx] = updated
elif action == "delete":
if show_yesno("Delete", f"Delete argument {arg.flag}?", dialog_prog):
tool.arguments.pop(idx)
elif selected == "output":
available = get_available_variables(tool)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
new_output = show_textbox(f"Output Template\n{var_help}", tool.output, dialog_prog)
if new_output is not None:
tool.output = new_output
def edit_tool_steps(tool: Tool, dialog_prog: str) -> None:
"""Edit tool processing steps."""
while True:
# Build steps section menu
choices = []
if tool.steps:
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
choices.append((f"step_{i}", f"{i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}"))
elif isinstance(step, CodeStep):
choices.append((f"step_{i}", f"{i+1}. CODE -> {{{step.output_var}}}"))
else:
choices.append(("none", "(no steps defined)"))
choices.append(("---1", "" * 40))
choices.append(("add_prompt", "[ + Add Prompt Step ]"))
choices.append(("add_code", "[ + Add Code Step ]"))
choices.append(("---2", "" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Processing Steps", choices, dialog_prog)
if selected is None or selected == "back" or selected == "none":
if selected == "none":
continue
break
elif selected == "add_prompt":
available = get_available_variables(tool)
step = edit_prompt_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected == "add_code":
available = get_available_variables(tool)
step = edit_code_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected.startswith("step_"):
idx = int(selected[5:])
step = tool.steps[idx]
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
move_choices = [("edit", "Edit"), ("delete", "Delete")]
if idx > 0:
move_choices.insert(1, ("move_up", "Move Up"))
if idx < len(tool.steps) - 1:
move_choices.insert(2 if idx > 0 else 1, ("move_down", "Move Down"))
move_choices.append(("back", "Back"))
action = show_menu(f"Step {idx+1}: {step_type}", move_choices, dialog_prog)
if action == "edit":
available = get_available_variables(tool, idx)
if isinstance(step, PromptStep):
updated = edit_prompt_step(dialog_prog, step, available)
else:
updated = edit_code_step(dialog_prog, step, available)
if updated:
tool.steps[idx] = updated
elif action == "move_up" and idx > 0:
tool.steps[idx], tool.steps[idx-1] = tool.steps[idx-1], tool.steps[idx]
elif action == "move_down" and idx < len(tool.steps) - 1:
tool.steps[idx], tool.steps[idx+1] = tool.steps[idx+1], tool.steps[idx]
elif action == "delete":
if show_yesno("Delete", f"Delete step {idx+1}?", dialog_prog):
tool.steps.pop(idx)
def tool_builder(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder interface with tabbed sections."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Build main menu with section summaries
args_count = len(tool.arguments)
steps_count = len(tool.steps)
# Info summary
name_display = tool.name or "(not set)"
info_summary = f"{name_display}"
if tool.arguments:
info_summary += f" | {args_count} arg{'s' if args_count != 1 else ''}"
# Steps summary
if tool.steps:
step_types = []
for s in tool.steps:
if isinstance(s, PromptStep):
step_types.append(f"P:{s.provider}")
else:
step_types.append("C")
steps_summary = " -> ".join(step_types)
else:
steps_summary = "(none)"
choices = [
("info", f"[1] Info & Args : {info_summary}"),
("steps", f"[2] Steps : {steps_summary}"),
("---", "" * 50),
("preview", "Preview Full Summary"),
("save", "Save Tool"),
("cancel", "Cancel"),
]
title = f"Tool Builder: {tool.name}" if tool.name else "Tool Builder: New Tool"
selected = show_menu(title, choices, dialog_prog)
if selected is None or selected == "cancel":
if show_yesno("Cancel", "Discard changes?", dialog_prog):
return None
continue
elif selected == "info":
edit_tool_info(tool, is_edit, dialog_prog)
elif selected == "steps":
edit_tool_steps(tool, dialog_prog)
elif selected == "preview":
summary = format_tool_summary(tool)
show_message("Tool Summary", summary, dialog_prog)
elif selected == "save":
if not tool.name:
show_message("Error", "Tool name is required. Go to Info & Args to set it.", dialog_prog)
continue
if not is_edit and tool_exists(tool.name):
if not show_yesno("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", dialog_prog):
continue
return tool
# ============ Main Menu Functions ============
def ui_list_tools(dialog_prog: str):
"""Show list of tools."""
tools = list_tools()
@ -208,17 +637,42 @@ def ui_list_tools(dialog_prog: str):
tool = load_tool(name)
if tool:
text += f" {name}: {tool.description or 'No description'}\n"
text += f" Provider: {tool.provider}\n\n"
if tool.arguments:
args = ", ".join(arg.flag for arg in tool.arguments)
text += f" Arguments: {args}\n"
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
else:
step_info.append("CODE")
text += f" Steps: {' -> '.join(step_info)}\n"
text += "\n"
show_message("Tools", text, dialog_prog)
def ui_create_tool(dialog_prog: str):
"""Create a new tool."""
tool = create_tool_form(dialog_prog)
tool = tool_builder(dialog_prog)
if tool:
path = save_tool(tool)
show_message("Success", f"Tool '{tool.name}' created!\n\nConfig: {path}\n\nRun it with: {tool.name} -i <file>", dialog_prog)
# Build usage example
usage = f"{tool.name}"
for arg in tool.arguments:
if arg.default:
usage += f" [{arg.flag} <{arg.variable}>]"
else:
usage += f" {arg.flag} <{arg.variable}>"
usage += " < input.txt"
show_message("Success",
f"Tool '{tool.name}' created!\n\n"
f"Config: {path}\n\n"
f"Usage: {usage}",
dialog_prog)
def ui_edit_tool(dialog_prog: str):
@ -228,13 +682,18 @@ def ui_edit_tool(dialog_prog: str):
show_message("Edit Tool", "No tools found.", dialog_prog)
return
choices = [(name, load_tool(name).description or "No description") for name in tools]
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Edit", choices, dialog_prog)
if selected:
existing = load_tool(selected)
if existing:
tool = create_tool_form(dialog_prog, existing)
tool = tool_builder(dialog_prog, existing)
if tool:
save_tool(tool)
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
@ -247,7 +706,12 @@ def ui_delete_tool(dialog_prog: str):
show_message("Delete Tool", "No tools found.", dialog_prog)
return
choices = [(name, load_tool(name).description or "No description") for name in tools]
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Delete", choices, dialog_prog)
if selected:
@ -265,13 +729,17 @@ def ui_test_tool(dialog_prog: str):
show_message("Test Tool", "No tools found.", dialog_prog)
return
choices = [(name, load_tool(name).description or "No description") for name in tools]
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Test", choices, dialog_prog)
if selected:
tool = load_tool(selected)
if tool:
# Get test input
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
if test_input:
from .runner import run_tool
@ -284,11 +752,10 @@ def ui_test_tool(dialog_prog: str):
show_prompt=False,
verbose=False
)
show_message(
"Test Result",
f"Exit code: {code}\n\n--- Output ---\n{output[:500]}",
dialog_prog
)
result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}"
if len(output) > 1000:
result_text += "\n... (truncated)"
show_message("Test Result", result_text, dialog_prog)
def main_menu(dialog_prog: str):
@ -302,6 +769,7 @@ def main_menu(dialog_prog: str):
("edit", "Edit existing tool"),
("delete", "Delete tool"),
("test", "Test tool (mock provider)"),
("providers", "Manage providers"),
("exit", "Exit"),
],
dialog_prog
@ -319,15 +787,33 @@ def main_menu(dialog_prog: str):
ui_delete_tool(dialog_prog)
elif choice == "test":
ui_test_tool(dialog_prog)
elif choice == "providers":
ui_manage_providers(dialog_prog)
def run_ui():
"""Entry point for the UI."""
# Prefer urwid (has mouse support)
if _check_urwid():
from .ui_urwid import run_ui as run_urwid_ui
run_urwid_ui()
return
# Fallback to snack (BIOS-style)
if _check_snack():
from .ui_snack import run_ui as run_snack_ui
run_snack_ui()
return
# Fallback to dialog/whiptail
dialog_prog = check_dialog()
if not dialog_prog:
print("Error: Neither 'dialog' nor 'whiptail' found.", file=sys.stderr)
print("Install with: sudo apt install dialog", file=sys.stderr)
print("Error: No TUI library found.", file=sys.stderr)
print("Install one of:", file=sys.stderr)
print(" pip install urwid (recommended - has mouse support)", file=sys.stderr)
print(" sudo apt install python3-newt", file=sys.stderr)
print(" sudo apt install dialog", file=sys.stderr)
sys.exit(1)
try:
@ -335,7 +821,6 @@ def run_ui():
except KeyboardInterrupt:
pass
finally:
# Clear screen on exit
subprocess.run(["clear"], check=False)

706
src/smarttools/ui_snack.py Normal file
View File

@ -0,0 +1,706 @@
"""BIOS-style TUI for SmartTools using snack (python3-newt)."""
import sys
# Ensure system packages are accessible
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
from typing import Optional, List, Tuple
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
class SmartToolsUI:
"""BIOS-style UI for SmartTools."""
def __init__(self):
self.screen = None
def run(self):
"""Run the UI."""
self.screen = snack.SnackScreen()
# Enable mouse support
self.screen.pushHelpLine(" Tab/Arrow:Navigate | Enter:Select | Mouse:Click | Esc:Back ")
try:
# Enable mouse - newt supports GPM and xterm mouse
import os
os.environ.setdefault('NEWT_MONO', '0')
self.main_menu()
finally:
self.screen.finish()
def main_menu(self):
"""Show the main menu."""
while True:
items = [
("Create New Tool", "create"),
("Edit Tool", "edit"),
("Delete Tool", "delete"),
("List Tools", "list"),
("Test Tool", "test"),
("Manage Providers", "providers"),
("Exit", "exit"),
]
listbox = snack.Listbox(height=7, width=30, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, "SmartTools Manager", 1, 1)
grid.add(listbox, 0, 0)
result = grid.runOnce()
selected = listbox.current()
if selected == "exit" or result == "ESC":
break
elif selected == "create":
self.tool_builder(None)
elif selected == "edit":
self.select_and_edit_tool()
elif selected == "delete":
self.select_and_delete_tool()
elif selected == "list":
self.show_tools_list()
elif selected == "test":
self.select_and_test_tool()
elif selected == "providers":
self.manage_providers()
def message_box(self, title: str, message: str):
"""Show a message box."""
snack.ButtonChoiceWindow(self.screen, title, message, ["OK"])
def yes_no(self, title: str, message: str) -> bool:
"""Show a yes/no dialog."""
result = snack.ButtonChoiceWindow(self.screen, title, message, ["Yes", "No"])
return result == "yes"
def input_box(self, title: str, prompt: str, initial: str = "", width: int = 40) -> Optional[str]:
"""Show an input dialog."""
entry = snack.Entry(width, initial)
grid = snack.GridForm(self.screen, title, 1, 3)
grid.add(snack.Label(prompt), 0, 0)
grid.add(entry, 0, 1, padding=(0, 1, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def text_edit(self, title: str, initial: str = "", width: int = 60, height: int = 10) -> Optional[str]:
"""Show a multi-line text editor."""
text = snack.Textbox(width, height, initial, scroll=1, wrap=1)
# snack doesn't have a true multi-line editor, so we use Entry for now
# For multi-line, we'll use a workaround with a simple entry
entry = snack.Entry(width, initial, scroll=1)
grid = snack.GridForm(self.screen, title, 1, 2)
grid.add(entry, 0, 0, padding=(0, 0, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def select_provider(self) -> Optional[str]:
"""Show provider selection dialog."""
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 1, 8), width=50, returnExit=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add New Provider ]", "__new__")
grid = snack.GridForm(self.screen, "Select Provider", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
selected = listbox.current()
if selected == "__new__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
return provider.name
return None
return selected
def add_provider_dialog(self) -> Optional[Provider]:
"""Dialog to add a new provider."""
name_entry = snack.Entry(30, "")
cmd_entry = snack.Entry(40, "")
desc_entry = snack.Entry(40, "")
grid = snack.GridForm(self.screen, "Add Provider", 2, 4)
grid.add(snack.Label("Name:"), 0, 0, anchorLeft=1)
grid.add(name_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Command:"), 0, 1, anchorLeft=1)
grid.add(cmd_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 2, anchorLeft=1)
grid.add(desc_entry, 1, 2, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 3, growx=1, growy=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
name = name_entry.value().strip()
cmd = cmd_entry.value().strip()
if name and cmd:
return Provider(name=name, command=cmd, description=desc_entry.value().strip())
self.message_box("Error", "Name and command are required.")
return None
def add_argument_dialog(self, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Dialog to add/edit an argument."""
flag_entry = snack.Entry(20, existing.flag if existing else "--")
var_entry = snack.Entry(20, existing.variable if existing else "")
default_entry = snack.Entry(20, existing.default or "" if existing else "")
desc_entry = snack.Entry(40, existing.description if existing else "")
title = "Edit Argument" if existing else "Add Argument"
grid = snack.GridForm(self.screen, title, 2, 5)
grid.add(snack.Label("Flag:"), 0, 0, anchorLeft=1)
grid.add(flag_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Variable:"), 0, 1, anchorLeft=1)
grid.add(var_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Default:"), 0, 2, anchorLeft=1)
grid.add(default_entry, 1, 2, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 3, anchorLeft=1)
grid.add(desc_entry, 1, 3, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 4, growx=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
flag = flag_entry.value().strip()
var = var_entry.value().strip()
if not flag:
self.message_box("Error", "Flag is required.")
return None
if not var:
var = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=var,
default=default_entry.value().strip() or None,
description=desc_entry.value().strip()
)
return None
def add_step_dialog(self, tool: Tool, existing_step: Optional[Step] = None, step_idx: int = -1) -> Optional[Step]:
"""Dialog to choose and add a step."""
if existing_step:
# Edit existing step
if isinstance(existing_step, PromptStep):
return self.add_prompt_dialog(tool, existing_step, step_idx)
else:
return self.add_code_dialog(tool, existing_step, step_idx)
# Choose step type
listbox = snack.Listbox(height=2, width=30, returnExit=1)
listbox.append("Prompt (AI call)", "prompt")
listbox.append("Code (Python)", "code")
grid = snack.GridForm(self.screen, "Add Step", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
step_type = listbox.current()
if step_type == "prompt":
return self.add_prompt_dialog(tool, None, step_idx)
else:
return self.add_code_dialog(tool, None, step_idx)
def get_available_variables(self, tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get available variables at a point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def add_prompt_dialog(self, tool: Tool, existing: Optional[PromptStep] = None, step_idx: int = -1) -> Optional[PromptStep]:
"""Dialog to add/edit a prompt step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
# Provider selection first
provider = self.select_provider()
if not provider:
provider = existing.provider if existing else "mock"
prompt_entry = snack.Entry(60, existing.prompt if existing else f"Process this:\n\n{{input}}", scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "result")
title = "Edit Prompt Step" if existing else "Add Prompt Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Provider: {provider}"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 0)
grid.add(snack.Label(f"Prompt ({var_help}):"), 0, 1, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 1)
grid.add(prompt_entry, 0, 2, growx=1)
grid.add(snack.Label(""), 1, 2)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 3, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 3)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
prompt = prompt_entry.value().strip()
output_var = output_entry.value().strip()
if not prompt:
self.message_box("Error", "Prompt is required.")
return None
if not output_var:
output_var = "result"
return PromptStep(prompt=prompt, provider=provider, output_var=output_var)
return None
def add_code_dialog(self, tool: Tool, existing: Optional[CodeStep] = None, step_idx: int = -1) -> Optional[CodeStep]:
"""Dialog to add/edit a code step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(available)
default_code = existing.code if existing else "# Set 'result' for output\nresult = input.upper()"
code_entry = snack.Entry(60, default_code, scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "processed")
title = "Edit Code Step" if existing else "Add Code Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Python Code ({var_help}):"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label("Set 'result' variable for output"), 1, 0)
grid.add(code_entry, 0, 1, growx=1)
grid.add(snack.Label(""), 1, 1)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 2, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
code = code_entry.value().strip()
output_var = output_entry.value().strip()
if not code:
self.message_box("Error", "Code is required.")
return None
if not output_var:
output_var = "processed"
return CodeStep(code=code, output_var=output_var)
return None
def tool_builder(self, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder - BIOS-style unified form."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Create form elements
name_entry = snack.Entry(25, tool.name, scroll=0)
desc_entry = snack.Entry(25, tool.description, scroll=1)
output_entry = snack.Entry(25, tool.output, scroll=1)
# Arguments listbox
args_listbox = snack.Listbox(height=4, width=35, returnExit=0, scroll=1)
for i, arg in enumerate(tool.arguments):
args_listbox.append(f"{arg.flag} -> {{{arg.variable}}}", i)
args_listbox.append("[ + Add ]", "add")
# Steps listbox
steps_listbox = snack.Listbox(height=5, width=35, returnExit=0, scroll=1)
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
steps_listbox.append(f"P:{step.provider} -> {{{step.output_var}}}", i)
else:
steps_listbox.append(f"C: -> {{{step.output_var}}}", i)
steps_listbox.append("[ + Add ]", "add")
# Build the grid layout using nested grids for better control
title = f"Edit Tool: {tool.name}" if is_edit and tool.name else "New Tool"
# Left column grid: Name, Description, Output
left_grid = snack.Grid(1, 6)
left_grid.setField(snack.Label("Name:"), 0, 0, anchorLeft=1)
left_grid.setField(name_entry, 0, 1, anchorLeft=1)
left_grid.setField(snack.Label("Description:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(desc_entry, 0, 3, anchorLeft=1)
left_grid.setField(snack.Label("Output:"), 0, 4, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(output_entry, 0, 5, anchorLeft=1)
# Right column grid: Arguments and Steps
right_grid = snack.Grid(1, 4)
right_grid.setField(snack.Label("Arguments:"), 0, 0, anchorLeft=1)
right_grid.setField(args_listbox, 0, 1, anchorLeft=1)
right_grid.setField(snack.Label("Execution Steps:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
right_grid.setField(steps_listbox, 0, 3, anchorLeft=1)
# Main grid
grid = snack.GridForm(self.screen, title, 2, 2)
grid.add(left_grid, 0, 0, anchorTop=1, padding=(0, 0, 2, 0))
grid.add(right_grid, 1, 0, anchorTop=1)
# Buttons at bottom
buttons = snack.ButtonBar(self.screen, [("Save", "save"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1, growx=1)
# Handle hotkeys for listbox interaction
form = grid.form
while True:
result = form.run()
# Update tool from entries
if not is_edit:
tool.name = name_entry.value().strip()
tool.description = desc_entry.value().strip()
tool.output = output_entry.value().strip()
# Check what was activated
if result == args_listbox:
selected = args_listbox.current()
if selected == "add":
new_arg = self.add_argument_dialog()
if new_arg:
tool.arguments.append(new_arg)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing argument
action = self.arg_action_menu(tool.arguments[selected])
if action == "edit":
updated = self.add_argument_dialog(tool.arguments[selected])
if updated:
tool.arguments[selected] = updated
elif action == "delete":
tool.arguments.pop(selected)
break # Refresh form
elif result == steps_listbox:
selected = steps_listbox.current()
if selected == "add":
new_step = self.add_step_dialog(tool)
if new_step:
tool.steps.append(new_step)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing step
action = self.step_action_menu(tool.steps[selected], selected, len(tool.steps))
if action == "edit":
updated = self.add_step_dialog(tool, tool.steps[selected], selected)
if updated:
tool.steps[selected] = updated
elif action == "delete":
tool.steps.pop(selected)
elif action == "move_up" and selected > 0:
tool.steps[selected], tool.steps[selected-1] = tool.steps[selected-1], tool.steps[selected]
elif action == "move_down" and selected < len(tool.steps) - 1:
tool.steps[selected], tool.steps[selected+1] = tool.steps[selected+1], tool.steps[selected]
break # Refresh form
elif buttons.buttonPressed(result) == "save":
if not tool.name:
self.message_box("Error", "Tool name is required.")
break
if not is_edit and tool_exists(tool.name):
if not self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?"):
break
self.screen.popWindow()
save_tool(tool)
self.message_box("Success", f"Tool '{tool.name}' saved!")
return tool
elif buttons.buttonPressed(result) == "cancel" or result == "ESC":
self.screen.popWindow()
return None
else:
# Tab between fields, continue
continue
self.screen.popWindow()
def arg_action_menu(self, arg: ToolArgument) -> Optional[str]:
"""Show action menu for an argument."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Argument: {arg.flag}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def step_action_menu(self, step: Step, idx: int, total: int) -> Optional[str]:
"""Show action menu for a step."""
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
items = [("Edit", "edit")]
if idx > 0:
items.append(("Move Up", "move_up"))
if idx < total - 1:
items.append(("Move Down", "move_down"))
items.append(("Delete", "delete"))
items.append(("Cancel", "cancel"))
listbox = snack.Listbox(height=len(items), width=20, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, f"Step {idx+1}: {step_type}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def select_and_edit_tool(self):
"""Select a tool to edit."""
tools = list_tools()
if not tools:
self.message_box("Edit Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
tool = load_tool(name)
desc = tool.description[:30] if tool and tool.description else "No description"
listbox.append(f"{name}: {desc}", name)
grid = snack.GridForm(self.screen, "Select Tool to Edit", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
self.tool_builder(tool)
def select_and_delete_tool(self):
"""Select a tool to delete."""
tools = list_tools()
if not tools:
self.message_box("Delete Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Delete", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Delete", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
if self.yes_no("Confirm", f"Delete tool '{selected}'?"):
if delete_tool(selected):
self.message_box("Deleted", f"Tool '{selected}' deleted.")
def show_tools_list(self):
"""Show list of all tools."""
tools = list_tools()
if not tools:
self.message_box("Tools", "No tools found.\n\nCreate one from the main menu.")
return
text = ""
for name in tools:
tool = load_tool(name)
if tool:
text += f"{name}\n"
text += f" {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(a.flag for a in tool.arguments)
text += f" Args: {args}\n"
if tool.steps:
steps = []
for s in tool.steps:
if isinstance(s, PromptStep):
steps.append(f"P:{s.provider}")
else:
steps.append("C")
text += f" Steps: {' -> '.join(steps)}\n"
text += "\n"
snack.ButtonChoiceWindow(self.screen, "Available Tools", text.strip(), ["OK"])
def select_and_test_tool(self):
"""Select a tool to test."""
tools = list_tools()
if not tools:
self.message_box("Test Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Test", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Test", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
test_input = self.input_box("Test Input", "Enter test input:", "Hello world")
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\nOutput:\n{output[:500]}"
self.message_box("Test Result", result_text)
def manage_providers(self):
"""Manage providers menu."""
while True:
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 2, 10), width=50, returnExit=1, scroll=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add Provider ]", "__add__")
listbox.append("[ <- Back ]", "__back__")
grid = snack.GridForm(self.screen, "Manage Providers", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
selected = listbox.current()
if selected == "__back__":
break
elif selected == "__add__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
self.message_box("Success", f"Provider '{provider.name}' added.")
else:
# Edit or delete
provider = get_provider(selected)
if provider:
action = self.provider_action_menu(provider)
if action == "edit":
updated = self.add_provider_dialog() # TODO: pass existing
if updated:
add_provider(updated)
elif action == "delete":
if self.yes_no("Confirm", f"Delete provider '{selected}'?"):
delete_provider(selected)
def provider_action_menu(self, provider: Provider) -> Optional[str]:
"""Show action menu for a provider."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Provider: {provider.name}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def run_ui():
"""Entry point for the snack UI."""
ui = SmartToolsUI()
ui.run()
if __name__ == "__main__":
run_ui()

1720
src/smarttools/ui_urwid.py Normal file

File diff suppressed because it is too large Load Diff