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:
parent
28dac465e6
commit
4c71dbded2
353
README.md
353
README.md
|
|
@ -1,79 +1,334 @@
|
||||||
# SmartTools
|
# SmartTools
|
||||||
|
|
||||||
A lightweight personal tool builder for AI-powered CLI commands.
|
**A lightweight personal tool builder for AI-powered CLI commands.**
|
||||||
|
|
||||||
## What is this?
|
Turn any AI model into a Unix-style pipe command. Build once, use forever.
|
||||||
|
|
||||||
SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, prompt, provider), then use it like any Linux command.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a summarizer, then use it like any command:
|
# Fix grammar in any file
|
||||||
sum -i document.txt -o summary.txt --max 512
|
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
|
## Quick Start
|
||||||
# ~/.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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sum -i myfile.txt -o summary.txt
|
# Install
|
||||||
sum -i myfile.txt --dry-run # preview prompt
|
pip install smarttools
|
||||||
sum -i myfile.txt --provider mock # test without API
|
|
||||||
|
# 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
|
## Installation
|
||||||
|
|
||||||
|
### From PyPI (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install smarttools
|
pip install smarttools
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Launch UI to manage tools
|
git clone https://github.com/yourusername/smarttools.git
|
||||||
smarttools
|
cd smarttools
|
||||||
|
pip install -e .
|
||||||
# Or use CLI:
|
|
||||||
smarttools list
|
|
||||||
smarttools create mytool
|
|
||||||
smarttools edit mytool
|
|
||||||
smarttools delete mytool
|
|
||||||
smarttools test mytool
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
## 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
|
## 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)
|
||||||
|
|
|
||||||
354
docs/DESIGN.md
354
docs/DESIGN.md
|
|
@ -1,10 +1,10 @@
|
||||||
# SmartTools - Simple Design
|
# SmartTools - Design Document
|
||||||
|
|
||||||
> A lightweight personal tool builder for AI-powered CLI commands
|
> A lightweight personal tool builder for AI-powered CLI commands
|
||||||
|
|
||||||
## Overview
|
## 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:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -20,44 +20,91 @@ sum -i text.txt -o summary.txt --max 512
|
||||||
~/.smarttools/
|
~/.smarttools/
|
||||||
sum/
|
sum/
|
||||||
config.yaml
|
config.yaml
|
||||||
post.py # optional post-processing script
|
processed.py # Optional external code file
|
||||||
reviewer/
|
reviewer/
|
||||||
config.yaml
|
config.yaml
|
||||||
translator/
|
translator/
|
||||||
config.yaml
|
config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Minimal config.yaml
|
### config.yaml Format
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: sum
|
name: sum
|
||||||
description: "Summarize documents"
|
description: "Summarize documents"
|
||||||
|
arguments:
|
||||||
|
- flag: --max
|
||||||
|
variable: max
|
||||||
|
default: "500"
|
||||||
|
description: "Maximum words in summary"
|
||||||
|
steps:
|
||||||
|
- type: prompt
|
||||||
prompt: |
|
prompt: |
|
||||||
Summarize the following text in {max} words or less:
|
Summarize the following text in {max} words or less:
|
||||||
|
|
||||||
{input}
|
{input}
|
||||||
|
provider: claude
|
||||||
provider: codex
|
output_var: response
|
||||||
provider_args: "-p"
|
output: "{response}"
|
||||||
|
|
||||||
# Optional
|
|
||||||
inputs:
|
|
||||||
- name: max
|
|
||||||
flag: --max
|
|
||||||
default: 500
|
|
||||||
|
|
||||||
# Optional post-processing script
|
|
||||||
post_process: post.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## CLI Interface
|
||||||
|
|
||||||
### Running Tools
|
### Running Tools
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic usage
|
# Basic usage (wrapper script in ~/.local/bin)
|
||||||
sum -i document.txt -o summary.txt
|
sum -i document.txt -o summary.txt
|
||||||
|
|
||||||
# With custom args
|
# With custom args
|
||||||
|
|
@ -74,14 +121,33 @@ sum -i document.txt --provider mock
|
||||||
|
|
||||||
# Read from stdin, write to stdout
|
# Read from stdin, write to stdout
|
||||||
cat doc.txt | sum | less
|
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)
|
### Universal Flags (all tools)
|
||||||
|
|
||||||
| Flag | Short | Description |
|
| Flag | Short | Description |
|
||||||
|------|-------|-------------|
|
|------|-------|-------------|
|
||||||
| `--input` | `-i` | Input file (or stdin if omitted) |
|
| `--input` | `-i` | Input file |
|
||||||
| `--output` | `-o` | Output file (or stdout if omitted) |
|
| `--output` | `-o` | Output file (or stdout if omitted) |
|
||||||
|
| `--stdin` | | Read input interactively (type then Ctrl+D) |
|
||||||
| `--dry-run` | | Show prompt, don't call AI |
|
| `--dry-run` | | Show prompt, don't call AI |
|
||||||
| `--show-prompt` | | Call AI but also print prompt to stderr |
|
| `--show-prompt` | | Call AI but also print prompt to stderr |
|
||||||
| `--provider` | `-p` | Override provider (e.g., `--provider mock`) |
|
| `--provider` | `-p` | Override provider (e.g., `--provider mock`) |
|
||||||
|
|
@ -96,15 +162,18 @@ smarttools
|
||||||
|
|
||||||
# Or use CLI directly:
|
# Or use CLI directly:
|
||||||
smarttools list # List all tools
|
smarttools list # List all tools
|
||||||
smarttools create sum # Create new tool
|
smarttools create sum # Create new tool (basic)
|
||||||
smarttools edit sum # Edit existing tool
|
smarttools edit sum # Edit tool config in $EDITOR
|
||||||
smarttools delete sum # Delete tool
|
smarttools delete sum # Delete tool
|
||||||
smarttools test sum # Test with mock provider
|
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
|
### Main Menu
|
||||||
|
|
||||||
|
|
@ -112,66 +181,84 @@ A simple terminal UI using `dialog` or `whiptail` (available on most Linux syste
|
||||||
┌──────────────────────────────────────┐
|
┌──────────────────────────────────────┐
|
||||||
│ SmartTools Manager │
|
│ SmartTools Manager │
|
||||||
├──────────────────────────────────────┤
|
├──────────────────────────────────────┤
|
||||||
│ > List Tools │
|
│ Tools: │
|
||||||
│ Create New Tool │
|
│ ┌────────────────────────────────┐ │
|
||||||
│ Edit Tool │
|
│ │ sum │ │
|
||||||
│ Delete Tool │
|
│ │ reviewer │ │
|
||||||
│ Test Tool │
|
│ │ translator │ │
|
||||||
│ Exit │
|
│ └────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [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 │
|
│ New Tool │
|
||||||
├──────────────────────────────────────┤
|
├──────────────────────────────────────────────────────────────┤
|
||||||
│ Name: [sum________________] │
|
│ ┌─[ Tool Info ]──────────┐ ┌─ Arguments ─────────────────┐ │
|
||||||
│ Description: [Summarize documents_] │
|
│ │ Name: [sum__________] │ │ --max -> {max} │ │
|
||||||
│ Provider: [codex_____________] │
|
│ │ │ │ │ │
|
||||||
│ Provider Args: [-p________________] │
|
│ │ Desc: [Summarize____] │ └─────────────────────────────┘ │
|
||||||
│ │
|
│ │ │ [Add] [Edit] [Delete] │
|
||||||
│ Prompt: │
|
│ │ Output: [{response}_] │ │
|
||||||
│ ┌──────────────────────────────────┐ │
|
│ └────────────────────────┘ ┌─ Execution Steps ───────────┐ │
|
||||||
│ │ Summarize the following text │ │
|
│ │ P:claude -> {response} │ │
|
||||||
│ │ in {max} words or less: │ │
|
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ {input} │ │
|
│ └─────────────────────────────┘ │
|
||||||
│ └──────────────────────────────────┘ │
|
│ [Add] [Edit] [Delete] │
|
||||||
│ │
|
│ │
|
||||||
│ Custom Arguments: │
|
│ [Save] [Cancel] │
|
||||||
│ --max (default: 500) │
|
└──────────────────────────────────────────────────────────────┘
|
||||||
│ [Add Argument] │
|
|
||||||
│ │
|
|
||||||
│ Post-process Script: [none______▼] │
|
|
||||||
│ │
|
|
||||||
│ [Save] [Test] [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
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────┐
|
┌──────────────── Add Code Step ─────────────────┐
|
||||||
│ Test Tool: sum │
|
│ Variables: input, max, response │
|
||||||
├──────────────────────────────────────┤
|
|
||||||
│ Input: [Select file...] or [Paste] │
|
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ Final Prompt ───────────────────┐ │
|
│ File: [processed.py______________] < Load > │
|
||||||
│ │ Summarize the following text │ │
|
│ │
|
||||||
│ │ in 500 words or less: │ │
|
│ ┌─ Code ─────────────────────────────────────┐ │
|
||||||
|
│ │processed = input.upper() │ │
|
||||||
|
│ │count = len(processed.split()) │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ Lorem ipsum dolor sit amet... │ │
|
│ └────────────────────────────────────────────┘ │
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
│ │
|
│ │
|
||||||
│ Provider: [mock_____▼] │
|
│ Output var: [processed, count____] │
|
||||||
│ │
|
├────────────────────────────────────────────────┤
|
||||||
│ [Run Test] [Copy Command] │
|
│ < 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
|
## Implementation
|
||||||
|
|
||||||
|
|
@ -181,79 +268,54 @@ A simple terminal UI using `dialog` or `whiptail` (available on most Linux syste
|
||||||
smarttools/
|
smarttools/
|
||||||
__init__.py
|
__init__.py
|
||||||
cli.py # Entry point, argument parsing
|
cli.py # Entry point, argument parsing
|
||||||
ui.py # Dialog-based UI
|
ui.py # UI dispatcher (chooses urwid/snack/dialog)
|
||||||
tool.py # Tool loading/saving
|
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
|
runner.py # Execute tools
|
||||||
providers.py # Provider abstraction (minimal)
|
providers.py # Provider abstraction
|
||||||
```
|
```
|
||||||
|
|
||||||
### Provider Abstraction (Simple)
|
### Provider System
|
||||||
|
|
||||||
```python
|
Providers are defined in `~/.smarttools/providers.yaml`:
|
||||||
# providers.py
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
def call_provider(provider: str, args: str, prompt: str) -> str:
|
```yaml
|
||||||
"""Call an AI CLI tool with the given prompt."""
|
providers:
|
||||||
cmd = f"{provider} {args}"
|
- name: claude
|
||||||
result = subprocess.run(
|
command: "claude -p"
|
||||||
cmd,
|
description: "Anthropic Claude"
|
||||||
shell=True,
|
- name: codex
|
||||||
input=prompt,
|
command: "codex -p"
|
||||||
capture_output=True,
|
description: "OpenAI Codex"
|
||||||
text=True
|
- name: gemini
|
||||||
)
|
command: "gemini"
|
||||||
if result.returncode != 0:
|
description: "Google Gemini"
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tool Runner (Simple)
|
The provider command receives the prompt via stdin and outputs to stdout.
|
||||||
|
|
||||||
```python
|
### Wrapper Scripts
|
||||||
# runner.py
|
|
||||||
import yaml
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
TOOLS_DIR = Path.home() / ".smarttools"
|
When you save a tool, SmartTools creates a wrapper script in `~/.local/bin/`:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ~/.smarttools/sum/sum (auto-generated)
|
# SmartTools wrapper for 'sum'
|
||||||
exec python3 -m smarttools.run 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
|
## What This Design Doesn't Include
|
||||||
|
|
||||||
|
|
@ -272,33 +334,31 @@ Intentionally omitted (not needed for personal use):
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Minimal:
|
Required:
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- PyYAML
|
- PyYAML
|
||||||
- `dialog` or `whiptail` (pre-installed on most Linux)
|
- urwid (for TUI)
|
||||||
|
|
||||||
Optional:
|
Optional fallbacks:
|
||||||
- `textual` (if you want a fancier TUI later)
|
- python3-newt/snack (simpler TUI)
|
||||||
|
- dialog/whiptail (basic TUI)
|
||||||
## 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**
|
|
||||||
|
|
||||||
## Example Workflow
|
## Example Workflow
|
||||||
|
|
||||||
1. Run `smarttools` to open UI
|
1. Run `smarttools` to open UI
|
||||||
2. Select "Create New Tool"
|
2. Select "Create" to create a new tool
|
||||||
3. Fill in: name=`sum`, prompt, provider=`codex`
|
3. Fill in: name, description, output template
|
||||||
4. Click "Test" to verify with mock provider
|
4. Add arguments (e.g., `--max` with default `500`)
|
||||||
5. Click "Save"
|
5. Add a prompt step with your prompt template and provider
|
||||||
6. Exit UI
|
6. Click "Save"
|
||||||
7. Run `sum -i myfile.txt -o summary.txt`
|
7. Exit UI
|
||||||
|
8. Run `sum -i myfile.txt -o summary.txt`
|
||||||
|
|
||||||
Done. No containers, no signing, no certification. Just a tool that works.
|
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` |
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -12,27 +12,38 @@ requires-python = ">=3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Rob"}
|
{name = "Rob"}
|
||||||
]
|
]
|
||||||
keywords = ["ai", "cli", "tools", "automation"]
|
keywords = ["ai", "cli", "tools", "automation", "llm", "terminal", "unix", "pipe"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 4 - Beta",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: System Administrators",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Operating System :: MacOS",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Topic :: Utilities",
|
"Topic :: Utilities",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Topic :: Text Processing",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
tui = [
|
||||||
|
"urwid>=2.1.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"pytest-cov>=4.0",
|
"pytest-cov>=4.0",
|
||||||
|
"urwid>=2.1.0",
|
||||||
|
]
|
||||||
|
all = [
|
||||||
|
"urwid>=2.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
@ -40,6 +51,10 @@ smarttools = "smarttools.cli:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/rob/smarttools"
|
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]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from . import __version__
|
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
|
from .ui import run_ui
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ def cmd_list(args):
|
||||||
|
|
||||||
if not tools:
|
if not tools:
|
||||||
print("No tools found.")
|
print("No tools found.")
|
||||||
print("Create your first tool with: smarttools create <name>")
|
print("Create your first tool with: smarttools ui")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"Available tools ({len(tools)}):\n")
|
print(f"Available tools ({len(tools)}):\n")
|
||||||
|
|
@ -23,14 +23,29 @@ def cmd_list(args):
|
||||||
if tool:
|
if tool:
|
||||||
print(f" {name}")
|
print(f" {name}")
|
||||||
print(f" {tool.description or 'No description'}")
|
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()
|
print()
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_create(args):
|
def cmd_create(args):
|
||||||
"""Create a new tool."""
|
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
|
||||||
name = args.name
|
name = args.name
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
|
|
@ -39,21 +54,28 @@ def cmd_create(args):
|
||||||
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
|
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
|
||||||
return 1
|
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(
|
tool = Tool(
|
||||||
name=name,
|
name=name,
|
||||||
description=args.description or "",
|
description=args.description or "",
|
||||||
prompt=args.prompt or "Process the following:\n\n{input}",
|
arguments=[],
|
||||||
provider=args.provider or "codex",
|
steps=steps,
|
||||||
provider_args=args.provider_args or "-p",
|
output="{response}" if steps else "{input}"
|
||||||
inputs=[],
|
|
||||||
post_process=None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
path = save_tool(tool)
|
path = save_tool(tool)
|
||||||
print(f"Created tool '{name}'")
|
print(f"Created tool '{name}'")
|
||||||
print(f"Config: {path}")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
@ -141,12 +163,106 @@ def cmd_test(args):
|
||||||
return code
|
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):
|
def cmd_ui(args):
|
||||||
"""Launch the interactive UI."""
|
"""Launch the interactive UI."""
|
||||||
run_ui()
|
run_ui()
|
||||||
return 0
|
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():
|
def main():
|
||||||
"""Main CLI entry point."""
|
"""Main CLI entry point."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|
@ -167,8 +283,7 @@ def main():
|
||||||
p_create.add_argument("name", help="Tool name")
|
p_create.add_argument("name", help="Tool name")
|
||||||
p_create.add_argument("-d", "--description", help="Tool description")
|
p_create.add_argument("-d", "--description", help="Tool description")
|
||||||
p_create.add_argument("-p", "--prompt", help="Prompt template")
|
p_create.add_argument("-p", "--prompt", help="Prompt template")
|
||||||
p_create.add_argument("--provider", help="AI provider (default: codex)")
|
p_create.add_argument("--provider", help="AI provider (default: mock)")
|
||||||
p_create.add_argument("--provider-args", help="Provider arguments (default: -p)")
|
|
||||||
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
|
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
|
||||||
p_create.set_defaults(func=cmd_create)
|
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.add_argument("--dry-run", action="store_true", help="Show prompt only")
|
||||||
p_test.set_defaults(func=cmd_test)
|
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)
|
# 'ui' command (explicit)
|
||||||
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
|
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
|
||||||
p_ui.set_defaults(func=cmd_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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# If no command, launch UI
|
# If no command, launch UI
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,40 @@
|
||||||
"""Provider abstraction for AI CLI tools."""
|
"""Provider abstraction for AI CLI tools."""
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
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
|
@dataclass
|
||||||
|
|
@ -14,29 +45,137 @@ class ProviderResult:
|
||||||
error: Optional[str] = None
|
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:
|
Args:
|
||||||
provider: The CLI command (e.g., 'codex', 'claude', 'gemini')
|
provider_name: Name of the provider to use
|
||||||
args: Additional arguments for the provider (e.g., '-p')
|
|
||||||
prompt: The prompt to send
|
prompt: The prompt to send
|
||||||
timeout: Maximum execution time in seconds
|
timeout: Maximum execution time in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ProviderResult with the response text or error
|
ProviderResult with the response text or error
|
||||||
"""
|
"""
|
||||||
# Check if provider exists
|
# Handle mock provider specially
|
||||||
if not shutil.which(provider):
|
if provider_name.lower() == "mock":
|
||||||
|
return mock_provider(prompt)
|
||||||
|
|
||||||
|
# Look up provider
|
||||||
|
provider = get_provider(provider_name)
|
||||||
|
if not provider:
|
||||||
return ProviderResult(
|
return ProviderResult(
|
||||||
text="",
|
text="",
|
||||||
success=False,
|
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
|
# Parse command (expand environment variables)
|
||||||
cmd = f"{provider} {args}".strip()
|
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:
|
try:
|
||||||
result = subprocess.run(
|
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.",
|
f"This is a mock response. Use a real provider for actual output.",
|
||||||
success=True
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -5,75 +5,86 @@ import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .tool import load_tool, Tool
|
from .tool import load_tool, Tool, PromptStep, CodeStep
|
||||||
from .providers import call_provider, mock_provider, ProviderResult
|
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:
|
Args:
|
||||||
tool: Tool definition
|
template: String with {var} placeholders
|
||||||
input_text: Content from input file or stdin
|
variables: Dict of variable name -> value
|
||||||
custom_args: Custom argument values
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Final prompt string
|
String with placeholders replaced
|
||||||
"""
|
"""
|
||||||
prompt = tool.prompt
|
result = template
|
||||||
|
for name, value in variables.items():
|
||||||
# Replace {input} placeholder
|
result = result.replace(f"{{{name}}}", str(value) if value else "")
|
||||||
prompt = prompt.replace("{input}", input_text)
|
return result
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
Args:
|
||||||
tool: Tool definition
|
step: The prompt step to execute
|
||||||
output: Raw output from provider
|
variables: Current variable values
|
||||||
|
provider_override: Override the step's provider
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Processed output
|
Tuple of (output_value, success)
|
||||||
"""
|
"""
|
||||||
if not tool.post_process:
|
# Build prompt with variable substitution
|
||||||
return output
|
prompt = substitute_variables(step.prompt, variables)
|
||||||
|
|
||||||
from .tool import get_tools_dir
|
# Call provider
|
||||||
script_path = get_tools_dir() / tool.name / tool.post_process
|
provider = provider_override or step.provider
|
||||||
|
|
||||||
if not script_path.exists():
|
if provider.lower() == "mock":
|
||||||
print(f"Warning: Post-process script not found: {script_path}", file=sys.stderr)
|
result = mock_provider(prompt)
|
||||||
return output
|
|
||||||
|
|
||||||
# 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:
|
else:
|
||||||
print(f"Warning: Post-process failed: {result.stderr}", file=sys.stderr)
|
result = call_provider(provider, prompt)
|
||||||
return output
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
print(f"Warning: Post-process error: {e}", file=sys.stderr)
|
print(f"Error in code step: {e}", file=sys.stderr)
|
||||||
return output
|
return {}, False
|
||||||
|
|
||||||
|
|
||||||
def run_tool(
|
def run_tool(
|
||||||
|
|
@ -92,48 +103,73 @@ def run_tool(
|
||||||
tool: Tool definition
|
tool: Tool definition
|
||||||
input_text: Input content
|
input_text: Input content
|
||||||
custom_args: Custom argument values
|
custom_args: Custom argument values
|
||||||
provider_override: Override the tool's provider
|
provider_override: Override all providers
|
||||||
dry_run: Just show prompt, don't call provider
|
dry_run: Just show what would happen
|
||||||
show_prompt: Show prompt in addition to output
|
show_prompt: Show prompts in addition to output
|
||||||
verbose: Show debug info
|
verbose: Show debug info
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (output_text, exit_code)
|
Tuple of (output_text, exit_code)
|
||||||
"""
|
"""
|
||||||
# Build prompt
|
# Initialize variables with input and arguments
|
||||||
prompt = build_prompt(tool, input_text, custom_args)
|
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:
|
if verbose:
|
||||||
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
|
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
|
||||||
print(f"[verbose] Provider: {provider_override or tool.provider}", file=sys.stderr)
|
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
|
||||||
print(f"[verbose] Prompt length: {len(prompt)} chars", file=sys.stderr)
|
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
|
||||||
|
|
||||||
# Dry run - just show prompt
|
# If no steps, just substitute output template
|
||||||
if dry_run:
|
if not tool.steps:
|
||||||
return prompt, 0
|
output = substitute_variables(tool.output, variables)
|
||||||
|
return output, 0
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
# Show prompt if requested
|
# Show prompt if requested
|
||||||
if show_prompt:
|
if show_prompt or dry_run:
|
||||||
print("=== PROMPT ===", file=sys.stderr)
|
prompt = substitute_variables(step.prompt, variables)
|
||||||
|
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
|
||||||
print(prompt, file=sys.stderr)
|
print(prompt, file=sys.stderr)
|
||||||
print("=== END PROMPT ===", file=sys.stderr)
|
print("=== END PROMPT ===", file=sys.stderr)
|
||||||
|
|
||||||
# Call provider
|
if dry_run:
|
||||||
provider = provider_override or tool.provider
|
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
|
||||||
|
|
||||||
if provider.lower() == "mock":
|
|
||||||
result = mock_provider(prompt)
|
|
||||||
else:
|
else:
|
||||||
result = call_provider(provider, tool.provider_args, prompt)
|
output, success = execute_prompt_step(step, variables, provider_override)
|
||||||
|
if not success:
|
||||||
|
return "", 2
|
||||||
|
variables[step.output_var] = output
|
||||||
|
|
||||||
if not result.success:
|
elif isinstance(step, CodeStep):
|
||||||
print(f"Error: {result.error}", file=sys.stderr)
|
if verbose or dry_run:
|
||||||
return "", 2 # Exit code 2 for provider errors
|
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
|
# Generate final output
|
||||||
output = run_post_process(tool, output)
|
output = substitute_variables(tool.output, variables)
|
||||||
|
|
||||||
return output, 0
|
return output, 0
|
||||||
|
|
||||||
|
|
@ -155,25 +191,27 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
|
||||||
|
|
||||||
# Universal flags
|
# Universal flags
|
||||||
parser.add_argument("-i", "--input", dest="input_file",
|
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",
|
parser.add_argument("-o", "--output", dest="output_file",
|
||||||
help="Output file (writes to stdout if omitted)")
|
help="Output file (writes to stdout if omitted)")
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
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",
|
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",
|
parser.add_argument("-p", "--provider",
|
||||||
help="Override provider (e.g., --provider mock)")
|
help="Override provider (e.g., --provider mock)")
|
||||||
parser.add_argument("-v", "--verbose", action="store_true",
|
parser.add_argument("-v", "--verbose", action="store_true",
|
||||||
help="Show debug information")
|
help="Show debug information")
|
||||||
|
|
||||||
# Tool-specific flags
|
# Tool-specific flags from arguments
|
||||||
for inp in tool.inputs:
|
for arg in tool.arguments:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
inp.flag,
|
arg.flag,
|
||||||
dest=inp.name,
|
dest=arg.variable,
|
||||||
default=inp.default,
|
default=arg.default,
|
||||||
help=inp.description or f"{inp.name} (default: {inp.default})"
|
help=arg.description or f"{arg.variable} (default: {arg.default})"
|
||||||
)
|
)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
@ -198,23 +236,29 @@ def main():
|
||||||
|
|
||||||
# Read input
|
# Read input
|
||||||
if args.input_file:
|
if args.input_file:
|
||||||
|
# Read from file
|
||||||
input_path = Path(args.input_file)
|
input_path = Path(args.input_file)
|
||||||
if not input_path.exists():
|
if not input_path.exists():
|
||||||
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
|
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
input_text = input_path.read_text()
|
input_text = input_path.read_text()
|
||||||
else:
|
elif args.stdin:
|
||||||
# Read from stdin
|
# Explicit interactive input requested
|
||||||
if sys.stdin.isatty():
|
|
||||||
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
|
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
|
||||||
input_text = sys.stdin.read()
|
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
|
# Collect custom args
|
||||||
custom_args = {}
|
custom_args = {}
|
||||||
for inp in tool.inputs:
|
for arg in tool.arguments:
|
||||||
value = getattr(args, inp.name, None)
|
value = getattr(args, arg.variable, None)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
custom_args[inp.name] = value
|
custom_args[arg.variable] = value
|
||||||
|
|
||||||
# Run tool
|
# Run tool
|
||||||
output, exit_code = run_tool(
|
output, exit_code = run_tool(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import os
|
||||||
import stat
|
import stat
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, List, Literal
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
@ -17,74 +17,145 @@ BIN_DIR = Path.home() / ".local" / "bin"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolInput:
|
class ToolArgument:
|
||||||
"""Definition of a custom input argument."""
|
"""Definition of a custom input argument."""
|
||||||
name: str
|
flag: str # e.g., "--max-size"
|
||||||
flag: str
|
variable: str # e.g., "max_size"
|
||||||
default: Optional[str] = None
|
default: Optional[str] = None
|
||||||
description: str = ""
|
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
|
@dataclass
|
||||||
class Tool:
|
class Tool:
|
||||||
"""A SmartTools tool definition."""
|
"""A SmartTools tool definition."""
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str = ""
|
||||||
prompt: str
|
arguments: List[ToolArgument] = field(default_factory=list)
|
||||||
provider: str
|
steps: List[Step] = field(default_factory=list)
|
||||||
provider_args: str = ""
|
output: str = "{input}" # Output template
|
||||||
inputs: list[ToolInput] = field(default_factory=list)
|
|
||||||
post_process: Optional[str] = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "Tool":
|
def from_dict(cls, data: dict) -> "Tool":
|
||||||
"""Create a Tool from a dictionary (parsed YAML)."""
|
arguments = []
|
||||||
inputs = []
|
for arg in data.get("arguments", []):
|
||||||
for inp in data.get("inputs", []):
|
arguments.append(ToolArgument.from_dict(arg))
|
||||||
inputs.append(ToolInput(
|
|
||||||
name=inp["name"],
|
steps = []
|
||||||
flag=inp.get("flag", f"--{inp['name']}"),
|
for step in data.get("steps", []):
|
||||||
default=inp.get("default"),
|
if step.get("type") == "prompt":
|
||||||
description=inp.get("description", "")
|
steps.append(PromptStep.from_dict(step))
|
||||||
))
|
elif step.get("type") == "code":
|
||||||
|
steps.append(CodeStep.from_dict(step))
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
prompt=data["prompt"],
|
arguments=arguments,
|
||||||
provider=data["provider"],
|
steps=steps,
|
||||||
provider_args=data.get("provider_args", ""),
|
output=data.get("output", "{input}")
|
||||||
inputs=inputs,
|
|
||||||
post_process=data.get("post_process")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert Tool to dictionary for YAML serialization."""
|
return {
|
||||||
data = {
|
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"prompt": self.prompt,
|
"arguments": [arg.to_dict() for arg in self.arguments],
|
||||||
"provider": self.provider,
|
"steps": [step.to_dict() for step in self.steps],
|
||||||
|
"output": self.output
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.provider_args:
|
def get_available_variables(self) -> List[str]:
|
||||||
data["provider_args"] = self.provider_args
|
"""Get all variables available for use in templates."""
|
||||||
|
variables = ["input"] # Always available
|
||||||
|
|
||||||
if self.inputs:
|
# Add argument variables
|
||||||
data["inputs"] = [
|
for arg in self.arguments:
|
||||||
{
|
variables.append(arg.variable)
|
||||||
"name": inp.name,
|
|
||||||
"flag": inp.flag,
|
|
||||||
"default": inp.default,
|
|
||||||
"description": inp.description,
|
|
||||||
}
|
|
||||||
for inp in self.inputs
|
|
||||||
]
|
|
||||||
|
|
||||||
if self.post_process:
|
# Add step output variables
|
||||||
data["post_process"] = self.post_process
|
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:
|
def get_tools_dir() -> Path:
|
||||||
|
|
@ -114,15 +185,7 @@ def list_tools() -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def load_tool(name: str) -> Optional[Tool]:
|
def load_tool(name: str) -> Optional[Tool]:
|
||||||
"""
|
"""Load a tool by name."""
|
||||||
Load a tool by name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Tool name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tool object or None if not found
|
|
||||||
"""
|
|
||||||
config_path = get_tools_dir() / name / "config.yaml"
|
config_path = get_tools_dir() / name / "config.yaml"
|
||||||
|
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
|
|
@ -130,21 +193,44 @@ def load_tool(name: str) -> Optional[Tool]:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = yaml.safe_load(config_path.read_text())
|
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)
|
return Tool.from_dict(data)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(f"Error loading tool {name}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def save_tool(tool: Tool) -> Path:
|
def save_tool(tool: Tool) -> Path:
|
||||||
"""
|
"""Save a tool to disk."""
|
||||||
Save a tool to disk.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool: Tool object to save
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the saved config file
|
|
||||||
"""
|
|
||||||
tool_dir = get_tools_dir() / tool.name
|
tool_dir = get_tools_dir() / tool.name
|
||||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
@ -158,15 +244,7 @@ def save_tool(tool: Tool) -> Path:
|
||||||
|
|
||||||
|
|
||||||
def delete_tool(name: str) -> bool:
|
def delete_tool(name: str) -> bool:
|
||||||
"""
|
"""Delete a tool."""
|
||||||
Delete a tool.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Tool name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted, False if not found
|
|
||||||
"""
|
|
||||||
tool_dir = get_tools_dir() / name
|
tool_dir = get_tools_dir() / name
|
||||||
|
|
||||||
if not tool_dir.exists():
|
if not tool_dir.exists():
|
||||||
|
|
@ -185,22 +263,19 @@ def delete_tool(name: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def create_wrapper_script(name: str) -> Path:
|
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()
|
bin_dir = get_bin_dir()
|
||||||
wrapper_path = bin_dir / name
|
wrapper_path = bin_dir / name
|
||||||
|
|
||||||
|
# Use the current Python interpreter to ensure smarttools is available
|
||||||
|
python_path = sys.executable
|
||||||
|
|
||||||
script = f"""#!/bin/bash
|
script = f"""#!/bin/bash
|
||||||
# SmartTools wrapper for '{name}'
|
# SmartTools wrapper for '{name}'
|
||||||
# Auto-generated - do not edit
|
# Auto-generated - do not edit
|
||||||
exec python3 -m smarttools.runner {name} "$@"
|
exec {python_path} -m smarttools.runner {name} "$@"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
wrapper_path.write_text(script)
|
wrapper_path.write_text(script)
|
||||||
|
|
@ -212,3 +287,34 @@ exec python3 -m smarttools.runner {name} "$@"
|
||||||
def tool_exists(name: str) -> bool:
|
def tool_exists(name: str) -> bool:
|
||||||
"""Check if a tool exists."""
|
"""Check if a tool exists."""
|
||||||
return (get_tools_dir() / name / "config.yaml").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, ""
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,34 @@
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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:
|
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]:
|
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
|
||||||
"""
|
"""Run a dialog command and return (exit_code, output)."""
|
||||||
Run a dialog command and return (exit_code, output).
|
try:
|
||||||
|
if dialog_prog == "whiptail":
|
||||||
Args:
|
result = subprocess.run(
|
||||||
args: Dialog arguments
|
[dialog_prog] + args,
|
||||||
dialog_prog: Dialog program to use
|
stderr=subprocess.PIPE,
|
||||||
|
text=True
|
||||||
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()
|
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]:
|
def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]:
|
||||||
"""
|
"""Show a menu and return the selected item."""
|
||||||
Show a menu and return the selected item.
|
args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))]
|
||||||
|
|
||||||
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))]
|
|
||||||
for tag, desc in choices:
|
for tag, desc in choices:
|
||||||
args.extend([tag, desc])
|
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]:
|
def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]:
|
||||||
"""
|
"""Show an input box and return the entered text."""
|
||||||
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
|
|
||||||
"""
|
|
||||||
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
|
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
|
||||||
code, output = run_dialog(args, dialog_prog)
|
code, output = run_dialog(args, dialog_prog)
|
||||||
return output if code == 0 else None
|
return output if code == 0 else None
|
||||||
|
|
||||||
|
|
||||||
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
|
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
|
||||||
"""
|
"""Show a text editor for multi-line input."""
|
||||||
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
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
temp_path = f.name
|
temp_path = f.name
|
||||||
|
|
||||||
args = ["--title", title, "--editbox", temp_path, "20", "70"]
|
try:
|
||||||
|
args = ["--title", title, "--editbox", temp_path, "20", "75"]
|
||||||
code, output = run_dialog(args, dialog_prog)
|
code, output = run_dialog(args, dialog_prog)
|
||||||
|
return output if code == 0 else None
|
||||||
|
finally:
|
||||||
import os
|
import os
|
||||||
os.unlink(temp_path)
|
os.unlink(temp_path)
|
||||||
|
|
||||||
return output if code == 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
|
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
|
||||||
"""
|
"""Show a yes/no dialog."""
|
||||||
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
|
|
||||||
"""
|
|
||||||
args = ["--title", title, "--yesno", text, "10", "60"]
|
args = ["--title", title, "--yesno", text, "10", "60"]
|
||||||
code, _ = run_dialog(args, dialog_prog)
|
code, _ = run_dialog(args, dialog_prog)
|
||||||
return code == 0
|
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"):
|
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
|
||||||
"""Show a message box."""
|
"""Show a message box."""
|
||||||
args = ["--title", title, "--msgbox", text, "10", "60"]
|
args = ["--title", title, "--msgbox", text, "15", "70"]
|
||||||
run_dialog(args, dialog_prog)
|
run_dialog(args, dialog_prog)
|
||||||
|
|
||||||
|
|
||||||
def create_tool_form(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[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."""
|
||||||
Show form for creating/editing a tool.
|
args = ["--title", title, "--mixedform",
|
||||||
|
"Tab: next field | Enter: submit | Esc: cancel",
|
||||||
|
str(height), "75", "0"]
|
||||||
|
|
||||||
Args:
|
field_names = list(fields.keys())
|
||||||
dialog_prog: Dialog program to use
|
y = 1
|
||||||
existing: Existing tool to edit (None for new)
|
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
|
||||||
|
|
||||||
Returns:
|
code, output = run_dialog(args, dialog_prog)
|
||||||
Tool object or None if cancelled
|
|
||||||
"""
|
if code != 0:
|
||||||
# Get basic info
|
return None
|
||||||
name = show_input(
|
|
||||||
"Tool Name",
|
values = output.split('\n')
|
||||||
"Enter tool name (used as command):",
|
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 "",
|
existing.name if existing else "",
|
||||||
dialog_prog
|
2 if existing else 0 # readonly if editing
|
||||||
)
|
),
|
||||||
if not name:
|
"command": (
|
||||||
return None
|
"Command:",
|
||||||
|
existing.command if existing else "",
|
||||||
# Check if name already exists (for new tools)
|
0
|
||||||
if not existing and tool_exists(name):
|
),
|
||||||
if not show_yesno("Overwrite?", f"Tool '{name}' already exists. Overwrite?", dialog_prog):
|
"description": (
|
||||||
return None
|
"Description:",
|
||||||
|
|
||||||
description = show_input(
|
|
||||||
"Description",
|
|
||||||
"Enter tool description:",
|
|
||||||
existing.description if existing else "",
|
existing.description if existing else "",
|
||||||
dialog_prog
|
0
|
||||||
)
|
),
|
||||||
if description is None:
|
}
|
||||||
|
|
||||||
|
result = show_mixed_form(title, fields, dialog_prog, height=12)
|
||||||
|
|
||||||
|
if not result:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
provider = show_input(
|
name = result["name"].strip()
|
||||||
"Provider",
|
command = result["command"].strip()
|
||||||
"Enter AI provider command (e.g., codex, claude, gemini):",
|
|
||||||
existing.provider if existing else "codex",
|
if not name:
|
||||||
dialog_prog
|
show_message("Error", "Provider name is required.", dialog_prog)
|
||||||
)
|
|
||||||
if not provider:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
provider_args = show_input(
|
if not command:
|
||||||
"Provider Args",
|
show_message("Error", "Command is required.", dialog_prog)
|
||||||
"Enter provider arguments (e.g., -p):",
|
|
||||||
existing.provider_args if existing else "-p",
|
|
||||||
dialog_prog
|
|
||||||
)
|
|
||||||
if provider_args is None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Edit prompt
|
return Provider(
|
||||||
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(
|
|
||||||
name=name,
|
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,
|
prompt=prompt,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
provider_args=provider_args,
|
output_var=output_var.strip()
|
||||||
inputs=inputs,
|
|
||||||
post_process=existing.post_process if existing else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def ui_list_tools(dialog_prog: str):
|
||||||
"""Show list of tools."""
|
"""Show list of tools."""
|
||||||
tools = list_tools()
|
tools = list_tools()
|
||||||
|
|
@ -208,17 +637,42 @@ def ui_list_tools(dialog_prog: str):
|
||||||
tool = load_tool(name)
|
tool = load_tool(name)
|
||||||
if tool:
|
if tool:
|
||||||
text += f" {name}: {tool.description or 'No description'}\n"
|
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)
|
show_message("Tools", text, dialog_prog)
|
||||||
|
|
||||||
|
|
||||||
def ui_create_tool(dialog_prog: str):
|
def ui_create_tool(dialog_prog: str):
|
||||||
"""Create a new tool."""
|
"""Create a new tool."""
|
||||||
tool = create_tool_form(dialog_prog)
|
tool = tool_builder(dialog_prog)
|
||||||
if tool:
|
if tool:
|
||||||
path = save_tool(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):
|
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)
|
show_message("Edit Tool", "No tools found.", dialog_prog)
|
||||||
return
|
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)
|
selected = show_menu("Select Tool to Edit", choices, dialog_prog)
|
||||||
|
|
||||||
if selected:
|
if selected:
|
||||||
existing = load_tool(selected)
|
existing = load_tool(selected)
|
||||||
if existing:
|
if existing:
|
||||||
tool = create_tool_form(dialog_prog, existing)
|
tool = tool_builder(dialog_prog, existing)
|
||||||
if tool:
|
if tool:
|
||||||
save_tool(tool)
|
save_tool(tool)
|
||||||
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
|
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)
|
show_message("Delete Tool", "No tools found.", dialog_prog)
|
||||||
return
|
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)
|
selected = show_menu("Select Tool to Delete", choices, dialog_prog)
|
||||||
|
|
||||||
if selected:
|
if selected:
|
||||||
|
|
@ -265,13 +729,17 @@ def ui_test_tool(dialog_prog: str):
|
||||||
show_message("Test Tool", "No tools found.", dialog_prog)
|
show_message("Test Tool", "No tools found.", dialog_prog)
|
||||||
return
|
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)
|
selected = show_menu("Select Tool to Test", choices, dialog_prog)
|
||||||
|
|
||||||
if selected:
|
if selected:
|
||||||
tool = load_tool(selected)
|
tool = load_tool(selected)
|
||||||
if tool:
|
if tool:
|
||||||
# Get test input
|
|
||||||
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
|
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
|
||||||
if test_input:
|
if test_input:
|
||||||
from .runner import run_tool
|
from .runner import run_tool
|
||||||
|
|
@ -284,11 +752,10 @@ def ui_test_tool(dialog_prog: str):
|
||||||
show_prompt=False,
|
show_prompt=False,
|
||||||
verbose=False
|
verbose=False
|
||||||
)
|
)
|
||||||
show_message(
|
result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}"
|
||||||
"Test Result",
|
if len(output) > 1000:
|
||||||
f"Exit code: {code}\n\n--- Output ---\n{output[:500]}",
|
result_text += "\n... (truncated)"
|
||||||
dialog_prog
|
show_message("Test Result", result_text, dialog_prog)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main_menu(dialog_prog: str):
|
def main_menu(dialog_prog: str):
|
||||||
|
|
@ -302,6 +769,7 @@ def main_menu(dialog_prog: str):
|
||||||
("edit", "Edit existing tool"),
|
("edit", "Edit existing tool"),
|
||||||
("delete", "Delete tool"),
|
("delete", "Delete tool"),
|
||||||
("test", "Test tool (mock provider)"),
|
("test", "Test tool (mock provider)"),
|
||||||
|
("providers", "Manage providers"),
|
||||||
("exit", "Exit"),
|
("exit", "Exit"),
|
||||||
],
|
],
|
||||||
dialog_prog
|
dialog_prog
|
||||||
|
|
@ -319,15 +787,33 @@ def main_menu(dialog_prog: str):
|
||||||
ui_delete_tool(dialog_prog)
|
ui_delete_tool(dialog_prog)
|
||||||
elif choice == "test":
|
elif choice == "test":
|
||||||
ui_test_tool(dialog_prog)
|
ui_test_tool(dialog_prog)
|
||||||
|
elif choice == "providers":
|
||||||
|
ui_manage_providers(dialog_prog)
|
||||||
|
|
||||||
|
|
||||||
def run_ui():
|
def run_ui():
|
||||||
"""Entry point for the 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()
|
dialog_prog = check_dialog()
|
||||||
|
|
||||||
if not dialog_prog:
|
if not dialog_prog:
|
||||||
print("Error: Neither 'dialog' nor 'whiptail' found.", file=sys.stderr)
|
print("Error: No TUI library found.", file=sys.stderr)
|
||||||
print("Install with: sudo apt install dialog", 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)
|
sys.exit(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -335,7 +821,6 @@ def run_ui():
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
# Clear screen on exit
|
|
||||||
subprocess.run(["clear"], check=False)
|
subprocess.run(["clear"], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,706 @@
|
||||||
|
"""BIOS-style TUI for SmartTools using snack (python3-newt)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
# Ensure system packages are accessible
|
||||||
|
if '/usr/lib/python3/dist-packages' not in sys.path:
|
||||||
|
sys.path.insert(0, '/usr/lib/python3/dist-packages')
|
||||||
|
|
||||||
|
import snack
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
|
from .tool import (
|
||||||
|
Tool, ToolArgument, PromptStep, CodeStep, Step,
|
||||||
|
list_tools, load_tool, save_tool, delete_tool, tool_exists
|
||||||
|
)
|
||||||
|
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
|
||||||
|
|
||||||
|
|
||||||
|
class SmartToolsUI:
|
||||||
|
"""BIOS-style UI for SmartTools."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.screen = None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the UI."""
|
||||||
|
self.screen = snack.SnackScreen()
|
||||||
|
# Enable mouse support
|
||||||
|
self.screen.pushHelpLine(" Tab/Arrow:Navigate | Enter:Select | Mouse:Click | Esc:Back ")
|
||||||
|
try:
|
||||||
|
# Enable mouse - newt supports GPM and xterm mouse
|
||||||
|
import os
|
||||||
|
os.environ.setdefault('NEWT_MONO', '0')
|
||||||
|
self.main_menu()
|
||||||
|
finally:
|
||||||
|
self.screen.finish()
|
||||||
|
|
||||||
|
def main_menu(self):
|
||||||
|
"""Show the main menu."""
|
||||||
|
while True:
|
||||||
|
items = [
|
||||||
|
("Create New Tool", "create"),
|
||||||
|
("Edit Tool", "edit"),
|
||||||
|
("Delete Tool", "delete"),
|
||||||
|
("List Tools", "list"),
|
||||||
|
("Test Tool", "test"),
|
||||||
|
("Manage Providers", "providers"),
|
||||||
|
("Exit", "exit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
listbox = snack.Listbox(height=7, width=30, returnExit=1)
|
||||||
|
for label, value in items:
|
||||||
|
listbox.append(label, value)
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "SmartTools Manager", 1, 1)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
selected = listbox.current()
|
||||||
|
|
||||||
|
if selected == "exit" or result == "ESC":
|
||||||
|
break
|
||||||
|
elif selected == "create":
|
||||||
|
self.tool_builder(None)
|
||||||
|
elif selected == "edit":
|
||||||
|
self.select_and_edit_tool()
|
||||||
|
elif selected == "delete":
|
||||||
|
self.select_and_delete_tool()
|
||||||
|
elif selected == "list":
|
||||||
|
self.show_tools_list()
|
||||||
|
elif selected == "test":
|
||||||
|
self.select_and_test_tool()
|
||||||
|
elif selected == "providers":
|
||||||
|
self.manage_providers()
|
||||||
|
|
||||||
|
def message_box(self, title: str, message: str):
|
||||||
|
"""Show a message box."""
|
||||||
|
snack.ButtonChoiceWindow(self.screen, title, message, ["OK"])
|
||||||
|
|
||||||
|
def yes_no(self, title: str, message: str) -> bool:
|
||||||
|
"""Show a yes/no dialog."""
|
||||||
|
result = snack.ButtonChoiceWindow(self.screen, title, message, ["Yes", "No"])
|
||||||
|
return result == "yes"
|
||||||
|
|
||||||
|
def input_box(self, title: str, prompt: str, initial: str = "", width: int = 40) -> Optional[str]:
|
||||||
|
"""Show an input dialog."""
|
||||||
|
entry = snack.Entry(width, initial)
|
||||||
|
grid = snack.GridForm(self.screen, title, 1, 3)
|
||||||
|
grid.add(snack.Label(prompt), 0, 0)
|
||||||
|
grid.add(entry, 0, 1, padding=(0, 1, 0, 1))
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 2)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
return entry.value()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def text_edit(self, title: str, initial: str = "", width: int = 60, height: int = 10) -> Optional[str]:
|
||||||
|
"""Show a multi-line text editor."""
|
||||||
|
text = snack.Textbox(width, height, initial, scroll=1, wrap=1)
|
||||||
|
|
||||||
|
# snack doesn't have a true multi-line editor, so we use Entry for now
|
||||||
|
# For multi-line, we'll use a workaround with a simple entry
|
||||||
|
entry = snack.Entry(width, initial, scroll=1)
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, title, 1, 2)
|
||||||
|
grid.add(entry, 0, 0, padding=(0, 0, 0, 1))
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
return entry.value()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def select_provider(self) -> Optional[str]:
|
||||||
|
"""Show provider selection dialog."""
|
||||||
|
providers = load_providers()
|
||||||
|
|
||||||
|
listbox = snack.Listbox(height=min(len(providers) + 1, 8), width=50, returnExit=1)
|
||||||
|
for p in providers:
|
||||||
|
listbox.append(f"{p.name}: {p.command}", p.name)
|
||||||
|
listbox.append("[ + Add New Provider ]", "__new__")
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "Select Provider", 1, 2)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
|
||||||
|
return None
|
||||||
|
|
||||||
|
selected = listbox.current()
|
||||||
|
|
||||||
|
if selected == "__new__":
|
||||||
|
provider = self.add_provider_dialog()
|
||||||
|
if provider:
|
||||||
|
add_provider(provider)
|
||||||
|
return provider.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def add_provider_dialog(self) -> Optional[Provider]:
|
||||||
|
"""Dialog to add a new provider."""
|
||||||
|
name_entry = snack.Entry(30, "")
|
||||||
|
cmd_entry = snack.Entry(40, "")
|
||||||
|
desc_entry = snack.Entry(40, "")
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "Add Provider", 2, 4)
|
||||||
|
grid.add(snack.Label("Name:"), 0, 0, anchorLeft=1)
|
||||||
|
grid.add(name_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||||
|
grid.add(snack.Label("Command:"), 0, 1, anchorLeft=1)
|
||||||
|
grid.add(cmd_entry, 1, 1, padding=(1, 0, 0, 0))
|
||||||
|
grid.add(snack.Label("Description:"), 0, 2, anchorLeft=1)
|
||||||
|
grid.add(desc_entry, 1, 2, padding=(1, 0, 0, 0))
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 3, growx=1, growy=1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
name = name_entry.value().strip()
|
||||||
|
cmd = cmd_entry.value().strip()
|
||||||
|
if name and cmd:
|
||||||
|
return Provider(name=name, command=cmd, description=desc_entry.value().strip())
|
||||||
|
self.message_box("Error", "Name and command are required.")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_argument_dialog(self, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
|
||||||
|
"""Dialog to add/edit an argument."""
|
||||||
|
flag_entry = snack.Entry(20, existing.flag if existing else "--")
|
||||||
|
var_entry = snack.Entry(20, existing.variable if existing else "")
|
||||||
|
default_entry = snack.Entry(20, existing.default or "" if existing else "")
|
||||||
|
desc_entry = snack.Entry(40, existing.description if existing else "")
|
||||||
|
|
||||||
|
title = "Edit Argument" if existing else "Add Argument"
|
||||||
|
grid = snack.GridForm(self.screen, title, 2, 5)
|
||||||
|
grid.add(snack.Label("Flag:"), 0, 0, anchorLeft=1)
|
||||||
|
grid.add(flag_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||||
|
grid.add(snack.Label("Variable:"), 0, 1, anchorLeft=1)
|
||||||
|
grid.add(var_entry, 1, 1, padding=(1, 0, 0, 0))
|
||||||
|
grid.add(snack.Label("Default:"), 0, 2, anchorLeft=1)
|
||||||
|
grid.add(default_entry, 1, 2, padding=(1, 0, 0, 0))
|
||||||
|
grid.add(snack.Label("Description:"), 0, 3, anchorLeft=1)
|
||||||
|
grid.add(desc_entry, 1, 3, padding=(1, 0, 0, 0))
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 4, growx=1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
flag = flag_entry.value().strip()
|
||||||
|
var = var_entry.value().strip()
|
||||||
|
|
||||||
|
if not flag:
|
||||||
|
self.message_box("Error", "Flag is required.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not var:
|
||||||
|
var = flag.lstrip("-").replace("-", "_")
|
||||||
|
|
||||||
|
return ToolArgument(
|
||||||
|
flag=flag,
|
||||||
|
variable=var,
|
||||||
|
default=default_entry.value().strip() or None,
|
||||||
|
description=desc_entry.value().strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_step_dialog(self, tool: Tool, existing_step: Optional[Step] = None, step_idx: int = -1) -> Optional[Step]:
|
||||||
|
"""Dialog to choose and add a step."""
|
||||||
|
if existing_step:
|
||||||
|
# Edit existing step
|
||||||
|
if isinstance(existing_step, PromptStep):
|
||||||
|
return self.add_prompt_dialog(tool, existing_step, step_idx)
|
||||||
|
else:
|
||||||
|
return self.add_code_dialog(tool, existing_step, step_idx)
|
||||||
|
|
||||||
|
# Choose step type
|
||||||
|
listbox = snack.Listbox(height=2, width=30, returnExit=1)
|
||||||
|
listbox.append("Prompt (AI call)", "prompt")
|
||||||
|
listbox.append("Code (Python)", "code")
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "Add Step", 1, 2)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
|
||||||
|
return None
|
||||||
|
|
||||||
|
step_type = listbox.current()
|
||||||
|
|
||||||
|
if step_type == "prompt":
|
||||||
|
return self.add_prompt_dialog(tool, None, step_idx)
|
||||||
|
else:
|
||||||
|
return self.add_code_dialog(tool, None, step_idx)
|
||||||
|
|
||||||
|
def get_available_variables(self, tool: Tool, up_to_step: int = -1) -> List[str]:
|
||||||
|
"""Get available variables at a point in the tool."""
|
||||||
|
variables = ["input"]
|
||||||
|
for arg in tool.arguments:
|
||||||
|
variables.append(arg.variable)
|
||||||
|
if up_to_step == -1:
|
||||||
|
up_to_step = len(tool.steps)
|
||||||
|
for i, step in enumerate(tool.steps):
|
||||||
|
if i >= up_to_step:
|
||||||
|
break
|
||||||
|
variables.append(step.output_var)
|
||||||
|
return variables
|
||||||
|
|
||||||
|
def add_prompt_dialog(self, tool: Tool, existing: Optional[PromptStep] = None, step_idx: int = -1) -> Optional[PromptStep]:
|
||||||
|
"""Dialog to add/edit a prompt step."""
|
||||||
|
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
|
||||||
|
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
|
||||||
|
|
||||||
|
# Provider selection first
|
||||||
|
provider = self.select_provider()
|
||||||
|
if not provider:
|
||||||
|
provider = existing.provider if existing else "mock"
|
||||||
|
|
||||||
|
prompt_entry = snack.Entry(60, existing.prompt if existing else f"Process this:\n\n{{input}}", scroll=1)
|
||||||
|
output_entry = snack.Entry(20, existing.output_var if existing else "result")
|
||||||
|
|
||||||
|
title = "Edit Prompt Step" if existing else "Add Prompt Step"
|
||||||
|
grid = snack.GridForm(self.screen, title, 2, 4)
|
||||||
|
|
||||||
|
grid.add(snack.Label(f"Provider: {provider}"), 0, 0, anchorLeft=1, growx=1)
|
||||||
|
grid.add(snack.Label(""), 1, 0)
|
||||||
|
|
||||||
|
grid.add(snack.Label(f"Prompt ({var_help}):"), 0, 1, anchorLeft=1, growx=1)
|
||||||
|
grid.add(snack.Label(""), 1, 1)
|
||||||
|
|
||||||
|
grid.add(prompt_entry, 0, 2, growx=1)
|
||||||
|
grid.add(snack.Label(""), 1, 2)
|
||||||
|
|
||||||
|
sub_grid = snack.Grid(2, 1)
|
||||||
|
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
|
||||||
|
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||||
|
grid.add(sub_grid, 0, 3, anchorLeft=1)
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 1, 3)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
prompt = prompt_entry.value().strip()
|
||||||
|
output_var = output_entry.value().strip()
|
||||||
|
|
||||||
|
if not prompt:
|
||||||
|
self.message_box("Error", "Prompt is required.")
|
||||||
|
return None
|
||||||
|
if not output_var:
|
||||||
|
output_var = "result"
|
||||||
|
|
||||||
|
return PromptStep(prompt=prompt, provider=provider, output_var=output_var)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_code_dialog(self, tool: Tool, existing: Optional[CodeStep] = None, step_idx: int = -1) -> Optional[CodeStep]:
|
||||||
|
"""Dialog to add/edit a code step."""
|
||||||
|
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
|
||||||
|
var_help = "Variables: " + ", ".join(available)
|
||||||
|
|
||||||
|
default_code = existing.code if existing else "# Set 'result' for output\nresult = input.upper()"
|
||||||
|
code_entry = snack.Entry(60, default_code, scroll=1)
|
||||||
|
output_entry = snack.Entry(20, existing.output_var if existing else "processed")
|
||||||
|
|
||||||
|
title = "Edit Code Step" if existing else "Add Code Step"
|
||||||
|
grid = snack.GridForm(self.screen, title, 2, 4)
|
||||||
|
|
||||||
|
grid.add(snack.Label(f"Python Code ({var_help}):"), 0, 0, anchorLeft=1, growx=1)
|
||||||
|
grid.add(snack.Label("Set 'result' variable for output"), 1, 0)
|
||||||
|
|
||||||
|
grid.add(code_entry, 0, 1, growx=1)
|
||||||
|
grid.add(snack.Label(""), 1, 1)
|
||||||
|
|
||||||
|
sub_grid = snack.Grid(2, 1)
|
||||||
|
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
|
||||||
|
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||||
|
grid.add(sub_grid, 0, 2, anchorLeft=1)
|
||||||
|
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 1, 2)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
code = code_entry.value().strip()
|
||||||
|
output_var = output_entry.value().strip()
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
self.message_box("Error", "Code is required.")
|
||||||
|
return None
|
||||||
|
if not output_var:
|
||||||
|
output_var = "processed"
|
||||||
|
|
||||||
|
return CodeStep(code=code, output_var=output_var)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def tool_builder(self, existing: Optional[Tool] = None) -> Optional[Tool]:
|
||||||
|
"""Main tool builder - BIOS-style unified form."""
|
||||||
|
is_edit = existing is not None
|
||||||
|
|
||||||
|
# Initialize tool
|
||||||
|
if existing:
|
||||||
|
tool = Tool(
|
||||||
|
name=existing.name,
|
||||||
|
description=existing.description,
|
||||||
|
arguments=list(existing.arguments),
|
||||||
|
steps=list(existing.steps),
|
||||||
|
output=existing.output
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Create form elements
|
||||||
|
name_entry = snack.Entry(25, tool.name, scroll=0)
|
||||||
|
desc_entry = snack.Entry(25, tool.description, scroll=1)
|
||||||
|
output_entry = snack.Entry(25, tool.output, scroll=1)
|
||||||
|
|
||||||
|
# Arguments listbox
|
||||||
|
args_listbox = snack.Listbox(height=4, width=35, returnExit=0, scroll=1)
|
||||||
|
for i, arg in enumerate(tool.arguments):
|
||||||
|
args_listbox.append(f"{arg.flag} -> {{{arg.variable}}}", i)
|
||||||
|
args_listbox.append("[ + Add ]", "add")
|
||||||
|
|
||||||
|
# Steps listbox
|
||||||
|
steps_listbox = snack.Listbox(height=5, width=35, returnExit=0, scroll=1)
|
||||||
|
for i, step in enumerate(tool.steps):
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
steps_listbox.append(f"P:{step.provider} -> {{{step.output_var}}}", i)
|
||||||
|
else:
|
||||||
|
steps_listbox.append(f"C: -> {{{step.output_var}}}", i)
|
||||||
|
steps_listbox.append("[ + Add ]", "add")
|
||||||
|
|
||||||
|
# Build the grid layout using nested grids for better control
|
||||||
|
title = f"Edit Tool: {tool.name}" if is_edit and tool.name else "New Tool"
|
||||||
|
|
||||||
|
# Left column grid: Name, Description, Output
|
||||||
|
left_grid = snack.Grid(1, 6)
|
||||||
|
left_grid.setField(snack.Label("Name:"), 0, 0, anchorLeft=1)
|
||||||
|
left_grid.setField(name_entry, 0, 1, anchorLeft=1)
|
||||||
|
left_grid.setField(snack.Label("Description:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
|
||||||
|
left_grid.setField(desc_entry, 0, 3, anchorLeft=1)
|
||||||
|
left_grid.setField(snack.Label("Output:"), 0, 4, anchorLeft=1, padding=(0, 1, 0, 0))
|
||||||
|
left_grid.setField(output_entry, 0, 5, anchorLeft=1)
|
||||||
|
|
||||||
|
# Right column grid: Arguments and Steps
|
||||||
|
right_grid = snack.Grid(1, 4)
|
||||||
|
right_grid.setField(snack.Label("Arguments:"), 0, 0, anchorLeft=1)
|
||||||
|
right_grid.setField(args_listbox, 0, 1, anchorLeft=1)
|
||||||
|
right_grid.setField(snack.Label("Execution Steps:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
|
||||||
|
right_grid.setField(steps_listbox, 0, 3, anchorLeft=1)
|
||||||
|
|
||||||
|
# Main grid
|
||||||
|
grid = snack.GridForm(self.screen, title, 2, 2)
|
||||||
|
grid.add(left_grid, 0, 0, anchorTop=1, padding=(0, 0, 2, 0))
|
||||||
|
grid.add(right_grid, 1, 0, anchorTop=1)
|
||||||
|
|
||||||
|
# Buttons at bottom
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("Save", "save"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 1, growx=1)
|
||||||
|
|
||||||
|
# Handle hotkeys for listbox interaction
|
||||||
|
form = grid.form
|
||||||
|
|
||||||
|
while True:
|
||||||
|
result = form.run()
|
||||||
|
|
||||||
|
# Update tool from entries
|
||||||
|
if not is_edit:
|
||||||
|
tool.name = name_entry.value().strip()
|
||||||
|
tool.description = desc_entry.value().strip()
|
||||||
|
tool.output = output_entry.value().strip()
|
||||||
|
|
||||||
|
# Check what was activated
|
||||||
|
if result == args_listbox:
|
||||||
|
selected = args_listbox.current()
|
||||||
|
if selected == "add":
|
||||||
|
new_arg = self.add_argument_dialog()
|
||||||
|
if new_arg:
|
||||||
|
tool.arguments.append(new_arg)
|
||||||
|
break # Refresh form
|
||||||
|
elif isinstance(selected, int):
|
||||||
|
# Edit/delete existing argument
|
||||||
|
action = self.arg_action_menu(tool.arguments[selected])
|
||||||
|
if action == "edit":
|
||||||
|
updated = self.add_argument_dialog(tool.arguments[selected])
|
||||||
|
if updated:
|
||||||
|
tool.arguments[selected] = updated
|
||||||
|
elif action == "delete":
|
||||||
|
tool.arguments.pop(selected)
|
||||||
|
break # Refresh form
|
||||||
|
|
||||||
|
elif result == steps_listbox:
|
||||||
|
selected = steps_listbox.current()
|
||||||
|
if selected == "add":
|
||||||
|
new_step = self.add_step_dialog(tool)
|
||||||
|
if new_step:
|
||||||
|
tool.steps.append(new_step)
|
||||||
|
break # Refresh form
|
||||||
|
elif isinstance(selected, int):
|
||||||
|
# Edit/delete existing step
|
||||||
|
action = self.step_action_menu(tool.steps[selected], selected, len(tool.steps))
|
||||||
|
if action == "edit":
|
||||||
|
updated = self.add_step_dialog(tool, tool.steps[selected], selected)
|
||||||
|
if updated:
|
||||||
|
tool.steps[selected] = updated
|
||||||
|
elif action == "delete":
|
||||||
|
tool.steps.pop(selected)
|
||||||
|
elif action == "move_up" and selected > 0:
|
||||||
|
tool.steps[selected], tool.steps[selected-1] = tool.steps[selected-1], tool.steps[selected]
|
||||||
|
elif action == "move_down" and selected < len(tool.steps) - 1:
|
||||||
|
tool.steps[selected], tool.steps[selected+1] = tool.steps[selected+1], tool.steps[selected]
|
||||||
|
break # Refresh form
|
||||||
|
|
||||||
|
elif buttons.buttonPressed(result) == "save":
|
||||||
|
if not tool.name:
|
||||||
|
self.message_box("Error", "Tool name is required.")
|
||||||
|
break
|
||||||
|
if not is_edit and tool_exists(tool.name):
|
||||||
|
if not self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?"):
|
||||||
|
break
|
||||||
|
self.screen.popWindow()
|
||||||
|
save_tool(tool)
|
||||||
|
self.message_box("Success", f"Tool '{tool.name}' saved!")
|
||||||
|
return tool
|
||||||
|
|
||||||
|
elif buttons.buttonPressed(result) == "cancel" or result == "ESC":
|
||||||
|
self.screen.popWindow()
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Tab between fields, continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.screen.popWindow()
|
||||||
|
|
||||||
|
def arg_action_menu(self, arg: ToolArgument) -> Optional[str]:
|
||||||
|
"""Show action menu for an argument."""
|
||||||
|
listbox = snack.Listbox(height=3, width=20, returnExit=1)
|
||||||
|
listbox.append("Edit", "edit")
|
||||||
|
listbox.append("Delete", "delete")
|
||||||
|
listbox.append("Cancel", "cancel")
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, f"Argument: {arg.flag}", 1, 1)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
|
||||||
|
grid.runOnce()
|
||||||
|
return listbox.current() if listbox.current() != "cancel" else None
|
||||||
|
|
||||||
|
def step_action_menu(self, step: Step, idx: int, total: int) -> Optional[str]:
|
||||||
|
"""Show action menu for a step."""
|
||||||
|
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
|
||||||
|
|
||||||
|
items = [("Edit", "edit")]
|
||||||
|
if idx > 0:
|
||||||
|
items.append(("Move Up", "move_up"))
|
||||||
|
if idx < total - 1:
|
||||||
|
items.append(("Move Down", "move_down"))
|
||||||
|
items.append(("Delete", "delete"))
|
||||||
|
items.append(("Cancel", "cancel"))
|
||||||
|
|
||||||
|
listbox = snack.Listbox(height=len(items), width=20, returnExit=1)
|
||||||
|
for label, value in items:
|
||||||
|
listbox.append(label, value)
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, f"Step {idx+1}: {step_type}", 1, 1)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
|
||||||
|
grid.runOnce()
|
||||||
|
return listbox.current() if listbox.current() != "cancel" else None
|
||||||
|
|
||||||
|
def select_and_edit_tool(self):
|
||||||
|
"""Select a tool to edit."""
|
||||||
|
tools = list_tools()
|
||||||
|
if not tools:
|
||||||
|
self.message_box("Edit Tool", "No tools found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
|
||||||
|
for name in tools:
|
||||||
|
tool = load_tool(name)
|
||||||
|
desc = tool.description[:30] if tool and tool.description else "No description"
|
||||||
|
listbox.append(f"{name}: {desc}", name)
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "Select Tool to Edit", 1, 2)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
selected = listbox.current()
|
||||||
|
tool = load_tool(selected)
|
||||||
|
if tool:
|
||||||
|
self.tool_builder(tool)
|
||||||
|
|
||||||
|
def select_and_delete_tool(self):
|
||||||
|
"""Select a tool to delete."""
|
||||||
|
tools = list_tools()
|
||||||
|
if not tools:
|
||||||
|
self.message_box("Delete Tool", "No tools found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
|
||||||
|
for name in tools:
|
||||||
|
listbox.append(name, name)
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "Select Tool to Delete", 1, 2)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("Delete", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
selected = listbox.current()
|
||||||
|
if self.yes_no("Confirm", f"Delete tool '{selected}'?"):
|
||||||
|
if delete_tool(selected):
|
||||||
|
self.message_box("Deleted", f"Tool '{selected}' deleted.")
|
||||||
|
|
||||||
|
def show_tools_list(self):
|
||||||
|
"""Show list of all tools."""
|
||||||
|
tools = list_tools()
|
||||||
|
if not tools:
|
||||||
|
self.message_box("Tools", "No tools found.\n\nCreate one from the main menu.")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = ""
|
||||||
|
for name in tools:
|
||||||
|
tool = load_tool(name)
|
||||||
|
if tool:
|
||||||
|
text += f"{name}\n"
|
||||||
|
text += f" {tool.description or 'No description'}\n"
|
||||||
|
if tool.arguments:
|
||||||
|
args = ", ".join(a.flag for a in tool.arguments)
|
||||||
|
text += f" Args: {args}\n"
|
||||||
|
if tool.steps:
|
||||||
|
steps = []
|
||||||
|
for s in tool.steps:
|
||||||
|
if isinstance(s, PromptStep):
|
||||||
|
steps.append(f"P:{s.provider}")
|
||||||
|
else:
|
||||||
|
steps.append("C")
|
||||||
|
text += f" Steps: {' -> '.join(steps)}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
snack.ButtonChoiceWindow(self.screen, "Available Tools", text.strip(), ["OK"])
|
||||||
|
|
||||||
|
def select_and_test_tool(self):
|
||||||
|
"""Select a tool to test."""
|
||||||
|
tools = list_tools()
|
||||||
|
if not tools:
|
||||||
|
self.message_box("Test Tool", "No tools found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
|
||||||
|
for name in tools:
|
||||||
|
listbox.append(name, name)
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "Select Tool to Test", 1, 2)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
buttons = snack.ButtonBar(self.screen, [("Test", "ok"), ("Cancel", "cancel")])
|
||||||
|
grid.add(buttons, 0, 1)
|
||||||
|
|
||||||
|
result = grid.runOnce()
|
||||||
|
|
||||||
|
if buttons.buttonPressed(result) == "ok":
|
||||||
|
selected = listbox.current()
|
||||||
|
tool = load_tool(selected)
|
||||||
|
if tool:
|
||||||
|
test_input = self.input_box("Test Input", "Enter test input:", "Hello world")
|
||||||
|
if test_input:
|
||||||
|
from .runner import run_tool
|
||||||
|
output, code = run_tool(
|
||||||
|
tool=tool,
|
||||||
|
input_text=test_input,
|
||||||
|
custom_args={},
|
||||||
|
provider_override="mock",
|
||||||
|
dry_run=False,
|
||||||
|
show_prompt=False,
|
||||||
|
verbose=False
|
||||||
|
)
|
||||||
|
result_text = f"Exit code: {code}\n\nOutput:\n{output[:500]}"
|
||||||
|
self.message_box("Test Result", result_text)
|
||||||
|
|
||||||
|
def manage_providers(self):
|
||||||
|
"""Manage providers menu."""
|
||||||
|
while True:
|
||||||
|
providers = load_providers()
|
||||||
|
|
||||||
|
listbox = snack.Listbox(height=min(len(providers) + 2, 10), width=50, returnExit=1, scroll=1)
|
||||||
|
for p in providers:
|
||||||
|
listbox.append(f"{p.name}: {p.command}", p.name)
|
||||||
|
listbox.append("[ + Add Provider ]", "__add__")
|
||||||
|
listbox.append("[ <- Back ]", "__back__")
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, "Manage Providers", 1, 1)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
|
||||||
|
grid.runOnce()
|
||||||
|
selected = listbox.current()
|
||||||
|
|
||||||
|
if selected == "__back__":
|
||||||
|
break
|
||||||
|
elif selected == "__add__":
|
||||||
|
provider = self.add_provider_dialog()
|
||||||
|
if provider:
|
||||||
|
add_provider(provider)
|
||||||
|
self.message_box("Success", f"Provider '{provider.name}' added.")
|
||||||
|
else:
|
||||||
|
# Edit or delete
|
||||||
|
provider = get_provider(selected)
|
||||||
|
if provider:
|
||||||
|
action = self.provider_action_menu(provider)
|
||||||
|
if action == "edit":
|
||||||
|
updated = self.add_provider_dialog() # TODO: pass existing
|
||||||
|
if updated:
|
||||||
|
add_provider(updated)
|
||||||
|
elif action == "delete":
|
||||||
|
if self.yes_no("Confirm", f"Delete provider '{selected}'?"):
|
||||||
|
delete_provider(selected)
|
||||||
|
|
||||||
|
def provider_action_menu(self, provider: Provider) -> Optional[str]:
|
||||||
|
"""Show action menu for a provider."""
|
||||||
|
listbox = snack.Listbox(height=3, width=20, returnExit=1)
|
||||||
|
listbox.append("Edit", "edit")
|
||||||
|
listbox.append("Delete", "delete")
|
||||||
|
listbox.append("Cancel", "cancel")
|
||||||
|
|
||||||
|
grid = snack.GridForm(self.screen, f"Provider: {provider.name}", 1, 1)
|
||||||
|
grid.add(listbox, 0, 0)
|
||||||
|
|
||||||
|
grid.runOnce()
|
||||||
|
return listbox.current() if listbox.current() != "cancel" else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_ui():
|
||||||
|
"""Entry point for the snack UI."""
|
||||||
|
ui = SmartToolsUI()
|
||||||
|
ui.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_ui()
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue