From 4c71dbded28bde05632f43ce1ba7628503bb2def Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Dec 2025 12:42:04 -0400 Subject: [PATCH] Add comprehensive documentation and 28 example tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 353 ++++++- docs/DESIGN.md | 370 ++++---- docs/EXAMPLES.md | 960 +++++++++++++++++++ docs/INSTALL.md | 247 +++++ docs/PROVIDERS.md | 312 +++++++ examples/install.py | 342 +++++++ pyproject.toml | 19 +- src/smarttools/cli.py | 158 +++- src/smarttools/providers.py | 177 +++- src/smarttools/runner.py | 226 +++-- src/smarttools/tool.py | 266 ++++-- src/smarttools/ui.py | 781 +++++++++++++--- src/smarttools/ui_snack.py | 706 ++++++++++++++ src/smarttools/ui_urwid.py | 1720 +++++++++++++++++++++++++++++++++++ 14 files changed, 6072 insertions(+), 565 deletions(-) create mode 100644 docs/EXAMPLES.md create mode 100644 docs/INSTALL.md create mode 100644 docs/PROVIDERS.md create mode 100644 examples/install.py create mode 100644 src/smarttools/ui_snack.py create mode 100644 src/smarttools/ui_urwid.py diff --git a/README.md b/README.md index a2a6783..b28ac00 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,334 @@ # SmartTools -A lightweight personal tool builder for AI-powered CLI commands. +**A lightweight personal tool builder for AI-powered CLI commands.** -## What is this? - -SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, prompt, provider), then use it like any Linux command. +Turn any AI model into a Unix-style pipe command. Build once, use forever. ```bash -# Create a summarizer, then use it like any command: -sum -i document.txt -o summary.txt --max 512 +# Fix grammar in any file +echo "teh cat sat on teh mat" | fix-grammar +# Output: The cat sat on the mat + +# Explain errors instantly +cat error.log | explain-error + +# Generate commit messages +git diff --staged | commit-msg + +# Extract data as validated JSON +echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku" +# Output: {"price": 49.99, "sku": "ABC-123"} ``` -## Quick Example +## Why SmartTools? -A tool is just a YAML config: +- **Unix Philosophy** - Tools that do one thing well, composable with pipes +- **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models +- **No Lock-in** - Your tools are YAML files, your prompts are yours +- **Multi-step Pipelines** - Chain AI prompts with Python code for validation +- **12+ Providers Profiled** - We tested them so you don't have to -```yaml -# ~/.smarttools/sum/config.yaml -name: sum -description: "Summarize documents" -prompt: | - Summarize the following text in {max} words or less: - {input} -provider: codex -provider_args: "-p" -inputs: - - name: max - flag: --max - default: 500 -``` - -Then run it: +## Quick Start ```bash -sum -i myfile.txt -o summary.txt -sum -i myfile.txt --dry-run # preview prompt -sum -i myfile.txt --provider mock # test without API +# Install +pip install smarttools + +# Ensure ~/.local/bin is in PATH +export PATH="$HOME/.local/bin:$PATH" + +# Launch the UI +smarttools ui + +# Or create your first tool +smarttools create summarize ``` -## Features - -- **Simple UI** - Create/edit/delete tools with `dialog`-based interface -- **CLI-first** - Tools work like regular Linux commands -- **Provider-agnostic** - Use Codex, Claude, Gemini, or any CLI AI tool -- **Testing built-in** - `--dry-run`, `--show-prompt`, `--provider mock` -- **Minimal** - ~430 lines of Python, minimal dependencies - ## Installation +### From PyPI (Recommended) + ```bash pip install smarttools ``` -## Usage +### From Source ```bash -# Launch UI to manage tools -smarttools - -# Or use CLI: -smarttools list -smarttools create mytool -smarttools edit mytool -smarttools delete mytool -smarttools test mytool +git clone https://github.com/yourusername/smarttools.git +cd smarttools +pip install -e . ``` -## Documentation +### Requirements -See [docs/DESIGN.md](docs/DESIGN.md) for the full design document. +- Python 3.10+ +- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md)) +- Optional: `urwid` for the TUI (`pip install urwid`) + +### Post-Install + +Add to your `~/.bashrc` or `~/.zshrc`: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +Then refresh wrapper scripts: + +```bash +smarttools refresh +``` + +## Usage + +### UI Mode (Recommended for Beginners) + +```bash +smarttools ui +``` + +Navigate with arrow keys, Tab, Enter, and mouse. Create tools visually with the built-in prompt editor. + +### CLI Mode + +```bash +smarttools list # List all tools +smarttools create mytool # Create new tool +smarttools edit mytool # Edit in $EDITOR +smarttools delete mytool # Delete tool +smarttools run mytool # Run a tool +smarttools test mytool # Test with mock provider +smarttools refresh # Update executable wrappers +``` + +### Running Tools + +Once created, tools work like any Unix command: + +```bash +# Direct invocation +mytool -i input.txt -o output.txt + +# Pipe input (most common) +cat file.txt | mytool + +# With arguments +echo "hello" | translate --lang French + +# Preview without calling AI +cat file.txt | mytool --dry-run + +# Test with mock provider +cat file.txt | mytool --provider mock +``` + +## Example Tools + +SmartTools comes with 28 pre-built examples you can install: + +### Text Processing + +| Tool | Description | Example | +|------|-------------|---------| +| `summarize` | Condense documents | `cat article.txt \| summarize` | +| `translate` | Translate text | `echo "Hello" \| translate --lang Spanish` | +| `fix-grammar` | Fix spelling/grammar | `cat draft.txt \| fix-grammar` | +| `simplify` | Rewrite for clarity | `cat legal.txt \| simplify --level "5th grade"` | +| `tone-shift` | Change tone | `cat email.txt \| tone-shift --tone professional` | +| `eli5` | Explain like I'm 5 | `cat quantum.txt \| eli5` | +| `tldr` | One-line summary | `cat readme.txt \| tldr` | +| `expand` | Expand bullet points | `cat notes.txt \| expand` | + +### Developer Tools + +| Tool | Description | Example | +|------|-------------|---------| +| `explain-error` | Explain stack traces | `cat error.log \| explain-error` | +| `explain-code` | Explain what code does | `cat script.py \| explain-code` | +| `review-code` | Quick code review | `cat pr.diff \| review-code --focus security` | +| `gen-tests` | Generate unit tests | `cat module.py \| gen-tests --framework pytest` | +| `docstring` | Add docstrings | `cat functions.py \| docstring` | +| `commit-msg` | Generate commit message | `git diff --staged \| commit-msg` | + +### Data Tools + +| Tool | Description | Example | +|------|-------------|---------| +| `json-extract` | Extract as validated JSON | `cat text.txt \| json-extract --fields "name, email"` | +| `json2csv` | Convert JSON to CSV | `cat data.json \| json2csv` | +| `extract-emails` | Extract email addresses | `cat page.html \| extract-emails` | +| `extract-contacts` | Extract contacts as CSV | `cat notes.txt \| extract-contacts` | +| `sql-from-text` | Natural language to SQL | `echo "get active users" \| sql-from-text` | +| `safe-sql` | SQL with safety checks | `echo "delete old records" \| safe-sql` | +| `parse-log` | Analyze log files | `cat app.log \| parse-log --focus errors` | +| `csv-insights` | Analyze CSV data | `cat sales.csv \| csv-insights --question "trends?"` | + +### Advanced Multi-Step Tools + +| Tool | Description | Pattern | +|------|-------------|---------| +| `log-errors` | Extract & explain errors from huge logs | Codeβ†’AI | +| `diff-focus` | Review only added lines | Codeβ†’AI | +| `changelog` | Git log to formatted changelog | Codeβ†’AIβ†’Code | +| `code-validate` | Generate syntax-checked Python | AIβ†’Code | + +### Install Example Tools + +```bash +# Download and run the example installer +curl -sSL https://raw.githubusercontent.com/yourusername/smarttools/main/examples/install.py | python3 + +# Or manually copy from examples/ +``` + +## Providers + +SmartTools works with any AI CLI tool. We've profiled 12 providers: + +| Provider | Speed | Accuracy | Cost | Best For | +|----------|-------|----------|------|----------| +| `opencode-deepseek` | 13s | 4/4 | Cheap | **Best value** - daily use | +| `opencode-pickle` | 13s | 4/4 | FREE | **Best free** - accurate | +| `claude-haiku` | 14s | 4/4 | Paid | Fast + accurate | +| `codex` | 14s | 4/4 | Paid | Reliable | +| `claude-opus` | 18s | 4/4 | $$$ | Highest quality | +| `gemini` | 91s | 3/4 | Paid | Large docs (1M tokens) | + +See [docs/PROVIDERS.md](docs/PROVIDERS.md) for setup instructions. + +## Tool Anatomy + +A tool is a YAML config file in `~/.smarttools//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 fg :!fix-grammar + +" Explain selected code +vnoremap ec :!explain-code +``` + +## UI Navigation + +| Action | Keys | +|--------|------| +| Cycle sections | `Tab` | +| Go back | `Escape` | +| Select | `Enter` or click | +| Navigate | Arrow keys | +| **Select text** | `Shift` + mouse drag | +| **Copy** | Terminal native (with Shift) | +| **Paste** | `Ctrl+Shift+V` | + +**Tip:** Hold `Shift` while using mouse for terminal-native text selection. ## Philosophy -This is a **personal power tool**. You write the tools, you run the tools, you accept the responsibility. No trust tiers, no sandboxing, no signing - just like any bash script you write. +SmartTools is a **personal power tool**: + +- **You own your tools** - YAML files you can read, edit, share +- **You choose the AI** - Any provider, swap anytime +- **You accept responsibility** - No sandboxing, like any script you write +- **Unix philosophy** - Small tools that compose + +## Contributing + +```bash +git clone https://github.com/yourusername/smarttools.git +cd smarttools +pip install -e ".[dev]" +pytest +``` + +## Support + +If SmartTools saves you time, consider: + +- [GitHub Sponsors](https://github.com/sponsors/yourusername) +- [Buy Me a Coffee](https://buymeacoffee.com/yourusername) ## License -MIT +MIT - Use it, modify it, share it. + +## Links + +- [Installation Guide](docs/INSTALL.md) +- [Provider Setup](docs/PROVIDERS.md) +- [Example Tools](docs/EXAMPLES.md) +- [Design Document](docs/DESIGN.md) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 587ebe7..97aa3af 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,10 +1,10 @@ -# SmartTools - Simple Design +# SmartTools - Design Document > A lightweight personal tool builder for AI-powered CLI commands ## Overview -SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, prompt, provider), then use it like any Linux command. +SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, steps, provider), then use it like any Linux command. **Example:** ```bash @@ -20,44 +20,91 @@ sum -i text.txt -o summary.txt --max 512 ~/.smarttools/ sum/ config.yaml - post.py # optional post-processing script + processed.py # Optional external code file reviewer/ config.yaml translator/ config.yaml ``` -### Minimal config.yaml +### config.yaml Format ```yaml name: sum description: "Summarize documents" -prompt: | - Summarize the following text in {max} words or less: +arguments: + - flag: --max + variable: max + default: "500" + description: "Maximum words in summary" +steps: + - type: prompt + prompt: | + Summarize the following text in {max} words or less: - {input} - -provider: codex -provider_args: "-p" - -# Optional -inputs: - - name: max - flag: --max - default: 500 - -# Optional post-processing script -post_process: post.py + {input} + provider: claude + output_var: response +output: "{response}" ``` -That's it. No trust tiers, no signing, no containers. +### Step Types + +**Prompt Step** - Calls an AI provider: +```yaml +- type: prompt + prompt: "Your prompt template with {variables}" + provider: claude + output_var: response +``` + +**Code Step** - Runs Python code: +```yaml +- type: code + code: | + processed = input.upper() + count = len(processed.split()) + output_var: processed, count + code_file: processed.py # Optional: external file storage +``` + +Steps execute in order. Each step's `output_var` becomes available to subsequent steps. + +### Variables + +- `{input}` - Always available, contains stdin or input file content (empty string if no input) +- `{variable_name}` - From arguments (e.g., `{max}`) +- `{output_var}` - From previous steps (e.g., `{response}`, `{processed}`) + +### Output Variables + +The `output_var` field specifies which Python variable(s) to capture from your code: + +**Single variable:** +```yaml +output_var: processed +``` +```python +processed = input.upper() # This gets captured +``` + +**Multiple variables (comma-separated):** +```yaml +output_var: processed, count, summary +``` +```python +processed = input.upper() +count = len(processed.split()) +summary = f"Processed {count} words" +# All three are captured and available as {processed}, {count}, {summary} +``` ## CLI Interface ### Running Tools ```bash -# Basic usage +# Basic usage (wrapper script in ~/.local/bin) sum -i document.txt -o summary.txt # With custom args @@ -74,14 +121,33 @@ sum -i document.txt --provider mock # Read from stdin, write to stdout cat doc.txt | sum | less + +# Interactive stdin input +sum --stdin + +# No input (empty string) - useful for tools using only arguments +sum --max 100 + +# Or via smarttools run +smarttools run sum -i document.txt ``` +### Input Handling + +| Scenario | Behavior | +|----------|----------| +| Piped stdin | Automatically read (`cat file.txt \| mytool`) | +| `-i file.txt` | Read from file | +| `--stdin` | Interactive input (type then Ctrl+D) | +| No input | Empty string (useful for argument-only tools) | + ### Universal Flags (all tools) | Flag | Short | Description | |------|-------|-------------| -| `--input` | `-i` | Input file (or stdin if omitted) | +| `--input` | `-i` | Input file | | `--output` | `-o` | Output file (or stdout if omitted) | +| `--stdin` | | Read input interactively (type then Ctrl+D) | | `--dry-run` | | Show prompt, don't call AI | | `--show-prompt` | | Call AI but also print prompt to stderr | | `--provider` | `-p` | Override provider (e.g., `--provider mock`) | @@ -96,15 +162,18 @@ smarttools # Or use CLI directly: smarttools list # List all tools -smarttools create sum # Create new tool -smarttools edit sum # Edit existing tool +smarttools create sum # Create new tool (basic) +smarttools edit sum # Edit tool config in $EDITOR smarttools delete sum # Delete tool smarttools test sum # Test with mock provider +smarttools run sum # Run tool for real +smarttools refresh # Refresh all wrapper scripts +smarttools ui # Launch interactive UI ``` -## Lightweight UI +## Terminal UI -A simple terminal UI using `dialog` or `whiptail` (available on most Linux systems). +A BIOS-style terminal UI using `urwid` with full mouse support. ### Main Menu @@ -112,66 +181,84 @@ A simple terminal UI using `dialog` or `whiptail` (available on most Linux syste β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ SmartTools Manager β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ > List Tools β”‚ -β”‚ Create New Tool β”‚ -β”‚ Edit Tool β”‚ -β”‚ Delete Tool β”‚ -β”‚ Test Tool β”‚ -β”‚ Exit β”‚ +β”‚ Tools: β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ sum β”‚ β”‚ +β”‚ β”‚ reviewer β”‚ β”‚ +β”‚ β”‚ translator β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ [Create] [Edit] [Delete] [Test] β”‚ +β”‚ β”‚ +β”‚ Tool Info: β”‚ +β”‚ Name: sum β”‚ +β”‚ Description: Summarize documents β”‚ +β”‚ Arguments: --max β”‚ +β”‚ Steps: PROMPT[claude] -> {response} β”‚ +β”‚ β”‚ +β”‚ [EXIT] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -### Create/Edit Tool Form +### Tool Builder ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Create New Tool β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Name: [sum________________] β”‚ -β”‚ Description: [Summarize documents_] β”‚ -β”‚ Provider: [codex_____________] β”‚ -β”‚ Provider Args: [-p________________] β”‚ -β”‚ β”‚ -β”‚ Prompt: β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Summarize the following text β”‚ β”‚ -β”‚ β”‚ in {max} words or less: β”‚ β”‚ -β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ {input} β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ Custom Arguments: β”‚ -β”‚ --max (default: 500) β”‚ -β”‚ [Add Argument] β”‚ -β”‚ β”‚ -β”‚ Post-process Script: [none______β–Ό] β”‚ -β”‚ β”‚ -β”‚ [Save] [Test] [Cancel] β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ New Tool β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”Œβ”€[ Tool Info ]──────────┐ β”Œβ”€ Arguments ─────────────────┐ β”‚ +β”‚ β”‚ Name: [sum__________] β”‚ β”‚ --max -> {max} β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Desc: [Summarize____] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ [Add] [Edit] [Delete] β”‚ +β”‚ β”‚ Output: [{response}_] β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€ Execution Steps ───────────┐ β”‚ +β”‚ β”‚ P:claude -> {response} β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ [Add] [Edit] [Delete] β”‚ +β”‚ β”‚ +β”‚ [Save] [Cancel] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -### Test Tool +**Navigation:** +- **Tab** - Cycle between sections (Tool Info -> Arguments -> Steps -> Buttons) +- **Arrow keys** - Navigate within lists +- **Enter/Click** - Select/activate +- **Escape** - Cancel/back +- **Shift + mouse** - Terminal-native text selection (bypasses UI mouse handling) +- **Ctrl+Shift+V** - Paste + +The current section's title is highlighted with brackets: `[ Tool Info ]` + +**Copy/Paste tip:** Hold `Shift` while dragging with mouse to select text, then use your terminal's copy function (usually right-click or Ctrl+Shift+C). + +### Code Step Dialog ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Test Tool: sum β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Input: [Select file...] or [Paste] β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€ Final Prompt ───────────────────┐ β”‚ -β”‚ β”‚ Summarize the following text β”‚ β”‚ -β”‚ β”‚ in 500 words or less: β”‚ β”‚ -β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ Lorem ipsum dolor sit amet... β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ Provider: [mock_____β–Ό] β”‚ -β”‚ β”‚ -β”‚ [Run Test] [Copy Command] β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ Add Code Step ─────────────────┐ +β”‚ Variables: input, max, response β”‚ +β”‚ β”‚ +β”‚ File: [processed.py______________] < Load > β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€ Code ─────────────────────────────────────┐ β”‚ +β”‚ β”‚processed = input.upper() β”‚ β”‚ +β”‚ β”‚count = len(processed.split()) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Output var: [processed, count____] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ < OK > < Cancel > β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -"Copy Command" shows: `sum -i test.txt --dry-run` +**Code Step Features:** +- **Multiline editor** - Write multi-line Python code +- **External file storage** - Code is auto-saved to `~/.smarttools//` on OK +- **Load button** - Load code from external file (with confirmation) +- **Multiple output vars** - Capture multiple variables (comma-separated) ## Implementation @@ -181,79 +268,54 @@ A simple terminal UI using `dialog` or `whiptail` (available on most Linux syste smarttools/ __init__.py cli.py # Entry point, argument parsing - ui.py # Dialog-based UI - tool.py # Tool loading/saving + ui.py # UI dispatcher (chooses urwid/snack/dialog) + ui_urwid.py # Urwid-based BIOS-style UI + ui_snack.py # Snack/newt fallback UI + tool.py # Tool loading/saving/wrapper scripts runner.py # Execute tools - providers.py # Provider abstraction (minimal) + providers.py # Provider abstraction ``` -### Provider Abstraction (Simple) +### Provider System -```python -# providers.py -import subprocess +Providers are defined in `~/.smarttools/providers.yaml`: -def call_provider(provider: str, args: str, prompt: str) -> str: - """Call an AI CLI tool with the given prompt.""" - cmd = f"{provider} {args}" - result = subprocess.run( - cmd, - shell=True, - input=prompt, - capture_output=True, - text=True - ) - if result.returncode != 0: - raise RuntimeError(f"Provider failed: {result.stderr}") - return result.stdout - -def mock_provider(prompt: str) -> str: - """Return a mock response for testing.""" - return f"[MOCK RESPONSE]\nReceived prompt of {len(prompt)} chars" +```yaml +providers: + - name: claude + command: "claude -p" + description: "Anthropic Claude" + - name: codex + command: "codex -p" + description: "OpenAI Codex" + - name: gemini + command: "gemini" + description: "Google Gemini" ``` -### Tool Runner (Simple) +The provider command receives the prompt via stdin and outputs to stdout. -```python -# runner.py -import yaml -from pathlib import Path +### Wrapper Scripts -TOOLS_DIR = Path.home() / ".smarttools" - -def load_tool(name: str) -> dict: - config_path = TOOLS_DIR / name / "config.yaml" - return yaml.safe_load(config_path.read_text()) - -def build_prompt(tool: dict, input_text: str, args: dict) -> str: - prompt = tool["prompt"] - prompt = prompt.replace("{input}", input_text) - for key, value in args.items(): - prompt = prompt.replace(f"{{{key}}}", str(value)) - return prompt - -def run_tool(name: str, input_text: str, args: dict, provider_override: str = None) -> str: - tool = load_tool(name) - prompt = build_prompt(tool, input_text, args) - - provider = provider_override or tool["provider"] - if provider == "mock": - return mock_provider(prompt) - - return call_provider(provider, tool.get("provider_args", ""), prompt) -``` - -### Generated Wrapper Script - -When you create a tool, SmartTools generates a wrapper script: +When you save a tool, SmartTools creates a wrapper script in `~/.local/bin/`: ```bash #!/bin/bash -# ~/.smarttools/sum/sum (auto-generated) -exec python3 -m smarttools.run sum "$@" +# SmartTools wrapper for 'sum' +# Auto-generated - do not edit +exec /path/to/python -m smarttools.runner sum "$@" ``` -And symlinks it to `~/.local/bin/sum` so you can call `sum` directly. +The wrapper uses the full Python path to ensure the correct environment is used. + +Use `smarttools refresh` to regenerate all wrapper scripts (e.g., after changing Python environments). + +### Tool Name Validation + +Tool names must: +- Start with a letter or underscore +- Contain only letters, numbers, underscores, and dashes +- Not contain spaces or shell-problematic characters (`/\|&;$`"'<>(){}[]!?*#~`) ## What This Design Doesn't Include @@ -272,33 +334,31 @@ Intentionally omitted (not needed for personal use): ## Dependencies -Minimal: +Required: - Python 3.10+ - PyYAML -- `dialog` or `whiptail` (pre-installed on most Linux) +- urwid (for TUI) -Optional: -- `textual` (if you want a fancier TUI later) - -## File Sizes - -Estimated implementation: -- `cli.py`: ~100 lines -- `ui.py`: ~150 lines -- `tool.py`: ~80 lines -- `runner.py`: ~60 lines -- `providers.py`: ~40 lines - -**Total: ~430 lines of Python** +Optional fallbacks: +- python3-newt/snack (simpler TUI) +- dialog/whiptail (basic TUI) ## Example Workflow 1. Run `smarttools` to open UI -2. Select "Create New Tool" -3. Fill in: name=`sum`, prompt, provider=`codex` -4. Click "Test" to verify with mock provider -5. Click "Save" -6. Exit UI -7. Run `sum -i myfile.txt -o summary.txt` +2. Select "Create" to create a new tool +3. Fill in: name, description, output template +4. Add arguments (e.g., `--max` with default `500`) +5. Add a prompt step with your prompt template and provider +6. Click "Save" +7. Exit UI +8. Run `sum -i myfile.txt -o summary.txt` Done. No containers, no signing, no certification. Just a tool that works. + +## Default Values + +| Step Type | Default Output Var | Default File | +|-----------|-------------------|--------------| +| Prompt | `response` | n/a | +| Code | `processed` | `processed.py` | diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..aab2969 --- /dev/null +++ b/docs/EXAMPLES.md @@ -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` diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..6de8b86 --- /dev/null +++ b/docs/INSTALL.md @@ -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//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 diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 0000000..cdfdb14 --- /dev/null +++ b/docs/PROVIDERS.md @@ -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" | ` +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 +``` diff --git a/examples/install.py b/examples/install.py new file mode 100644 index 0000000..8fdffff --- /dev/null +++ b/examples/install.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index cf07c2e..0cb7a6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,27 +12,38 @@ requires-python = ">=3.10" authors = [ {name = "Rob"} ] -keywords = ["ai", "cli", "tools", "automation"] +keywords = ["ai", "cli", "tools", "automation", "llm", "terminal", "unix", "pipe"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", + "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Utilities", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Text Processing", ] dependencies = [ "PyYAML>=6.0", ] [project.optional-dependencies] +tui = [ + "urwid>=2.1.0", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", + "urwid>=2.1.0", +] +all = [ + "urwid>=2.1.0", ] [project.scripts] @@ -40,6 +51,10 @@ smarttools = "smarttools.cli:main" [project.urls] Homepage = "https://github.com/rob/smarttools" +Documentation = "https://github.com/rob/smarttools#readme" +Repository = "https://github.com/rob/smarttools.git" +Issues = "https://github.com/rob/smarttools/issues" +Changelog = "https://github.com/rob/smarttools/releases" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/smarttools/cli.py b/src/smarttools/cli.py index 33a05c8..79c4ee9 100644 --- a/src/smarttools/cli.py +++ b/src/smarttools/cli.py @@ -4,7 +4,7 @@ import argparse import sys from . import __version__ -from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolInput +from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolArgument, PromptStep, CodeStep from .ui import run_ui @@ -14,7 +14,7 @@ def cmd_list(args): if not tools: print("No tools found.") - print("Create your first tool with: smarttools create ") + print("Create your first tool with: smarttools ui") return 0 print(f"Available tools ({len(tools)}):\n") @@ -23,14 +23,29 @@ def cmd_list(args): if tool: print(f" {name}") print(f" {tool.description or 'No description'}") - print(f" Provider: {tool.provider}") + + # Show arguments + if tool.arguments: + args_str = ", ".join(arg.flag for arg in tool.arguments) + print(f" Arguments: {args_str}") + + # Show steps + if tool.steps: + step_info = [] + for step in tool.steps: + if isinstance(step, PromptStep): + step_info.append(f"PROMPT[{step.provider}]") + elif isinstance(step, CodeStep): + step_info.append("CODE") + print(f" Steps: {' -> '.join(step_info)}") + print() return 0 def cmd_create(args): - """Create a new tool.""" + """Create a new tool (basic CLI creation - use 'ui' for full builder).""" name = args.name # Check if already exists @@ -39,21 +54,28 @@ def cmd_create(args): print(f"Error: Tool '{name}' already exists. Use --force to overwrite.") return 1 - # Create minimal tool + # Create a tool with a single prompt step + steps = [] + if args.prompt: + steps.append(PromptStep( + prompt=args.prompt, + provider=args.provider or "mock", + output_var="response" + )) + tool = Tool( name=name, description=args.description or "", - prompt=args.prompt or "Process the following:\n\n{input}", - provider=args.provider or "codex", - provider_args=args.provider_args or "-p", - inputs=[], - post_process=None + arguments=[], + steps=steps, + output="{response}" if steps else "{input}" ) path = save_tool(tool) print(f"Created tool '{name}'") print(f"Config: {path}") - print(f"\nEdit the config to customize, then run: {name} -i ") + print(f"\nUse 'smarttools ui' to add arguments, steps, and customize.") + print(f"Or run: {name} < input.txt") return 0 @@ -141,12 +163,106 @@ def cmd_test(args): return code +def cmd_run(args): + """Run a tool.""" + from pathlib import Path + from .runner import run_tool + + tool = load_tool(args.name) + if not tool: + print(f"Error: Tool '{args.name}' not found.", file=sys.stderr) + return 1 + + # Read input + if args.input: + # Read from file + input_path = Path(args.input) + if not input_path.exists(): + print(f"Error: Input file not found: {args.input}", file=sys.stderr) + return 1 + input_text = input_path.read_text() + elif args.stdin: + # Explicit interactive input requested + print("Reading from stdin (Ctrl+D to end):", file=sys.stderr) + input_text = sys.stdin.read() + elif not sys.stdin.isatty(): + # Stdin is piped - read it + input_text = sys.stdin.read() + else: + # No input provided - use empty string + input_text = "" + + # Collect custom args from remaining arguments + custom_args = {} + if args.tool_args: + # Parse tool-specific arguments + i = 0 + while i < len(args.tool_args): + arg = args.tool_args[i] + if arg.startswith('--'): + key = arg[2:].replace('-', '_') + if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'): + custom_args[key] = args.tool_args[i + 1] + i += 2 + else: + custom_args[key] = True + i += 1 + elif arg.startswith('-'): + key = arg[1:].replace('-', '_') + if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'): + custom_args[key] = args.tool_args[i + 1] + i += 2 + else: + custom_args[key] = True + i += 1 + else: + i += 1 + + # Run tool + output, code = run_tool( + tool=tool, + input_text=input_text, + custom_args=custom_args, + provider_override=args.provider, + dry_run=args.dry_run, + show_prompt=args.show_prompt, + verbose=args.verbose + ) + + # Write output + if code == 0 and output: + if args.output: + Path(args.output).write_text(output) + else: + print(output) + + return code + + def cmd_ui(args): """Launch the interactive UI.""" run_ui() return 0 +def cmd_refresh(args): + """Refresh all wrapper scripts with the current Python path.""" + from .tool import list_tools, create_wrapper_script + + tools = list_tools() + if not tools: + print("No tools found.") + return 0 + + print(f"Refreshing wrapper scripts for {len(tools)} tools...") + for name in tools: + path = create_wrapper_script(name) + print(f" {name} -> {path}") + + print("\nDone. Wrapper scripts updated to use current Python interpreter.") + return 0 + + def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser( @@ -167,8 +283,7 @@ def main(): p_create.add_argument("name", help="Tool name") p_create.add_argument("-d", "--description", help="Tool description") p_create.add_argument("-p", "--prompt", help="Prompt template") - p_create.add_argument("--provider", help="AI provider (default: codex)") - p_create.add_argument("--provider-args", help="Provider arguments (default: -p)") + p_create.add_argument("--provider", help="AI provider (default: mock)") p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing") p_create.set_defaults(func=cmd_create) @@ -190,10 +305,27 @@ def main(): p_test.add_argument("--dry-run", action="store_true", help="Show prompt only") p_test.set_defaults(func=cmd_test) + # 'run' command + p_run = subparsers.add_parser("run", help="Run a tool") + p_run.add_argument("name", help="Tool name") + p_run.add_argument("-i", "--input", help="Input file (reads from stdin if piped)") + p_run.add_argument("-o", "--output", help="Output file (writes to stdout if omitted)") + p_run.add_argument("--stdin", action="store_true", help="Read input interactively (type then Ctrl+D)") + p_run.add_argument("-p", "--provider", help="Override provider") + p_run.add_argument("--dry-run", action="store_true", help="Show what would happen without executing") + p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output") + p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information") + p_run.add_argument("tool_args", nargs="*", help="Additional tool-specific arguments") + p_run.set_defaults(func=cmd_run) + # 'ui' command (explicit) p_ui = subparsers.add_parser("ui", help="Launch interactive UI") p_ui.set_defaults(func=cmd_ui) + # 'refresh' command + p_refresh = subparsers.add_parser("refresh", help="Refresh all wrapper scripts") + p_refresh.set_defaults(func=cmd_refresh) + args = parser.parse_args() # If no command, launch UI diff --git a/src/smarttools/providers.py b/src/smarttools/providers.py index e4247e0..731932a 100644 --- a/src/smarttools/providers.py +++ b/src/smarttools/providers.py @@ -1,9 +1,40 @@ """Provider abstraction for AI CLI tools.""" +import os import subprocess import shutil -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, List + +import yaml + + +# Default providers config location +PROVIDERS_FILE = Path.home() / ".smarttools" / "providers.yaml" + + +@dataclass +class Provider: + """Definition of an AI provider.""" + name: str + command: str + description: str = "" + + def to_dict(self) -> dict: + return { + "name": self.name, + "command": self.command, + "description": self.description, + } + + @classmethod + def from_dict(cls, data: dict) -> "Provider": + return cls( + name=data["name"], + command=data["command"], + description=data.get("description", ""), + ) @dataclass @@ -14,29 +45,137 @@ class ProviderResult: error: Optional[str] = None -def call_provider(provider: str, args: str, prompt: str, timeout: int = 300) -> ProviderResult: +# Default providers that come pre-configured +# Profiled with 4-task test: Math, Code, Reasoning, Extract (Dec 2025) +DEFAULT_PROVIDERS = [ + # TOP PICKS - best value/performance ratio + Provider("opencode-deepseek", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat", "13s 4/4 | BEST VALUE, cheap, fast, accurate"), + Provider("opencode-pickle", "$HOME/.opencode/bin/opencode run --model opencode/big-pickle", "13s 4/4 | BEST FREE, accurate"), + Provider("claude-haiku", "claude -p --model haiku", "14s 4/4 | fast, accurate, best paid option"), + Provider("codex", "codex exec -", "14s 4/4 | reliable, auto-routes"), + + # CLAUDE - all accurate, paid, good for code + Provider("claude", "claude -p", "18s 4/4 | auto-routes to best model"), + Provider("claude-opus", "claude -p --model opus", "18s 4/4 | highest quality, expensive"), + Provider("claude-sonnet", "claude -p --model sonnet", "21s 4/4 | balanced quality/speed"), + + # OPENCODE - additional models + Provider("opencode-nano", "$HOME/.opencode/bin/opencode run --model opencode/gpt-5-nano", "24s 4/4 | GPT-5 Nano, reliable"), + Provider("opencode-reasoner", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-reasoner", "33s 4/4 | complex reasoning, cheap"), + Provider("opencode-grok", "$HOME/.opencode/bin/opencode run --model opencode/grok-code", "11s 2/4 | fastest but unreliable, FREE"), + + # GEMINI - slow CLI but good for large docs (1M token context) + Provider("gemini-flash", "gemini --model gemini-2.5-flash", "28s 4/4 | use this for quick tasks"), + Provider("gemini", "gemini --model gemini-2.5-pro", "91s 3/4 | slow CLI, best for large docs/PDFs"), + + # Mock for testing + Provider("mock", "mock", "Mock provider for testing"), +] + + +def get_providers_file() -> Path: + """Get the providers config file, creating default if needed.""" + if not PROVIDERS_FILE.exists(): + PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True) + save_providers(DEFAULT_PROVIDERS) + return PROVIDERS_FILE + + +def load_providers() -> List[Provider]: + """Load all defined providers.""" + providers_file = get_providers_file() + + try: + data = yaml.safe_load(providers_file.read_text()) + if not data or "providers" not in data: + return DEFAULT_PROVIDERS.copy() + return [Provider.from_dict(p) for p in data["providers"]] + except Exception: + return DEFAULT_PROVIDERS.copy() + + +def save_providers(providers: List[Provider]): + """Save providers to config file.""" + PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True) + data = {"providers": [p.to_dict() for p in providers]} + PROVIDERS_FILE.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False)) + + +def get_provider(name: str) -> Optional[Provider]: + """Get a provider by name.""" + providers = load_providers() + for p in providers: + if p.name == name: + return p + return None + + +def add_provider(provider: Provider) -> bool: + """Add or update a provider.""" + providers = load_providers() + + # Update if exists, otherwise add + for i, p in enumerate(providers): + if p.name == provider.name: + providers[i] = provider + save_providers(providers) + return True + + providers.append(provider) + save_providers(providers) + return True + + +def delete_provider(name: str) -> bool: + """Delete a provider by name.""" + providers = load_providers() + original_len = len(providers) + providers = [p for p in providers if p.name != name] + + if len(providers) < original_len: + save_providers(providers) + return True + return False + + +def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> ProviderResult: """ - Call an AI CLI tool with the given prompt. + Call an AI provider with the given prompt. Args: - provider: The CLI command (e.g., 'codex', 'claude', 'gemini') - args: Additional arguments for the provider (e.g., '-p') + provider_name: Name of the provider to use prompt: The prompt to send timeout: Maximum execution time in seconds Returns: ProviderResult with the response text or error """ - # Check if provider exists - if not shutil.which(provider): + # Handle mock provider specially + if provider_name.lower() == "mock": + return mock_provider(prompt) + + # Look up provider + provider = get_provider(provider_name) + if not provider: return ProviderResult( text="", success=False, - error=f"Provider '{provider}' not found. Is it installed and in PATH?" + error=f"Provider '{provider_name}' not found. Use 'smarttools providers' to manage providers." ) - # Build command - cmd = f"{provider} {args}".strip() + # Parse command (expand environment variables) + cmd = os.path.expandvars(provider.command) + + # Check if base command exists + base_cmd = cmd.split()[0] + # Expand ~ for the which check + base_cmd_expanded = os.path.expanduser(base_cmd) + if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded): + return ProviderResult( + text="", + success=False, + error=f"Command '{base_cmd}' not found. Is it installed and in PATH?" + ) try: result = subprocess.run( @@ -92,19 +231,3 @@ def mock_provider(prompt: str) -> ProviderResult: f"This is a mock response. Use a real provider for actual output.", success=True ) - - -def get_provider_func(provider: str): - """ - Get the appropriate provider function. - - Args: - provider: Provider name ('mock' for mock, anything else for real) - - Returns: - Callable that takes (prompt) or (provider, args, prompt) - """ - if provider.lower() == "mock": - return lambda prompt, **kwargs: mock_provider(prompt) - else: - return lambda prompt, args="", **kwargs: call_provider(provider, args, prompt, **kwargs) diff --git a/src/smarttools/runner.py b/src/smarttools/runner.py index 0ab0f7f..be80139 100644 --- a/src/smarttools/runner.py +++ b/src/smarttools/runner.py @@ -5,75 +5,86 @@ import sys from pathlib import Path from typing import Optional -from .tool import load_tool, Tool -from .providers import call_provider, mock_provider, ProviderResult +from .tool import load_tool, Tool, PromptStep, CodeStep +from .providers import call_provider, mock_provider -def build_prompt(tool: Tool, input_text: str, custom_args: dict) -> str: +def substitute_variables(template: str, variables: dict) -> str: """ - Build the final prompt by substituting placeholders. + Substitute {variable} placeholders in a template. Args: - tool: Tool definition - input_text: Content from input file or stdin - custom_args: Custom argument values + template: String with {var} placeholders + variables: Dict of variable name -> value Returns: - Final prompt string + String with placeholders replaced """ - prompt = tool.prompt - - # Replace {input} placeholder - prompt = prompt.replace("{input}", input_text) - - # Replace custom argument placeholders - for inp in tool.inputs: - value = custom_args.get(inp.name, inp.default) - if value is not None: - prompt = prompt.replace(f"{{{inp.name}}}", str(value)) - - return prompt + result = template + for name, value in variables.items(): + result = result.replace(f"{{{name}}}", str(value) if value else "") + return result -def run_post_process(tool: Tool, output: str) -> str: +def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]: """ - Run post-processing script if defined. + Execute a prompt step. Args: - tool: Tool definition - output: Raw output from provider + step: The prompt step to execute + variables: Current variable values + provider_override: Override the step's provider Returns: - Processed output + Tuple of (output_value, success) """ - if not tool.post_process: - return output + # Build prompt with variable substitution + prompt = substitute_variables(step.prompt, variables) - from .tool import get_tools_dir - script_path = get_tools_dir() / tool.name / tool.post_process + # Call provider + provider = provider_override or step.provider - if not script_path.exists(): - print(f"Warning: Post-process script not found: {script_path}", file=sys.stderr) - return output + if provider.lower() == "mock": + result = mock_provider(prompt) + else: + result = call_provider(provider, prompt) + + if not result.success: + print(f"Error in prompt step: {result.error}", file=sys.stderr) + return "", False + + return result.text, True + + +def execute_code_step(step: CodeStep, variables: dict) -> tuple[dict, bool]: + """ + Execute a code step. + + Args: + step: The code step to execute + variables: Current variable values (available in code) + + Returns: + Tuple of (output_vars_dict, success) + """ + # Create execution environment with variables + local_vars = dict(variables) - # Execute post-process script with output as input - import subprocess try: - result = subprocess.run( - ["python3", str(script_path)], - input=output, - capture_output=True, - text=True, - timeout=60 - ) - if result.returncode == 0: - return result.stdout - else: - print(f"Warning: Post-process failed: {result.stderr}", file=sys.stderr) - return output + # Execute the code + exec(step.code, {"__builtins__": __builtins__}, local_vars) + + # Support comma-separated output vars (e.g., "a, b, c") + output_vars = [v.strip() for v in step.output_var.split(',')] + outputs = {} + for var in output_vars: + outputs[var] = str(local_vars.get(var, "")) + + return outputs, True + except Exception as e: - print(f"Warning: Post-process error: {e}", file=sys.stderr) - return output + print(f"Error in code step: {e}", file=sys.stderr) + return {}, False def run_tool( @@ -92,48 +103,73 @@ def run_tool( tool: Tool definition input_text: Input content custom_args: Custom argument values - provider_override: Override the tool's provider - dry_run: Just show prompt, don't call provider - show_prompt: Show prompt in addition to output + provider_override: Override all providers + dry_run: Just show what would happen + show_prompt: Show prompts in addition to output verbose: Show debug info Returns: Tuple of (output_text, exit_code) """ - # Build prompt - prompt = build_prompt(tool, input_text, custom_args) + # Initialize variables with input and arguments + variables = {"input": input_text} + + # Add argument values (with defaults) + for arg in tool.arguments: + value = custom_args.get(arg.variable, arg.default) + variables[arg.variable] = value if verbose: print(f"[verbose] Tool: {tool.name}", file=sys.stderr) - print(f"[verbose] Provider: {provider_override or tool.provider}", file=sys.stderr) - print(f"[verbose] Prompt length: {len(prompt)} chars", file=sys.stderr) + print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr) + print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr) - # Dry run - just show prompt - if dry_run: - return prompt, 0 + # If no steps, just substitute output template + if not tool.steps: + output = substitute_variables(tool.output, variables) + return output, 0 - # Show prompt if requested - if show_prompt: - print("=== PROMPT ===", file=sys.stderr) - print(prompt, file=sys.stderr) - print("=== END PROMPT ===", file=sys.stderr) + # Execute each step + for i, step in enumerate(tool.steps): + if verbose: + step_type = "PROMPT" if isinstance(step, PromptStep) else "CODE" + print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr) - # Call provider - provider = provider_override or tool.provider + if isinstance(step, PromptStep): + # Show prompt if requested + if show_prompt or dry_run: + prompt = substitute_variables(step.prompt, variables) + print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr) + print(prompt, file=sys.stderr) + print("=== END PROMPT ===", file=sys.stderr) - if provider.lower() == "mock": - result = mock_provider(prompt) - else: - result = call_provider(provider, tool.provider_args, prompt) + if dry_run: + variables[step.output_var] = f"[DRY RUN - would call {step.provider}]" + else: + output, success = execute_prompt_step(step, variables, provider_override) + if not success: + return "", 2 + variables[step.output_var] = output - if not result.success: - print(f"Error: {result.error}", file=sys.stderr) - return "", 2 # Exit code 2 for provider errors + elif isinstance(step, CodeStep): + if verbose or dry_run: + print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr) + print(step.code, file=sys.stderr) + print("=== END CODE ===", file=sys.stderr) - output = result.text + if dry_run: + # Handle comma-separated output vars for dry run + for var in [v.strip() for v in step.output_var.split(',')]: + variables[var] = "[DRY RUN - would execute code]" + else: + outputs, success = execute_code_step(step, variables) + if not success: + return "", 1 + # Merge all output vars into variables + variables.update(outputs) - # Post-process if defined - output = run_post_process(tool, output) + # Generate final output + output = substitute_variables(tool.output, variables) return output, 0 @@ -155,25 +191,27 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser: # Universal flags parser.add_argument("-i", "--input", dest="input_file", - help="Input file (reads from stdin if omitted)") + help="Input file (reads from stdin if piped)") + parser.add_argument("--stdin", action="store_true", + help="Read input interactively from stdin (type then Ctrl+D)") parser.add_argument("-o", "--output", dest="output_file", help="Output file (writes to stdout if omitted)") parser.add_argument("--dry-run", action="store_true", - help="Show prompt without calling AI provider") + help="Show what would happen without executing") parser.add_argument("--show-prompt", action="store_true", - help="Show prompt in addition to output") + help="Show prompts in addition to output") parser.add_argument("-p", "--provider", help="Override provider (e.g., --provider mock)") parser.add_argument("-v", "--verbose", action="store_true", help="Show debug information") - # Tool-specific flags - for inp in tool.inputs: + # Tool-specific flags from arguments + for arg in tool.arguments: parser.add_argument( - inp.flag, - dest=inp.name, - default=inp.default, - help=inp.description or f"{inp.name} (default: {inp.default})" + arg.flag, + dest=arg.variable, + default=arg.default, + help=arg.description or f"{arg.variable} (default: {arg.default})" ) return parser @@ -198,23 +236,29 @@ def main(): # Read input if args.input_file: + # Read from file input_path = Path(args.input_file) if not input_path.exists(): print(f"Error: Input file not found: {args.input_file}", file=sys.stderr) sys.exit(1) input_text = input_path.read_text() - else: - # Read from stdin - if sys.stdin.isatty(): - print("Reading from stdin (Ctrl+D to end):", file=sys.stderr) + elif args.stdin: + # Explicit interactive input requested + print("Reading from stdin (Ctrl+D to end):", file=sys.stderr) input_text = sys.stdin.read() + elif not sys.stdin.isatty(): + # Stdin is piped - read it + input_text = sys.stdin.read() + else: + # No input provided - use empty string + input_text = "" # Collect custom args custom_args = {} - for inp in tool.inputs: - value = getattr(args, inp.name, None) + for arg in tool.arguments: + value = getattr(args, arg.variable, None) if value is not None: - custom_args[inp.name] = value + custom_args[arg.variable] = value # Run tool output, exit_code = run_tool( diff --git a/src/smarttools/tool.py b/src/smarttools/tool.py index 7b35730..5d3edda 100644 --- a/src/smarttools/tool.py +++ b/src/smarttools/tool.py @@ -4,7 +4,7 @@ import os import stat from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Optional, List, Literal import yaml @@ -17,74 +17,145 @@ BIN_DIR = Path.home() / ".local" / "bin" @dataclass -class ToolInput: +class ToolArgument: """Definition of a custom input argument.""" - name: str - flag: str + flag: str # e.g., "--max-size" + variable: str # e.g., "max_size" default: Optional[str] = None description: str = "" + def to_dict(self) -> dict: + d = {"flag": self.flag, "variable": self.variable} + if self.default: + d["default"] = self.default + if self.description: + d["description"] = self.description + return d + + @classmethod + def from_dict(cls, data: dict) -> "ToolArgument": + return cls( + flag=data["flag"], + variable=data["variable"], + default=data.get("default"), + description=data.get("description", "") + ) + + +@dataclass +class PromptStep: + """A prompt step that calls an AI provider.""" + prompt: str # The prompt template + provider: str # Provider name + output_var: str # Variable to store output + prompt_file: Optional[str] = None # Optional filename for external prompt + + def to_dict(self) -> dict: + d = { + "type": "prompt", + "prompt": self.prompt, + "provider": self.provider, + "output_var": self.output_var + } + if self.prompt_file: + d["prompt_file"] = self.prompt_file + return d + + @classmethod + def from_dict(cls, data: dict) -> "PromptStep": + return cls( + prompt=data["prompt"], + provider=data["provider"], + output_var=data["output_var"], + prompt_file=data.get("prompt_file") + ) + + +@dataclass +class CodeStep: + """A code step that runs Python code.""" + code: str # Python code (inline or loaded from file) + output_var: str # Variable name(s) to capture (comma-separated for multiple) + code_file: Optional[str] = None # Optional filename for external code + + def to_dict(self) -> dict: + d = { + "type": "code", + "code": self.code, + "output_var": self.output_var + } + if self.code_file: + d["code_file"] = self.code_file + return d + + @classmethod + def from_dict(cls, data: dict) -> "CodeStep": + return cls( + code=data.get("code", ""), + output_var=data["output_var"], + code_file=data.get("code_file") + ) + + +Step = PromptStep | CodeStep + @dataclass class Tool: """A SmartTools tool definition.""" name: str - description: str - prompt: str - provider: str - provider_args: str = "" - inputs: list[ToolInput] = field(default_factory=list) - post_process: Optional[str] = None + description: str = "" + arguments: List[ToolArgument] = field(default_factory=list) + steps: List[Step] = field(default_factory=list) + output: str = "{input}" # Output template @classmethod def from_dict(cls, data: dict) -> "Tool": - """Create a Tool from a dictionary (parsed YAML).""" - inputs = [] - for inp in data.get("inputs", []): - inputs.append(ToolInput( - name=inp["name"], - flag=inp.get("flag", f"--{inp['name']}"), - default=inp.get("default"), - description=inp.get("description", "") - )) + arguments = [] + for arg in data.get("arguments", []): + arguments.append(ToolArgument.from_dict(arg)) + + steps = [] + for step in data.get("steps", []): + if step.get("type") == "prompt": + steps.append(PromptStep.from_dict(step)) + elif step.get("type") == "code": + steps.append(CodeStep.from_dict(step)) return cls( name=data["name"], description=data.get("description", ""), - prompt=data["prompt"], - provider=data["provider"], - provider_args=data.get("provider_args", ""), - inputs=inputs, - post_process=data.get("post_process") + arguments=arguments, + steps=steps, + output=data.get("output", "{input}") ) def to_dict(self) -> dict: - """Convert Tool to dictionary for YAML serialization.""" - data = { + return { "name": self.name, "description": self.description, - "prompt": self.prompt, - "provider": self.provider, + "arguments": [arg.to_dict() for arg in self.arguments], + "steps": [step.to_dict() for step in self.steps], + "output": self.output } - if self.provider_args: - data["provider_args"] = self.provider_args + def get_available_variables(self) -> List[str]: + """Get all variables available for use in templates.""" + variables = ["input"] # Always available - if self.inputs: - data["inputs"] = [ - { - "name": inp.name, - "flag": inp.flag, - "default": inp.default, - "description": inp.description, - } - for inp in self.inputs - ] + # Add argument variables + for arg in self.arguments: + variables.append(arg.variable) - if self.post_process: - data["post_process"] = self.post_process + # Add step output variables + for step in self.steps: + variables.append(step.output_var) - return data + return variables + + +# Legacy support - map old ToolInput to new ToolArgument +ToolInput = ToolArgument def get_tools_dir() -> Path: @@ -114,15 +185,7 @@ def list_tools() -> list[str]: def load_tool(name: str) -> Optional[Tool]: - """ - Load a tool by name. - - Args: - name: Tool name - - Returns: - Tool object or None if not found - """ + """Load a tool by name.""" config_path = get_tools_dir() / name / "config.yaml" if not config_path.exists(): @@ -130,21 +193,44 @@ def load_tool(name: str) -> Optional[Tool]: try: data = yaml.safe_load(config_path.read_text()) + + # Handle legacy format (prompt/provider/provider_args/inputs) + if "prompt" in data and "steps" not in data: + # Convert to new format + steps = [] + if data.get("prompt"): + steps.append({ + "type": "prompt", + "prompt": data["prompt"], + "provider": data.get("provider", "mock"), + "output_var": "response" + }) + + arguments = [] + for inp in data.get("inputs", []): + arguments.append({ + "flag": inp.get("flag", f"--{inp['name']}"), + "variable": inp["name"], + "default": inp.get("default"), + "description": inp.get("description", "") + }) + + data = { + "name": data["name"], + "description": data.get("description", ""), + "arguments": arguments, + "steps": steps, + "output": "{response}" if steps else "{input}" + } + return Tool.from_dict(data) - except Exception: + except Exception as e: + print(f"Error loading tool {name}: {e}") return None def save_tool(tool: Tool) -> Path: - """ - Save a tool to disk. - - Args: - tool: Tool object to save - - Returns: - Path to the saved config file - """ + """Save a tool to disk.""" tool_dir = get_tools_dir() / tool.name tool_dir.mkdir(parents=True, exist_ok=True) @@ -158,15 +244,7 @@ def save_tool(tool: Tool) -> Path: def delete_tool(name: str) -> bool: - """ - Delete a tool. - - Args: - name: Tool name - - Returns: - True if deleted, False if not found - """ + """Delete a tool.""" tool_dir = get_tools_dir() / name if not tool_dir.exists(): @@ -185,22 +263,19 @@ def delete_tool(name: str) -> bool: def create_wrapper_script(name: str) -> Path: - """ - Create a wrapper script for a tool in ~/.local/bin. + """Create a wrapper script for a tool in ~/.local/bin.""" + import sys - Args: - name: Tool name - - Returns: - Path to the wrapper script - """ bin_dir = get_bin_dir() wrapper_path = bin_dir / name + # Use the current Python interpreter to ensure smarttools is available + python_path = sys.executable + script = f"""#!/bin/bash # SmartTools wrapper for '{name}' # Auto-generated - do not edit -exec python3 -m smarttools.runner {name} "$@" +exec {python_path} -m smarttools.runner {name} "$@" """ wrapper_path.write_text(script) @@ -212,3 +287,34 @@ exec python3 -m smarttools.runner {name} "$@" def tool_exists(name: str) -> bool: """Check if a tool exists.""" return (get_tools_dir() / name / "config.yaml").exists() + + +def validate_tool_name(name: str) -> tuple[bool, str]: + """ + Validate a tool name. + + Returns: + (is_valid, error_message) - error_message is empty if valid + """ + if not name: + return False, "Tool name cannot be empty" + + if ' ' in name: + return False, "Tool name cannot contain spaces" + + # Check for shell-problematic characters + bad_chars = set('/\\|&;$`"\'<>(){}[]!?*#~') + found = [c for c in name if c in bad_chars] + if found: + return False, f"Tool name cannot contain: {' '.join(found)}" + + # Must start with letter or underscore + if not (name[0].isalpha() or name[0] == '_'): + return False, "Tool name must start with a letter or underscore" + + # Check it's a valid identifier-ish (alphanumeric, underscore, dash) + for c in name: + if not (c.isalnum() or c in '_-'): + return False, f"Tool name can only contain letters, numbers, underscore, and dash" + + return True, "" diff --git a/src/smarttools/ui.py b/src/smarttools/ui.py index 2dd9190..5d45d9a 100644 --- a/src/smarttools/ui.py +++ b/src/smarttools/ui.py @@ -2,9 +2,34 @@ import subprocess import sys -from typing import Optional, Tuple +import tempfile +from typing import Optional, Tuple, List -from .tool import Tool, ToolInput, list_tools, load_tool, save_tool, delete_tool, tool_exists +from .tool import ( + Tool, ToolArgument, PromptStep, CodeStep, Step, + list_tools, load_tool, save_tool, delete_tool, tool_exists +) +from .providers import Provider, load_providers, add_provider, delete_provider, get_provider + + +def _check_urwid() -> bool: + """Check if urwid is available (preferred - has mouse support).""" + try: + import urwid + return True + except ImportError: + return False + + +def _check_snack() -> bool: + """Check if snack (python3-newt) is available.""" + try: + if '/usr/lib/python3/dist-packages' not in sys.path: + sys.path.insert(0, '/usr/lib/python3/dist-packages') + import snack + return True + except ImportError: + return False def check_dialog() -> str: @@ -19,35 +44,30 @@ def check_dialog() -> str: def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]: - """ - Run a dialog command and return (exit_code, output). - - Args: - args: Dialog arguments - dialog_prog: Dialog program to use - - Returns: - Tuple of (exit_code, captured_output) - """ - cmd = [dialog_prog] + args - result = subprocess.run(cmd, capture_output=True, text=True) - # dialog outputs to stderr - return result.returncode, result.stderr.strip() + """Run a dialog command and return (exit_code, output).""" + try: + if dialog_prog == "whiptail": + result = subprocess.run( + [dialog_prog] + args, + stderr=subprocess.PIPE, + text=True + ) + return result.returncode, result.stderr.strip() + else: + cmd_with_stdout = [dialog_prog, "--stdout"] + args + result = subprocess.run( + cmd_with_stdout, + stdout=subprocess.PIPE, + text=True + ) + return result.returncode, result.stdout.strip() + except Exception as e: + return 1, "" def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]: - """ - Show a menu and return the selected item. - - Args: - title: Menu title - choices: List of (tag, description) tuples - dialog_prog: Dialog program to use - - Returns: - Selected tag or None if cancelled - """ - args = ["--title", title, "--menu", "", "15", "60", str(len(choices))] + """Show a menu and return the selected item.""" + args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))] for tag, desc in choices: args.extend([tag, desc]) @@ -56,61 +76,29 @@ def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> O def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]: - """ - Show an input box and return the entered text. - - Args: - title: Dialog title - prompt: Prompt text - initial: Initial value - dialog_prog: Dialog program to use - - Returns: - Entered text or None if cancelled - """ + """Show an input box and return the entered text.""" args = ["--title", title, "--inputbox", prompt, "10", "60", initial] code, output = run_dialog(args, dialog_prog) return output if code == 0 else None def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]: - """ - Show a text editor for multi-line input. - - Args: - title: Dialog title - text: Initial text - dialog_prog: Dialog program to use - - Returns: - Edited text or None if cancelled - """ - import tempfile + """Show a text editor for multi-line input.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: f.write(text) temp_path = f.name - args = ["--title", title, "--editbox", temp_path, "20", "70"] - code, output = run_dialog(args, dialog_prog) - - import os - os.unlink(temp_path) - - return output if code == 0 else None + try: + args = ["--title", title, "--editbox", temp_path, "20", "75"] + code, output = run_dialog(args, dialog_prog) + return output if code == 0 else None + finally: + import os + os.unlink(temp_path) def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool: - """ - Show a yes/no dialog. - - Args: - title: Dialog title - text: Question text - dialog_prog: Dialog program to use - - Returns: - True if yes, False if no - """ + """Show a yes/no dialog.""" args = ["--title", title, "--yesno", text, "10", "60"] code, _ = run_dialog(args, dialog_prog) return code == 0 @@ -118,84 +106,525 @@ def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool: def show_message(title: str, text: str, dialog_prog: str = "dialog"): """Show a message box.""" - args = ["--title", title, "--msgbox", text, "10", "60"] + args = ["--title", title, "--msgbox", text, "15", "70"] run_dialog(args, dialog_prog) -def create_tool_form(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]: - """ - Show form for creating/editing a tool. +def show_mixed_form(title: str, fields: dict, dialog_prog: str, height: int = 20) -> Optional[dict]: + """Show a form with multiple fields using --mixedform.""" + args = ["--title", title, "--mixedform", + "Tab: next field | Enter: submit | Esc: cancel", + str(height), "75", "0"] - Args: - dialog_prog: Dialog program to use - existing: Existing tool to edit (None for new) + field_names = list(fields.keys()) + y = 1 + for name in field_names: + label, initial, field_type = fields[name] + args.extend([ + label, str(y), "1", + initial, str(y), "18", + "52", "256", str(field_type) + ]) + y += 1 + + code, output = run_dialog(args, dialog_prog) + + if code != 0: + return None + + values = output.split('\n') + result = {} + for i, name in enumerate(field_names): + result[name] = values[i] if i < len(values) else "" + + return result + + +# ============ Provider Management ============ + +def select_provider(dialog_prog: str) -> Optional[str]: + """Show provider selection menu with option to create new.""" + providers = load_providers() + + choices = [(p.name, f"{p.description} ({p.command})") for p in providers] + choices.append(("__new__", "[ + Add New Provider ]")) + + selected = show_menu("Select Provider", choices, dialog_prog) + + if selected == "__new__": + provider = create_provider_form(dialog_prog) + if provider: + add_provider(provider) + return provider.name + return None + + return selected + + +def create_provider_form(dialog_prog: str, existing: Optional[Provider] = None) -> Optional[Provider]: + """Show form for creating/editing a provider.""" + title = f"Edit Provider: {existing.name}" if existing else "Add New Provider" + + fields = { + "name": ( + "Name:", + existing.name if existing else "", + 2 if existing else 0 # readonly if editing + ), + "command": ( + "Command:", + existing.command if existing else "", + 0 + ), + "description": ( + "Description:", + existing.description if existing else "", + 0 + ), + } + + result = show_mixed_form(title, fields, dialog_prog, height=12) + + if not result: + return None + + name = result["name"].strip() + command = result["command"].strip() - Returns: - Tool object or None if cancelled - """ - # Get basic info - name = show_input( - "Tool Name", - "Enter tool name (used as command):", - existing.name if existing else "", - dialog_prog - ) if not name: + show_message("Error", "Provider name is required.", dialog_prog) return None - # Check if name already exists (for new tools) - if not existing and tool_exists(name): - if not show_yesno("Overwrite?", f"Tool '{name}' already exists. Overwrite?", dialog_prog): - return None - - description = show_input( - "Description", - "Enter tool description:", - existing.description if existing else "", - dialog_prog - ) - if description is None: + if not command: + show_message("Error", "Command is required.", dialog_prog) return None - provider = show_input( - "Provider", - "Enter AI provider command (e.g., codex, claude, gemini):", - existing.provider if existing else "codex", - dialog_prog - ) - if not provider: - return None - - provider_args = show_input( - "Provider Args", - "Enter provider arguments (e.g., -p):", - existing.provider_args if existing else "-p", - dialog_prog - ) - if provider_args is None: - return None - - # Edit prompt - default_prompt = existing.prompt if existing else "Process the following input:\n\n{input}" - prompt = show_textbox("Prompt Template", default_prompt, dialog_prog) - if not prompt: - return None - - # For simplicity, skip custom inputs in the UI for now - # They can be added by editing config.yaml directly - inputs = existing.inputs if existing else [] - - return Tool( + return Provider( name=name, - description=description, + command=command, + description=result["description"].strip() + ) + + +def ui_manage_providers(dialog_prog: str): + """Manage providers menu.""" + while True: + providers = load_providers() + choices = [(p.name, f"{p.command}") for p in providers] + choices.append(("__add__", "[ + Add New Provider ]")) + choices.append(("__back__", "[ <- Back to Main Menu ]")) + + selected = show_menu("Manage Providers", choices, dialog_prog) + + if selected is None or selected == "__back__": + break + elif selected == "__add__": + provider = create_provider_form(dialog_prog) + if provider: + add_provider(provider) + show_message("Success", f"Provider '{provider.name}' added.", dialog_prog) + else: + # Edit or delete existing provider + provider = get_provider(selected) + if provider: + action = show_menu( + f"Provider: {selected}", + [ + ("edit", "Edit provider"), + ("delete", "Delete provider"), + ("back", "Back"), + ], + dialog_prog + ) + + if action == "edit": + updated = create_provider_form(dialog_prog, provider) + if updated: + add_provider(updated) + show_message("Success", f"Provider '{updated.name}' updated.", dialog_prog) + elif action == "delete": + if show_yesno("Confirm", f"Delete provider '{selected}'?", dialog_prog): + delete_provider(selected) + show_message("Deleted", f"Provider '{selected}' deleted.", dialog_prog) + + +# ============ Tool Builder UI ============ + +def format_tool_summary(tool: Tool) -> str: + """Format a summary of the tool's components.""" + lines = [] + lines.append(f"Name: {tool.name}") + lines.append(f"Description: {tool.description or '(none)'}") + lines.append("") + + if tool.arguments: + lines.append("Arguments:") + for arg in tool.arguments: + default = f" = {arg.default}" if arg.default else "" + lines.append(f" {arg.flag} -> {{{arg.variable}}}{default}") + lines.append("") + + if tool.steps: + lines.append("Steps:") + for i, step in enumerate(tool.steps): + if isinstance(step, PromptStep): + preview = step.prompt[:40].replace('\n', ' ') + "..." + lines.append(f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}") + lines.append(f" {preview}") + elif isinstance(step, CodeStep): + preview = step.code[:40].replace('\n', ' ') + "..." + lines.append(f" {i+1}. CODE -> {{{step.output_var}}}") + lines.append(f" {preview}") + lines.append("") + + lines.append(f"Output: {tool.output}") + + return "\n".join(lines) + + +def get_available_variables(tool: Tool, up_to_step: int = -1) -> List[str]: + """Get list of available variables at a given point in the tool.""" + variables = ["input"] + + for arg in tool.arguments: + variables.append(arg.variable) + + if up_to_step == -1: + up_to_step = len(tool.steps) + + for i, step in enumerate(tool.steps): + if i >= up_to_step: + break + variables.append(step.output_var) + + return variables + + +def edit_argument(dialog_prog: str, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]: + """Edit or create an argument.""" + title = f"Edit Argument: {existing.flag}" if existing else "Add Argument" + + fields = { + "flag": ("Flag:", existing.flag if existing else "--", 0), + "variable": ("Variable:", existing.variable if existing else "", 0), + "default": ("Default:", existing.default or "" if existing else "", 0), + "description": ("Description:", existing.description if existing else "", 0), + } + + result = show_mixed_form(title, fields, dialog_prog, height=14) + if not result: + return None + + flag = result["flag"].strip() + variable = result["variable"].strip() + + if not flag: + show_message("Error", "Flag is required (e.g., --max-size).", dialog_prog) + return None + + if not variable: + # Auto-generate variable name from flag + variable = flag.lstrip("-").replace("-", "_") + + return ToolArgument( + flag=flag, + variable=variable, + default=result["default"].strip() or None, + description=result["description"].strip() + ) + + +def edit_prompt_step(dialog_prog: str, existing: Optional[PromptStep] = None, + available_vars: List[str] = None) -> Optional[PromptStep]: + """Edit or create a prompt step.""" + title = "Edit Prompt Step" if existing else "Add Prompt Step" + + # First, select provider + provider = select_provider(dialog_prog) + if not provider: + provider = existing.provider if existing else "mock" + + # Show variable help + var_help = "Available: " + ", ".join(f"{{{v}}}" for v in (available_vars or ["input"])) + + # Edit prompt text + default_prompt = existing.prompt if existing else f"Process this input:\n\n{{input}}" + prompt = show_textbox(f"Prompt Template\n{var_help}", default_prompt, dialog_prog) + if prompt is None: + return None + + # Get output variable + output_var = show_input( + "Output Variable", + "Variable name to store the result:", + existing.output_var if existing else "result", + dialog_prog + ) + if not output_var: + return None + + return PromptStep( prompt=prompt, provider=provider, - provider_args=provider_args, - inputs=inputs, - post_process=existing.post_process if existing else None + output_var=output_var.strip() ) +def edit_code_step(dialog_prog: str, existing: Optional[CodeStep] = None, + available_vars: List[str] = None) -> Optional[CodeStep]: + """Edit or create a code step.""" + title = "Edit Code Step" if existing else "Add Code Step" + + # Show variable help + var_help = "Variables: " + ", ".join(available_vars or ["input"]) + var_help += "\nSet 'result' variable for output" + + # Edit code + default_code = existing.code if existing else "# Available variables: " + ", ".join(available_vars or ["input"]) + "\n# Set 'result' for output\nresult = input.upper()" + code = show_textbox(f"Python Code\n{var_help}", default_code, dialog_prog) + if code is None: + return None + + # Get output variable + output_var = show_input( + "Output Variable", + "Variable name to store the result:", + existing.output_var if existing else "processed", + dialog_prog + ) + if not output_var: + return None + + return CodeStep( + code=code, + output_var=output_var.strip() + ) + + +def edit_tool_info(tool: Tool, is_edit: bool, dialog_prog: str) -> None: + """Edit basic tool info (name, description, output).""" + while True: + # Build info section menu + output_preview = tool.output[:35] + "..." if len(tool.output) > 35 else tool.output + args_count = len(tool.arguments) + args_summary = f"({args_count} defined)" if args_count else "(none)" + + choices = [ + ("name", f"Name: {tool.name or '(not set)'}"), + ("desc", f"Description: {tool.description[:35] + '...' if len(tool.description) > 35 else tool.description or '(none)'}"), + ("---1", "─" * 40), + ] + + # Show arguments + if tool.arguments: + for i, arg in enumerate(tool.arguments): + default = f" = {arg.default}" if arg.default else "" + choices.append((f"arg_{i}", f" {arg.flag} -> {{{arg.variable}}}{default}")) + choices.append(("add_arg", " [ + Add Argument ]")) + + choices.append(("---2", "─" * 40)) + choices.append(("output", f"Output Template: {output_preview}")) + choices.append(("---3", "─" * 40)) + choices.append(("back", "<- Back to Tool Builder")) + + selected = show_menu("Tool Info & Arguments", choices, dialog_prog) + + if selected is None or selected == "back": + break + + elif selected == "name": + if is_edit: + show_message("Info", "Cannot change tool name after creation.", dialog_prog) + else: + new_name = show_input("Tool Name", "Enter tool name:", tool.name, dialog_prog) + if new_name: + tool.name = new_name.strip() + + elif selected == "desc": + new_desc = show_input("Description", "Enter tool description:", tool.description, dialog_prog) + if new_desc is not None: + tool.description = new_desc.strip() + + elif selected == "add_arg": + arg = edit_argument(dialog_prog) + if arg: + tool.arguments.append(arg) + + elif selected.startswith("arg_"): + idx = int(selected[4:]) + arg = tool.arguments[idx] + action = show_menu( + f"Argument: {arg.flag}", + [("edit", "Edit"), ("delete", "Delete"), ("back", "Back")], + dialog_prog + ) + if action == "edit": + updated = edit_argument(dialog_prog, arg) + if updated: + tool.arguments[idx] = updated + elif action == "delete": + if show_yesno("Delete", f"Delete argument {arg.flag}?", dialog_prog): + tool.arguments.pop(idx) + + elif selected == "output": + available = get_available_variables(tool) + var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available) + new_output = show_textbox(f"Output Template\n{var_help}", tool.output, dialog_prog) + if new_output is not None: + tool.output = new_output + + +def edit_tool_steps(tool: Tool, dialog_prog: str) -> None: + """Edit tool processing steps.""" + while True: + # Build steps section menu + choices = [] + + if tool.steps: + for i, step in enumerate(tool.steps): + if isinstance(step, PromptStep): + choices.append((f"step_{i}", f"{i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}")) + elif isinstance(step, CodeStep): + choices.append((f"step_{i}", f"{i+1}. CODE -> {{{step.output_var}}}")) + else: + choices.append(("none", "(no steps defined)")) + + choices.append(("---1", "─" * 40)) + choices.append(("add_prompt", "[ + Add Prompt Step ]")) + choices.append(("add_code", "[ + Add Code Step ]")) + choices.append(("---2", "─" * 40)) + choices.append(("back", "<- Back to Tool Builder")) + + selected = show_menu("Processing Steps", choices, dialog_prog) + + if selected is None or selected == "back" or selected == "none": + if selected == "none": + continue + break + + elif selected == "add_prompt": + available = get_available_variables(tool) + step = edit_prompt_step(dialog_prog, available_vars=available) + if step: + tool.steps.append(step) + + elif selected == "add_code": + available = get_available_variables(tool) + step = edit_code_step(dialog_prog, available_vars=available) + if step: + tool.steps.append(step) + + elif selected.startswith("step_"): + idx = int(selected[5:]) + step = tool.steps[idx] + step_type = "Prompt" if isinstance(step, PromptStep) else "Code" + + move_choices = [("edit", "Edit"), ("delete", "Delete")] + if idx > 0: + move_choices.insert(1, ("move_up", "Move Up")) + if idx < len(tool.steps) - 1: + move_choices.insert(2 if idx > 0 else 1, ("move_down", "Move Down")) + move_choices.append(("back", "Back")) + + action = show_menu(f"Step {idx+1}: {step_type}", move_choices, dialog_prog) + + if action == "edit": + available = get_available_variables(tool, idx) + if isinstance(step, PromptStep): + updated = edit_prompt_step(dialog_prog, step, available) + else: + updated = edit_code_step(dialog_prog, step, available) + if updated: + tool.steps[idx] = updated + elif action == "move_up" and idx > 0: + tool.steps[idx], tool.steps[idx-1] = tool.steps[idx-1], tool.steps[idx] + elif action == "move_down" and idx < len(tool.steps) - 1: + tool.steps[idx], tool.steps[idx+1] = tool.steps[idx+1], tool.steps[idx] + elif action == "delete": + if show_yesno("Delete", f"Delete step {idx+1}?", dialog_prog): + tool.steps.pop(idx) + + +def tool_builder(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]: + """Main tool builder interface with tabbed sections.""" + is_edit = existing is not None + + # Initialize tool + if existing: + tool = Tool( + name=existing.name, + description=existing.description, + arguments=list(existing.arguments), + steps=list(existing.steps), + output=existing.output + ) + else: + tool = Tool(name="", description="", arguments=[], steps=[], output="{input}") + + while True: + # Build main menu with section summaries + args_count = len(tool.arguments) + steps_count = len(tool.steps) + + # Info summary + name_display = tool.name or "(not set)" + info_summary = f"{name_display}" + if tool.arguments: + info_summary += f" | {args_count} arg{'s' if args_count != 1 else ''}" + + # Steps summary + if tool.steps: + step_types = [] + for s in tool.steps: + if isinstance(s, PromptStep): + step_types.append(f"P:{s.provider}") + else: + step_types.append("C") + steps_summary = " -> ".join(step_types) + else: + steps_summary = "(none)" + + choices = [ + ("info", f"[1] Info & Args : {info_summary}"), + ("steps", f"[2] Steps : {steps_summary}"), + ("---", "─" * 50), + ("preview", "Preview Full Summary"), + ("save", "Save Tool"), + ("cancel", "Cancel"), + ] + + title = f"Tool Builder: {tool.name}" if tool.name else "Tool Builder: New Tool" + selected = show_menu(title, choices, dialog_prog) + + if selected is None or selected == "cancel": + if show_yesno("Cancel", "Discard changes?", dialog_prog): + return None + continue + + elif selected == "info": + edit_tool_info(tool, is_edit, dialog_prog) + + elif selected == "steps": + edit_tool_steps(tool, dialog_prog) + + elif selected == "preview": + summary = format_tool_summary(tool) + show_message("Tool Summary", summary, dialog_prog) + + elif selected == "save": + if not tool.name: + show_message("Error", "Tool name is required. Go to Info & Args to set it.", dialog_prog) + continue + + if not is_edit and tool_exists(tool.name): + if not show_yesno("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", dialog_prog): + continue + + return tool + + +# ============ Main Menu Functions ============ + def ui_list_tools(dialog_prog: str): """Show list of tools.""" tools = list_tools() @@ -208,17 +637,42 @@ def ui_list_tools(dialog_prog: str): tool = load_tool(name) if tool: text += f" {name}: {tool.description or 'No description'}\n" - text += f" Provider: {tool.provider}\n\n" + if tool.arguments: + args = ", ".join(arg.flag for arg in tool.arguments) + text += f" Arguments: {args}\n" + if tool.steps: + step_info = [] + for step in tool.steps: + if isinstance(step, PromptStep): + step_info.append(f"PROMPT[{step.provider}]") + else: + step_info.append("CODE") + text += f" Steps: {' -> '.join(step_info)}\n" + text += "\n" show_message("Tools", text, dialog_prog) def ui_create_tool(dialog_prog: str): """Create a new tool.""" - tool = create_tool_form(dialog_prog) + tool = tool_builder(dialog_prog) if tool: path = save_tool(tool) - show_message("Success", f"Tool '{tool.name}' created!\n\nConfig: {path}\n\nRun it with: {tool.name} -i ", dialog_prog) + + # Build usage example + usage = f"{tool.name}" + for arg in tool.arguments: + if arg.default: + usage += f" [{arg.flag} <{arg.variable}>]" + else: + usage += f" {arg.flag} <{arg.variable}>" + usage += " < input.txt" + + show_message("Success", + f"Tool '{tool.name}' created!\n\n" + f"Config: {path}\n\n" + f"Usage: {usage}", + dialog_prog) def ui_edit_tool(dialog_prog: str): @@ -228,13 +682,18 @@ def ui_edit_tool(dialog_prog: str): show_message("Edit Tool", "No tools found.", dialog_prog) return - choices = [(name, load_tool(name).description or "No description") for name in tools] + choices = [] + for name in tools: + tool = load_tool(name) + desc = tool.description if tool else "No description" + choices.append((name, desc)) + selected = show_menu("Select Tool to Edit", choices, dialog_prog) if selected: existing = load_tool(selected) if existing: - tool = create_tool_form(dialog_prog, existing) + tool = tool_builder(dialog_prog, existing) if tool: save_tool(tool) show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog) @@ -247,7 +706,12 @@ def ui_delete_tool(dialog_prog: str): show_message("Delete Tool", "No tools found.", dialog_prog) return - choices = [(name, load_tool(name).description or "No description") for name in tools] + choices = [] + for name in tools: + tool = load_tool(name) + desc = tool.description if tool else "No description" + choices.append((name, desc)) + selected = show_menu("Select Tool to Delete", choices, dialog_prog) if selected: @@ -265,13 +729,17 @@ def ui_test_tool(dialog_prog: str): show_message("Test Tool", "No tools found.", dialog_prog) return - choices = [(name, load_tool(name).description or "No description") for name in tools] + choices = [] + for name in tools: + tool = load_tool(name) + desc = tool.description if tool else "No description" + choices.append((name, desc)) + selected = show_menu("Select Tool to Test", choices, dialog_prog) if selected: tool = load_tool(selected) if tool: - # Get test input test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog) if test_input: from .runner import run_tool @@ -284,11 +752,10 @@ def ui_test_tool(dialog_prog: str): show_prompt=False, verbose=False ) - show_message( - "Test Result", - f"Exit code: {code}\n\n--- Output ---\n{output[:500]}", - dialog_prog - ) + result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}" + if len(output) > 1000: + result_text += "\n... (truncated)" + show_message("Test Result", result_text, dialog_prog) def main_menu(dialog_prog: str): @@ -302,6 +769,7 @@ def main_menu(dialog_prog: str): ("edit", "Edit existing tool"), ("delete", "Delete tool"), ("test", "Test tool (mock provider)"), + ("providers", "Manage providers"), ("exit", "Exit"), ], dialog_prog @@ -319,15 +787,33 @@ def main_menu(dialog_prog: str): ui_delete_tool(dialog_prog) elif choice == "test": ui_test_tool(dialog_prog) + elif choice == "providers": + ui_manage_providers(dialog_prog) def run_ui(): """Entry point for the UI.""" + # Prefer urwid (has mouse support) + if _check_urwid(): + from .ui_urwid import run_ui as run_urwid_ui + run_urwid_ui() + return + + # Fallback to snack (BIOS-style) + if _check_snack(): + from .ui_snack import run_ui as run_snack_ui + run_snack_ui() + return + + # Fallback to dialog/whiptail dialog_prog = check_dialog() if not dialog_prog: - print("Error: Neither 'dialog' nor 'whiptail' found.", file=sys.stderr) - print("Install with: sudo apt install dialog", file=sys.stderr) + print("Error: No TUI library found.", file=sys.stderr) + print("Install one of:", file=sys.stderr) + print(" pip install urwid (recommended - has mouse support)", file=sys.stderr) + print(" sudo apt install python3-newt", file=sys.stderr) + print(" sudo apt install dialog", file=sys.stderr) sys.exit(1) try: @@ -335,7 +821,6 @@ def run_ui(): except KeyboardInterrupt: pass finally: - # Clear screen on exit subprocess.run(["clear"], check=False) diff --git a/src/smarttools/ui_snack.py b/src/smarttools/ui_snack.py new file mode 100644 index 0000000..ea6cf5b --- /dev/null +++ b/src/smarttools/ui_snack.py @@ -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() diff --git a/src/smarttools/ui_urwid.py b/src/smarttools/ui_urwid.py new file mode 100644 index 0000000..f900c55 --- /dev/null +++ b/src/smarttools/ui_urwid.py @@ -0,0 +1,1720 @@ +"""BIOS-style TUI for SmartTools using urwid (with mouse support).""" + +import urwid +from typing import Optional, List, Callable + +from .tool import ( + Tool, ToolArgument, PromptStep, CodeStep, Step, + list_tools, load_tool, save_tool, delete_tool, tool_exists, validate_tool_name +) +from .providers import Provider, load_providers, add_provider, delete_provider, get_provider + + +# Color palette - BIOS style +PALETTE = [ + ('body', 'white', 'dark blue'), + ('header', 'white', 'dark red', 'bold'), + ('footer', 'black', 'light gray'), + ('button', 'black', 'light gray'), + ('button_focus', 'white', 'dark red', 'bold'), + ('edit', 'black', 'light gray'), + ('edit_focus', 'black', 'yellow'), + ('listbox', 'black', 'light gray'), + ('listbox_focus', 'white', 'dark red'), + ('dialog', 'black', 'light gray'), + ('dialog_border', 'white', 'dark blue'), + ('label', 'yellow', 'dark blue', 'bold'), + ('error', 'white', 'dark red', 'bold'), + ('success', 'light green', 'dark blue', 'bold'), +] + + +class SelectableText(urwid.WidgetWrap): + """A selectable text widget for list items.""" + + def __init__(self, text, value=None, on_select=None): + self.value = value + self.on_select = on_select + self.text_widget = urwid.Text(text) + display = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') + super().__init__(display) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == 'enter' and self.on_select: + self.on_select(self.value) + return None + return key + + def mouse_event(self, size, event, button, col, row, focus): + if event == 'mouse press' and button == 1 and self.on_select: + self.on_select(self.value) + return True + return False + + +class ClickableButton(urwid.WidgetWrap): + """A button that responds to mouse clicks.""" + + def __init__(self, label, on_press=None, user_data=None): + self.on_press = on_press + self.user_data = user_data + button = urwid.Button(label) + if on_press: + urwid.connect_signal(button, 'click', self._handle_click) + display = urwid.AttrMap(button, 'button', 'button_focus') + super().__init__(display) + + def _handle_click(self, button): + if self.on_press: + self.on_press(self.user_data) + + +class SelectableToolItem(urwid.WidgetWrap): + """A selectable tool item that maintains selection state.""" + + def __init__(self, name, on_select=None): + self.name = name + self.on_select = on_select + self._selected = False + self.text_widget = urwid.Text(f" {name} ") + self.attr_map = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') + super().__init__(self.attr_map) + + def selectable(self): + return True + + def set_selected(self, selected): + """Set whether this item is the selected tool.""" + self._selected = selected + if self._selected: + self.attr_map.set_attr_map({None: 'listbox_focus'}) + else: + self.attr_map.set_attr_map({None: 'listbox'}) + + def keypress(self, size, key): + if key == 'enter' and self.on_select: + self.on_select(self.name) + return None + return key + + def mouse_event(self, size, event, button, col, row, focus): + if event == 'mouse press' and button == 1: + # Single click just selects/focuses - don't call on_select + # on_select is only called on Enter key (to edit) + return True + return False + + +class ToolListBox(urwid.ListBox): + """A ListBox that keeps arrow keys internal and passes Tab out.""" + + def __init__(self, body, on_focus_change=None): + super().__init__(body) + self.on_focus_change = on_focus_change + self._last_focus = None + + def keypress(self, size, key): + if key in ('up', 'down'): + # Handle arrow keys internally - navigate within list + result = super().keypress(size, key) + # Check if focus changed + self._check_focus_change() + return result + elif key == 'tab': + # Pass tab out to parent for focus cycling + return key + elif key == 'shift tab': + return key + else: + return super().keypress(size, key) + + def _check_focus_change(self): + """Check if focus changed and notify callback.""" + try: + current = self.focus + if current is not self._last_focus: + self._last_focus = current + if self.on_focus_change and isinstance(current, SelectableToolItem): + self.on_focus_change(current.name) + except (IndexError, TypeError): + pass + + def render(self, size, focus=False): + # Check focus on render too (for initial display) + if focus: + self._check_focus_change() + return super().render(size, focus) + + +class TabCyclePile(urwid.Pile): + """A Pile that uses Tab/Shift-Tab to cycle between specific positions.""" + + def __init__(self, widget_list, tab_positions=None): + super().__init__(widget_list) + # Positions in the pile that Tab should cycle between + self.tab_positions = tab_positions or [0] + self._current_tab_idx = 0 + + def keypress(self, size, key): + if key == 'tab': + # Move to next tab position + self._current_tab_idx = (self._current_tab_idx + 1) % len(self.tab_positions) + self.focus_position = self.tab_positions[self._current_tab_idx] + return None + elif key == 'shift tab': + # Move to previous tab position + self._current_tab_idx = (self._current_tab_idx - 1) % len(self.tab_positions) + self.focus_position = self.tab_positions[self._current_tab_idx] + return None + else: + return super().keypress(size, key) + + +class ToolBuilderLayout(urwid.WidgetWrap): + """Custom layout for tool builder that handles Tab cycling across all sections.""" + + def __init__(self, left_box, args_box, steps_box, args_section, steps_section, bottom_buttons, on_cancel=None): + self._current_section = 0 + self.on_cancel = on_cancel + + # Store references to LineBoxes for title highlighting + self.left_box = left_box + self.args_box = args_box + self.steps_box = steps_box + + # Build visual layout: left column and right column side by side + right_pile = urwid.Pile([ + ('weight', 1, args_section), + ('pack', urwid.Divider()), + ('weight', 1, steps_section), + ]) + + columns = urwid.Columns([ + ('weight', 1, left_box), + ('weight', 1, right_pile), + ], dividechars=1) + + main_pile = urwid.Pile([ + ('weight', 1, columns), + ('pack', urwid.Divider()), + ('pack', bottom_buttons), + ]) + + super().__init__(main_pile) + + # Set initial highlight + self._update_section_titles() + + def keypress(self, size, key): + if key == 'tab': + self._current_section = (self._current_section + 1) % 4 + self._focus_section(self._current_section) + self._update_section_titles() + return None + elif key == 'shift tab': + self._current_section = (self._current_section - 1) % 4 + self._focus_section(self._current_section) + self._update_section_titles() + return None + elif key == 'esc': + # Go back to main menu instead of exiting + if self.on_cancel: + self.on_cancel(None) + return None + else: + return super().keypress(size, key) + + def mouse_event(self, size, event, button, col, row, focus): + # Let the parent handle the mouse event first + result = super().mouse_event(size, event, button, col, row, focus) + + # After mouse click, detect which section has focus and update titles + if event == 'mouse press': + self._detect_current_section() + self._update_section_titles() + + return result + + def _detect_current_section(self): + """Detect which section currently has focus based on widget hierarchy.""" + main_pile = self._w + + # Check if bottom buttons have focus (position 2) + if main_pile.focus_position == 2: + self._current_section = 3 + return + + # Focus is on columns (position 0) + columns = main_pile.contents[0][0] + + if columns.focus_position == 0: + # Left box (Tool Info) + self._current_section = 0 + else: + # Right pile + right_pile = columns.contents[1][0] + if right_pile.focus_position == 0: + # Args section + self._current_section = 1 + else: + # Steps section + self._current_section = 2 + + def _update_section_titles(self): + """Update section titles to highlight the current one with markers.""" + # Section 0 = Tool Info, Section 1 = Arguments, Section 2 = Steps, Section 3 = buttons + if self._current_section == 0: + self.left_box.set_title('[ Tool Info ]') + self.args_box.set_title('Arguments') + self.steps_box.set_title('Execution Steps') + elif self._current_section == 1: + self.left_box.set_title('Tool Info') + self.args_box.set_title('[ Arguments ]') + self.steps_box.set_title('Execution Steps') + elif self._current_section == 2: + self.left_box.set_title('Tool Info') + self.args_box.set_title('Arguments') + self.steps_box.set_title('[ Execution Steps ]') + else: + # Buttons focused - no section highlighted + self.left_box.set_title('Tool Info') + self.args_box.set_title('Arguments') + self.steps_box.set_title('Execution Steps') + + def _focus_section(self, section_idx): + """Set focus to the specified section.""" + # Get the main pile + main_pile = self._w + + if section_idx == 0: + # Tool Info (left box) - focus columns, then left + main_pile.focus_position = 0 # columns + columns = main_pile.contents[0][0] + columns.focus_position = 0 # left box + elif section_idx == 1: + # Arguments section - focus columns, then right, then args + main_pile.focus_position = 0 # columns + columns = main_pile.contents[0][0] + columns.focus_position = 1 # right pile + right_pile = columns.contents[1][0] + right_pile.focus_position = 0 # args section + elif section_idx == 2: + # Steps section - focus columns, then right, then steps + main_pile.focus_position = 0 # columns + columns = main_pile.contents[0][0] + columns.focus_position = 1 # right pile + right_pile = columns.contents[1][0] + right_pile.focus_position = 2 # steps section (after divider) + elif section_idx == 3: + # Save/Cancel buttons + main_pile.focus_position = 2 # bottom buttons (after divider) + + +class Dialog(urwid.WidgetWrap): + """A dialog box overlay.""" + + def __init__(self, title, body, buttons, width=60, height=None): + # Title + title_widget = urwid.Text(('header', f' {title} '), align='center') + + # Buttons row + button_widgets = [] + for label, callback in buttons: + btn = ClickableButton(label, callback) + button_widgets.append(btn) + buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2) + buttons_centered = urwid.Padding(buttons_row, align='center', width='pack') + + # Check if body is a box widget (like ListBox) + is_box_widget = isinstance(body, urwid.ListBox) + + if is_box_widget: + # ListBox is a box widget - use directly with weight + pile = urwid.Pile([ + ('pack', title_widget), + ('pack', urwid.Divider('─')), + ('weight', 1, body), + ('pack', urwid.Divider('─')), + ('pack', buttons_centered), + ]) + else: + # Flow widget - wrap in Filler + body_padded = urwid.Padding(body, left=1, right=1) + body_filled = urwid.Filler(body_padded, valign='top') + pile = urwid.Pile([ + ('pack', title_widget), + ('pack', urwid.Divider('─')), + body_filled, + ('pack', urwid.Divider('─')), + ('pack', buttons_centered), + ]) + + # Box it + box = urwid.LineBox(pile, title='', title_align='center') + box = urwid.AttrMap(box, 'dialog') + + super().__init__(box) + + +class SmartToolsUI: + """Urwid-based UI for SmartTools with mouse support.""" + + def __init__(self): + self.loop = None + self.main_widget = None + self.overlay_stack = [] + + def run(self): + """Run the UI.""" + self.show_main_menu() + self.loop = urwid.MainLoop( + self.main_widget, + palette=PALETTE, + unhandled_input=self.handle_input, + handle_mouse=True # Enable mouse support! + ) + self.loop.run() + + def handle_input(self, key): + """Handle global key input.""" + if key in ('q', 'Q', 'esc'): + if self.overlay_stack: + self.close_overlay() + else: + raise urwid.ExitMainLoop() + + def refresh(self): + """Refresh the display.""" + if self.loop: + self.loop.draw_screen() + + def set_main(self, widget): + """Set the main widget.""" + self.main_widget = urwid.AttrMap(widget, 'body') + if self.loop: + self.loop.widget = self.main_widget + + def show_overlay(self, dialog, width=60, height=20): + """Show a dialog overlay.""" + overlay = urwid.Overlay( + dialog, + self.main_widget, + align='center', width=width, + valign='middle', height=height, + ) + self.overlay_stack.append(self.main_widget) + self.main_widget = overlay + if self.loop: + self.loop.widget = self.main_widget + + def close_overlay(self): + """Close the current overlay.""" + if self.overlay_stack: + self.main_widget = self.overlay_stack.pop() + if self.loop: + self.loop.widget = self.main_widget + + def message_box(self, title: str, message: str, callback=None): + """Show a message box.""" + def on_ok(_): + self.close_overlay() + if callback: + callback() + + body = urwid.Text(message) + dialog = Dialog(title, body, [("OK", on_ok)], width=50) + self.show_overlay(dialog, width=52, height=min(10 + message.count('\n'), 20)) + + def yes_no(self, title: str, message: str, on_yes=None, on_no=None): + """Show a yes/no dialog.""" + def handle_yes(_): + self.close_overlay() + if on_yes: + on_yes() + + def handle_no(_): + self.close_overlay() + if on_no: + on_no() + + body = urwid.Text(message) + dialog = Dialog(title, body, [("Yes", handle_yes), ("No", handle_no)], width=50) + self.show_overlay(dialog, width=52, height=10) + + def input_dialog(self, title: str, prompt: str, initial: str, callback: Callable[[str], None]): + """Show an input dialog.""" + edit = urwid.Edit(('label', f"{prompt}: "), initial) + edit = urwid.AttrMap(edit, 'edit', 'edit_focus') + + def on_ok(_): + value = edit.base_widget.edit_text + self.close_overlay() + callback(value) + + def on_cancel(_): + self.close_overlay() + + body = urwid.Filler(edit, valign='top') + dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)], width=50) + self.show_overlay(dialog, width=52, height=8) + + # ==================== Main Menu ==================== + + def show_main_menu(self): + """Show the main menu with tool list and info panel.""" + self._selected_tool_name = None + self._refresh_main_menu() + + def _refresh_main_menu(self): + """Refresh the main menu display.""" + tools = list_tools() + self._tools_list = tools + + # Tool list - arrows navigate within, doesn't pass focus out + tool_items = [] + for name in tools: + item = SelectableToolItem(name, on_select=self._on_tool_select) + tool_items.append(item) + + if not tools: + tool_items.append(urwid.Text(('label', " (no tools - click Create to add one) "))) + + self._tool_walker = urwid.SimpleFocusListWalker(tool_items) + tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus) + tool_box = urwid.LineBox(tool_listbox, title='Tools') + + # Action buttons - Tab navigates here from tool list + create_btn = ClickableButton("Create", lambda _: self._create_tool_before_selected()) + edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_tool()) + delete_btn = ClickableButton("Delete", lambda _: self._delete_selected_tool()) + test_btn = ClickableButton("Test", lambda _: self._test_selected_tool()) + providers_btn = ClickableButton("Providers", lambda _: self.manage_providers()) + + buttons_row = urwid.Columns([ + ('pack', create_btn), + ('pack', urwid.Text(" ")), + ('pack', edit_btn), + ('pack', urwid.Text(" ")), + ('pack', delete_btn), + ('pack', urwid.Text(" ")), + ('pack', test_btn), + ('pack', urwid.Text(" ")), + ('pack', providers_btn), + ]) + buttons_padded = urwid.Padding(buttons_row, align='left', left=1) + + # Info panel - shows details of selected tool (not focusable) + self._info_name = urwid.Text("") + self._info_desc = urwid.Text("") + self._info_args = urwid.Text("") + self._info_steps = urwid.Text("") + self._info_output = urwid.Text("") + + info_content = urwid.Pile([ + self._info_name, + self._info_desc, + urwid.Divider(), + self._info_args, + urwid.Divider(), + self._info_steps, + urwid.Divider(), + self._info_output, + ]) + info_filler = urwid.Filler(info_content, valign='top') + info_box = urwid.LineBox(info_filler, title='Tool Info') + + # Exit button at bottom + exit_btn = ClickableButton("EXIT", lambda _: self.exit_app()) + exit_centered = urwid.Padding(exit_btn, align='center', width=10) + + # Use a custom Pile that handles Tab to cycle between tool list and buttons + self._main_pile = TabCyclePile([ + ('weight', 1, tool_box), + ('pack', buttons_padded), + ('pack', urwid.Divider('─')), + ('weight', 2, info_box), + ('pack', urwid.Divider()), + ('pack', exit_centered), + ], tab_positions=[0, 1, 5]) # Tool list, buttons row, exit button + + # Header + header = urwid.Text(('header', ' SmartTools Manager '), align='center') + + # Footer + footer = urwid.Text(('footer', ' Arrow:Navigate list | Tab:Jump to buttons | Enter/Click:Select | Q:Quit '), align='center') + + frame = urwid.Frame(self._main_pile, header=header, footer=footer) + self.set_main(frame) + + # Update info for first tool if any + if tools: + self._on_tool_focus(tools[0]) + + def _create_tool_before_selected(self): + """Create a new tool (will appear in list based on name sorting).""" + self.create_tool() + + def _on_tool_focus(self, name): + """Called when a tool is focused/highlighted.""" + self._selected_tool_name = name + + # Update selection state on all tool items + if hasattr(self, '_tool_walker'): + for item in self._tool_walker: + if isinstance(item, SelectableToolItem): + item.set_selected(item.name == name) + + tool = load_tool(name) + + if tool: + self._info_name.set_text(('label', f"Name: {tool.name}")) + self._info_desc.set_text(f"Description: {tool.description or '(none)'}") + + if tool.arguments: + args_text = "Arguments:\n" + for arg in tool.arguments: + default = f" = {arg.default}" if arg.default else "" + args_text += f" {arg.flag} -> {{{arg.variable}}}{default}\n" + else: + args_text = "Arguments: (none)" + self._info_args.set_text(args_text.rstrip()) + + if tool.steps: + steps_text = "Execution Steps:\n" + for i, step in enumerate(tool.steps): + if isinstance(step, PromptStep): + steps_text += f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}\n" + else: + steps_text += f" {i+1}. CODE -> {{{step.output_var}}}\n" + else: + steps_text = "Execution Steps: (none)" + self._info_steps.set_text(steps_text.rstrip()) + + self._info_output.set_text(f"Output: {tool.output}") + else: + self._info_name.set_text("") + self._info_desc.set_text("") + self._info_args.set_text("") + self._info_steps.set_text("") + self._info_output.set_text("") + + def _on_tool_select(self, name): + """Called when a tool is selected (Enter/double-click).""" + # Edit the tool on select + tool = load_tool(name) + if tool: + self.tool_builder(tool) + + def _edit_selected_tool(self): + """Edit the currently selected tool.""" + if self._selected_tool_name: + tool = load_tool(self._selected_tool_name) + if tool: + self.tool_builder(tool) + else: + self.message_box("Edit", "No tool selected.") + + def _delete_selected_tool(self): + """Delete the currently selected tool.""" + if self._selected_tool_name: + name = self._selected_tool_name + def do_delete(): + delete_tool(name) + self._selected_tool_name = None + self.message_box("Deleted", f"Tool '{name}' deleted.", self._refresh_main_menu) + self.yes_no("Confirm", f"Delete tool '{name}'?", on_yes=do_delete) + else: + self.message_box("Delete", "No tool selected.") + + def _test_selected_tool(self): + """Test the currently selected tool.""" + if self._selected_tool_name: + tool = load_tool(self._selected_tool_name) + if tool: + self._test_tool(tool) + else: + self.message_box("Test", "No tool selected.") + + def exit_app(self): + """Exit the application.""" + raise urwid.ExitMainLoop() + + + # ==================== Tool Builder ==================== + + def create_tool(self): + """Create a new tool.""" + self.tool_builder(None) + + def tool_builder(self, existing: Optional[Tool]): + """Main tool builder interface.""" + 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}") + + # Store references for callbacks + self._current_tool = tool + self._is_edit = is_edit + self._selected_arg_idx = None + self._selected_step_idx = None + + self._show_tool_builder() + + def _save_tool_fields(self): + """Save current edit field values to the tool object.""" + if not hasattr(self, '_name_edit') or not hasattr(self, '_current_tool'): + return + + tool = self._current_tool + + # Save name (only if it's an edit widget, not a text label) + if not self._is_edit and hasattr(self._name_edit, 'base_widget'): + name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit + if hasattr(name_edit, 'edit_text'): + tool.name = name_edit.edit_text.strip() + + # Save description + if hasattr(self, '_desc_edit') and hasattr(self._desc_edit, 'base_widget'): + tool.description = self._desc_edit.base_widget.edit_text.strip() + + # Save output + if hasattr(self, '_output_edit') and hasattr(self._output_edit, 'base_widget'): + tool.output = self._output_edit.base_widget.edit_text.strip() + + def _show_tool_builder(self): + """Render the tool builder screen.""" + tool = self._current_tool + + # Create edit widgets + if self._is_edit: + name_widget = urwid.Text(('label', f"Name: {tool.name}")) + else: + name_widget = urwid.AttrMap(urwid.Edit(('label', "Name: "), tool.name), 'edit', 'edit_focus') + self._name_edit = name_widget + + self._desc_edit = urwid.AttrMap(urwid.Edit(('label', "Desc: "), tool.description), 'edit', 'edit_focus') + self._output_edit = urwid.AttrMap(urwid.Edit(('label', "Output: "), tool.output), 'edit', 'edit_focus') + + # Left column - fields + left_pile = urwid.Pile([ + ('pack', name_widget), + ('pack', urwid.Divider()), + ('pack', self._desc_edit), + ('pack', urwid.Divider()), + ('pack', self._output_edit), + ]) + left_box = urwid.LineBox(urwid.Filler(left_pile, valign='top'), title='Tool Info') + + # Arguments list + arg_items = [] + for i, arg in enumerate(tool.arguments): + text = f"{arg.flag} -> {{{arg.variable}}}" + item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_arg_activate(idx)) + item.name = i # Store index + arg_items.append(item) + if not arg_items: + arg_items.append(urwid.Text(('label', " (none) "))) + + self._arg_walker = urwid.SimpleFocusListWalker(arg_items) + args_listbox = ToolListBox(self._arg_walker, on_focus_change=self._on_arg_focus) + args_box = urwid.LineBox(args_listbox, title='Arguments') + + # Argument buttons + arg_add_btn = ClickableButton("Add", lambda _: self._add_argument_dialog()) + arg_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_arg()) + arg_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_arg()) + arg_buttons = urwid.Columns([ + ('pack', arg_add_btn), + ('pack', urwid.Text(" ")), + ('pack', arg_edit_btn), + ('pack', urwid.Text(" ")), + ('pack', arg_del_btn), + ]) + arg_buttons_padded = urwid.Padding(arg_buttons, align='left', left=1) + + # Args section (list + buttons) + args_section = urwid.Pile([ + ('weight', 1, args_box), + ('pack', arg_buttons_padded), + ]) + + # Steps list + step_items = [] + for i, step in enumerate(tool.steps): + if isinstance(step, PromptStep): + text = f"P:{step.provider} -> {{{step.output_var}}}" + else: + text = f"C: -> {{{step.output_var}}}" + item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_step_activate(idx)) + item.name = i # Store index + step_items.append(item) + if not step_items: + step_items.append(urwid.Text(('label', " (none) "))) + + self._step_walker = urwid.SimpleFocusListWalker(step_items) + steps_listbox = ToolListBox(self._step_walker, on_focus_change=self._on_step_focus) + steps_box = urwid.LineBox(steps_listbox, title='Execution Steps') + + # Step buttons + step_add_btn = ClickableButton("Add", lambda _: self._add_step_choice()) + step_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_step()) + step_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_step()) + step_buttons = urwid.Columns([ + ('pack', step_add_btn), + ('pack', urwid.Text(" ")), + ('pack', step_edit_btn), + ('pack', urwid.Text(" ")), + ('pack', step_del_btn), + ]) + step_buttons_padded = urwid.Padding(step_buttons, align='left', left=1) + + # Steps section (list + buttons) + steps_section = urwid.Pile([ + ('weight', 1, steps_box), + ('pack', step_buttons_padded), + ]) + + # Save/Cancel buttons + save_btn = ClickableButton("Save", self._on_save_tool) + cancel_btn = ClickableButton("Cancel", self._on_cancel_tool) + bottom_buttons = urwid.Columns([ + ('pack', save_btn), + ('pack', urwid.Text(" ")), + ('pack', cancel_btn), + ], dividechars=1) + bottom_buttons_centered = urwid.Padding(bottom_buttons, align='center', width='pack') + + # Use ToolBuilderLayout for proper Tab cycling + # Pass LineBoxes for title highlighting and on_cancel for Escape key + body = ToolBuilderLayout( + left_box, args_box, steps_box, + args_section, steps_section, bottom_buttons_centered, + on_cancel=self._on_cancel_tool + ) + + # Frame + title = f"Edit Tool: {tool.name}" if self._is_edit and tool.name else "New Tool" + header = urwid.Text(('header', f' {title} '), align='center') + footer = urwid.Text(('footer', ' Arrow:Navigate | Tab:Next section | Enter/Click:Select | Esc:Cancel '), align='center') + + frame = urwid.Frame(body, header=header, footer=footer) + self.set_main(frame) + + # Set initial selection + if tool.arguments: + self._selected_arg_idx = 0 + self._on_arg_focus(0) + if tool.steps: + self._selected_step_idx = 0 + self._on_step_focus(0) + + def _on_arg_focus(self, idx): + """Called when an argument is focused.""" + if isinstance(idx, int): + self._selected_arg_idx = idx + # Update selection display + if hasattr(self, '_arg_walker'): + for i, item in enumerate(self._arg_walker): + if isinstance(item, SelectableToolItem): + item.set_selected(i == idx) + + def _on_arg_activate(self, idx): + """Called when an argument is activated (Enter/click).""" + self._selected_arg_idx = idx + self._edit_argument_at(idx) + + def _on_step_focus(self, idx): + """Called when a step is focused.""" + if isinstance(idx, int): + self._selected_step_idx = idx + # Update selection display + if hasattr(self, '_step_walker'): + for i, item in enumerate(self._step_walker): + if isinstance(item, SelectableToolItem): + item.set_selected(i == idx) + + def _on_step_activate(self, idx): + """Called when a step is activated (Enter/click).""" + self._selected_step_idx = idx + self._edit_step_at(idx) + + def _edit_selected_arg(self): + """Edit the currently selected argument.""" + if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): + self._edit_argument_at(self._selected_arg_idx) + else: + self.message_box("Edit", "No argument selected.") + + def _delete_selected_arg(self): + """Delete the currently selected argument.""" + if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): + idx = self._selected_arg_idx + arg = self._current_tool.arguments[idx] + def do_delete(): + self._save_tool_fields() + self._current_tool.arguments.pop(idx) + self._selected_arg_idx = None + self._show_tool_builder() + self.yes_no("Delete", f"Delete argument {arg.flag}?", on_yes=do_delete) + else: + self.message_box("Delete", "No argument selected.") + + def _edit_selected_step(self): + """Edit the currently selected step.""" + if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): + self._edit_step_at(self._selected_step_idx) + else: + self.message_box("Edit", "No step selected.") + + def _delete_selected_step(self): + """Delete the currently selected step.""" + if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): + idx = self._selected_step_idx + def do_delete(): + self._save_tool_fields() + self._current_tool.steps.pop(idx) + self._selected_step_idx = None + self._show_tool_builder() + self.yes_no("Delete", f"Delete step {idx + 1}?", on_yes=do_delete) + else: + self.message_box("Delete", "No step selected.") + + def _edit_argument_at(self, idx): + """Edit argument at index.""" + arg = self._current_tool.arguments[idx] + self._do_edit_argument(idx) + + def _edit_step_at(self, idx): + """Edit step at index - opens the appropriate dialog based on step type.""" + # Save current field values before showing dialog + self._save_tool_fields() + + step = self._current_tool.steps[idx] + if isinstance(step, PromptStep): + self._add_prompt_dialog(step, idx) + else: + self._add_code_dialog(step, idx) + + def _add_argument_dialog(self): + """Show add argument dialog.""" + # Save current field values before showing dialog + self._save_tool_fields() + + flag_edit = urwid.Edit(('label', "Flag: "), "--") + var_edit = urwid.Edit(('label', "Variable: "), "") + default_edit = urwid.Edit(('label', "Default: "), "") + + def on_ok(_): + flag = flag_edit.edit_text.strip() + var = var_edit.edit_text.strip() + default = default_edit.edit_text.strip() or None + + if not flag: + return + if not var: + var = flag.lstrip("-").replace("-", "_") + + self._current_tool.arguments.append(ToolArgument( + flag=flag, variable=var, default=default, description="" + )) + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Pile([ + urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(var_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(default_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Add Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=14) + + def _edit_argument_dialog(self, idx): + """Show edit/delete argument dialog.""" + arg = self._current_tool.arguments[idx] + + def on_edit(_): + self.close_overlay() + self._do_edit_argument(idx) + + def on_delete(_): + self._current_tool.arguments.pop(idx) + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Text(f"Argument: {arg.flag} -> {{{arg.variable}}}") + dialog = Dialog("Edit Argument", body, [("Edit", on_edit), ("Delete", on_delete), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=10) + + def _do_edit_argument(self, idx): + """Edit an existing argument.""" + # Save current field values before showing dialog + self._save_tool_fields() + + arg = self._current_tool.arguments[idx] + + flag_edit = urwid.Edit(('label', "Flag: "), arg.flag) + var_edit = urwid.Edit(('label', "Variable: "), arg.variable) + default_edit = urwid.Edit(('label', "Default: "), arg.default or "") + + def on_ok(_): + arg.flag = flag_edit.edit_text.strip() + arg.variable = var_edit.edit_text.strip() + arg.default = default_edit.edit_text.strip() or None + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Pile([ + urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(var_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(default_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Edit Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=14) + + def _add_step_choice(self): + """Choose step type to add.""" + # Save current field values before showing dialog + self._save_tool_fields() + + def on_prompt(_): + self.close_overlay() + self._add_prompt_dialog() + + def on_code(_): + self.close_overlay() + self._add_code_dialog() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Text("Choose step type:") + dialog = Dialog("Add Step", body, [("Prompt", on_prompt), ("Code", on_code), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=45, height=9) + + def _get_available_vars(self, up_to=-1): + """Get available variables.""" + tool = self._current_tool + variables = ["input"] + for arg in tool.arguments: + variables.append(arg.variable) + if up_to == -1: + up_to = len(tool.steps) + for i, step in enumerate(tool.steps): + if i >= up_to: + break + variables.append(step.output_var) + return variables + + def _add_prompt_dialog(self, existing=None, idx=-1): + """Add/edit prompt step with provider dropdown and multiline prompt.""" + from .tool import get_tools_dir + + providers = load_providers() + provider_names = [p.name for p in providers] + if not provider_names: + provider_names = ["mock"] + current_provider = existing.provider if existing else provider_names[0] + + # Provider selector state + selected_provider = [current_provider] # Use list to allow mutation in closures + + # Provider dropdown button + provider_btn_text = urwid.Text(current_provider) + provider_btn = urwid.AttrMap( + urwid.Padding(provider_btn_text, left=1, right=1), + 'edit', 'edit_focus' + ) + + def show_provider_dropdown(_): + """Show provider selection popup with descriptions.""" + # Build provider lookup for descriptions + provider_lookup = {p.name: p.description for p in providers} + + # Description display (updates on focus change) + desc_text = urwid.Text("") + desc_box = urwid.AttrMap( + urwid.Padding(desc_text, left=1, right=1), + 'label' + ) + + def update_description(name): + """Update the description text for the focused provider.""" + desc = provider_lookup.get(name, "") + desc_text.set_text(('label', desc if desc else "No description")) + + def select_provider(name): + def callback(_): + selected_provider[0] = name + provider_btn_text.set_text(name) + self.close_overlay() + return callback + + # Create focusable buttons that update description on focus + class DescriptiveButton(urwid.Button): + def __init__(self, name, desc_callback): + super().__init__(name, on_press=select_provider(name)) + self._name = name + self._desc_callback = desc_callback + + def render(self, size, focus=False): + if focus: + self._desc_callback(self._name) + return super().render(size, focus) + + items = [] + for name in provider_names: + # Show short hint inline: "name | short_desc" + short_desc = provider_lookup.get(name, "") + # Extract just the key info (after the timing) + if "|" in short_desc: + short_desc = short_desc.split("|", 1)[1].strip()[:20] + else: + short_desc = short_desc[:20] + + label = f"{name:<18} {short_desc}" + btn = DescriptiveButton(name, update_description) + btn.set_label(label) + items.append(urwid.AttrMap(btn, 'button', 'button_focus')) + + # Set initial description + update_description(provider_names[0]) + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + + # Combine listbox with description footer + body = urwid.Pile([ + ('weight', 1, listbox), + ('pack', urwid.Divider('─')), + ('pack', desc_box), + ]) + + popup = Dialog("Select Provider", body, []) + self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) + + provider_select_btn = urwid.AttrMap( + urwid.Button("Select", on_press=show_provider_dropdown), + 'button', 'button_focus' + ) + + # File input for external prompt + default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt" + file_edit = urwid.Edit(('label', "File: "), default_file) + + # Multiline prompt editor + prompt_edit = urwid.Edit( + edit_text=existing.prompt if existing else "{input}", + multiline=True + ) + + output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "response") + + vars_available = self._get_available_vars(idx) + vars_text = urwid.Text(('label', f"Variables: {', '.join('{'+v+'}' for v in vars_available)}")) + + status_text = urwid.Text("") + + def do_load(): + """Actually load prompt from file.""" + filename = file_edit.edit_text.strip() + tool_dir = get_tools_dir() / self._current_tool.name + prompt_path = tool_dir / filename + + try: + prompt_edit.set_edit_text(prompt_path.read_text()) + status_text.set_text(('success', f"Loaded from {filename}")) + except Exception as e: + status_text.set_text(('error', f"Load error: {e}")) + + def on_load(_): + """Load prompt from file with confirmation.""" + filename = file_edit.edit_text.strip() + if not filename: + status_text.set_text(('error', "Enter a filename first")) + return + + tool_dir = get_tools_dir() / self._current_tool.name + prompt_path = tool_dir / filename + + if not prompt_path.exists(): + status_text.set_text(('error', f"File not found: {filename}")) + return + + # Show confirmation dialog + def on_yes(_): + self.close_overlay() + do_load() + + def on_no(_): + self.close_overlay() + + confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current prompt.") + confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) + self.show_overlay(confirm_dialog, width=50, height=8) + + def on_ok(_): + provider = selected_provider[0] + prompt = prompt_edit.edit_text.strip() + output_var = output_edit.edit_text.strip() or "response" + prompt_file = file_edit.edit_text.strip() or None + + # Auto-save to file if filename is set + if prompt_file: + tool_dir = get_tools_dir() / self._current_tool.name + tool_dir.mkdir(parents=True, exist_ok=True) + prompt_path = tool_dir / prompt_file + try: + prompt_path.write_text(prompt) + except Exception as e: + status_text.set_text(('error', f"Save error: {e}")) + return + + step = PromptStep(prompt=prompt, provider=provider, output_var=output_var, prompt_file=prompt_file) + + if existing and idx >= 0: + self._current_tool.steps[idx] = step + else: + self._current_tool.steps.append(step) + + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus') + + # Prompt editor in a box + prompt_box = urwid.LineBox( + urwid.Filler(urwid.AttrMap(prompt_edit, 'edit', 'edit_focus'), valign='top'), + title="Prompt" + ) + + body = urwid.Pile([ + vars_text, + urwid.Divider(), + urwid.Columns([ + ('pack', urwid.Text(('label', "Provider: "))), + ('weight', 1, provider_btn), + ('pack', urwid.Text(" ")), + ('pack', provider_select_btn), + ]), + urwid.Divider(), + urwid.Columns([ + ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), + ('pack', urwid.Text(" ")), + ('pack', load_btn), + ]), + status_text, + ('weight', 1, prompt_box), + urwid.Divider(), + urwid.AttrMap(output_edit, 'edit', 'edit_focus'), + ]) + + title = "Edit Prompt Step" if existing else "Add Prompt Step" + dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=70, height=22) + + def _add_code_dialog(self, existing=None, idx=-1): + """Add/edit code step with multiline editor and file support.""" + from .tool import get_tools_dir + + # File name input (default based on output_var) + default_output_var = existing.output_var if existing else "processed" + default_file = existing.code_file if existing and existing.code_file else f"{default_output_var}.py" + file_edit = urwid.Edit(('label', "File: "), default_file) + + # Multiline code editor (default uses output_var name) + default_code = existing.code if existing else f"{default_output_var} = input.upper()" + code_edit = urwid.Edit( + edit_text=default_code, + multiline=True + ) + + output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "processed") + + vars_available = self._get_available_vars(idx) + vars_text = urwid.Text(('label', f"Variables: {', '.join(vars_available)}")) + + status_text = urwid.Text("") + + def do_load(): + """Actually load code from file.""" + filename = file_edit.edit_text.strip() + tool_dir = get_tools_dir() / self._current_tool.name + code_path = tool_dir / filename + + try: + code_edit.set_edit_text(code_path.read_text()) + status_text.set_text(('success', f"Loaded from {filename}")) + except Exception as e: + status_text.set_text(('error', f"Load error: {e}")) + + def on_load(_): + """Load code from file with confirmation.""" + filename = file_edit.edit_text.strip() + if not filename: + status_text.set_text(('error', "Enter a filename first")) + return + + tool_dir = get_tools_dir() / self._current_tool.name + code_path = tool_dir / filename + + if not code_path.exists(): + status_text.set_text(('error', f"File not found: {filename}")) + return + + # Show confirmation dialog + def on_yes(_): + self.close_overlay() + do_load() + + def on_no(_): + self.close_overlay() + + confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current code.") + confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) + self.show_overlay(confirm_dialog, width=50, height=8) + + def on_ok(_): + import ast + + code = code_edit.edit_text.strip() + output_var = output_edit.edit_text.strip() or "processed" + code_file = file_edit.edit_text.strip() or None + + # Validate Python syntax + if code: + try: + ast.parse(code) + except SyntaxError as e: + line_info = f" (line {e.lineno})" if e.lineno else "" + status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}")) + return + + # Auto-save to file if filename is set + if code_file: + tool_dir = get_tools_dir() / self._current_tool.name + tool_dir.mkdir(parents=True, exist_ok=True) + code_path = tool_dir / code_file + try: + code_path.write_text(code) + except Exception as e: + status_text.set_text(('error', f"Save error: {e}")) + return + + step = CodeStep(code=code, output_var=output_var, code_file=code_file) + + if existing and idx >= 0: + self._current_tool.steps[idx] = step + else: + self._current_tool.steps.append(step) + + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus') + + # Code editor in a box with scrolling + code_box = urwid.LineBox( + urwid.Filler(urwid.AttrMap(code_edit, 'edit', 'edit_focus'), valign='top'), + title="Code" + ) + + body = urwid.Pile([ + vars_text, + urwid.Divider(), + urwid.Columns([ + ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), + ('pack', urwid.Text(" ")), + ('pack', load_btn), + ]), + status_text, + ('weight', 1, code_box), + urwid.Divider(), + urwid.AttrMap(output_edit, 'edit', 'edit_focus'), + ]) + + title = "Edit Code Step" if existing else "Add Code Step" + dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=70, height=20) + + def _edit_step_dialog(self, idx): + """Show edit/delete step dialog.""" + step = self._current_tool.steps[idx] + step_type = "Prompt" if isinstance(step, PromptStep) else "Code" + + def on_edit(_): + self.close_overlay() + if isinstance(step, PromptStep): + self._add_prompt_dialog(step, idx) + else: + self._add_code_dialog(step, idx) + + def on_delete(_): + self._current_tool.steps.pop(idx) + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Text(f"Step {idx+1}: {step_type}") + dialog = Dialog("Edit Step", body, [("Edit", on_edit), ("Delete", on_delete), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=45, height=10) + + def _on_save_tool(self, _): + """Save the tool.""" + tool = self._current_tool + + # Update from edits - widgets are wrapped in AttrMap, access base_widget + if not self._is_edit: + # Name edit is an AttrMap wrapping an Edit + name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit + if hasattr(name_edit, 'edit_text'): + tool.name = name_edit.edit_text.strip() + tool.description = self._desc_edit.base_widget.edit_text.strip() + tool.output = self._output_edit.base_widget.edit_text.strip() + + if not tool.name: + self.message_box("Error", "Tool name is required.") + return + + # Validate tool name + is_valid, error_msg = validate_tool_name(tool.name) + if not is_valid: + self.message_box("Error", error_msg) + return + + if not self._is_edit and tool_exists(tool.name): + def on_yes(): + save_tool(tool) + self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) + self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", on_yes=on_yes) + else: + save_tool(tool) + self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) + + def _on_cancel_tool(self, _): + """Cancel tool editing.""" + self.show_main_menu() + + # ==================== Tool Selection ==================== + + def select_edit_tool(self): + """Select a tool to edit.""" + tools = list_tools() + if not tools: + self.message_box("Edit Tool", "No tools found.") + return + + def on_select(name): + self.close_overlay() + tool = load_tool(name) + if tool: + self.tool_builder(tool) + + def on_cancel(_): + self.close_overlay() + + items = [] + for name in tools: + item = SelectableText(f" {name} ", value=name, on_select=on_select) + items.append(item) + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + dialog = Dialog("Select Tool to Edit", listbox, [("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=min(len(tools) + 8, 20)) + + def select_delete_tool(self): + """Select a tool to delete.""" + tools = list_tools() + if not tools: + self.message_box("Delete Tool", "No tools found.") + return + + def on_select(name): + self.close_overlay() + def do_delete(): + delete_tool(name) + self.message_box("Deleted", f"Tool '{name}' deleted.") + self.yes_no("Confirm", f"Delete tool '{name}'?", on_yes=do_delete) + + def on_cancel(_): + self.close_overlay() + + items = [] + for name in tools: + item = SelectableText(f" {name} ", value=name, on_select=on_select) + items.append(item) + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + dialog = Dialog("Select Tool to Delete", listbox, [("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=min(len(tools) + 8, 20)) + + def select_test_tool(self): + """Select a tool to test.""" + tools = list_tools() + if not tools: + self.message_box("Test Tool", "No tools found.") + return + + def on_select(name): + self.close_overlay() + tool = load_tool(name) + if tool: + self._test_tool(tool) + + def on_cancel(_): + self.close_overlay() + + items = [] + for name in tools: + item = SelectableText(f" {name} ", value=name, on_select=on_select) + items.append(item) + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + dialog = Dialog("Select Tool to Test", listbox, [("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=min(len(tools) + 8, 20)) + + def _test_tool(self, tool): + """Test a tool with mock input.""" + def on_input(text): + from .runner import run_tool + output, code = run_tool( + tool=tool, + input_text=text, + custom_args={}, + provider_override="mock", + dry_run=False, + show_prompt=False, + verbose=False + ) + result = f"Exit code: {code}\n\nOutput:\n{output[:300]}" + self.message_box("Test Result", result) + + self.input_dialog("Test Input", "Enter test input", "Hello world", on_input) + + def show_tools_list(self): + """Show list of all tools.""" + tools = list_tools() + if not tools: + self.message_box("Tools", "No tools found.") + return + + text = "" + for name in tools: + tool = load_tool(name) + if tool: + text += f"{name}: {tool.description or 'No description'}\n" + + self.message_box("Available Tools", text.strip()) + + # ==================== Provider Management ==================== + + def manage_providers(self): + """Manage providers.""" + self._show_providers_menu() + + def _show_providers_menu(self): + """Show providers management menu.""" + providers = load_providers() + self._selected_provider_name = None + self._provider_walker = None + + def on_provider_focus(name): + """Called when a provider is focused.""" + self._selected_provider_name = name + # Update selection state on all items + if self._provider_walker: + for item in self._provider_walker: + if isinstance(item, SelectableToolItem): + item.set_selected(item.name == name) + + def on_provider_activate(name): + """Called when Enter is pressed on a provider.""" + self.close_overlay() + self._edit_provider_menu(name) + + def on_add(_): + self.close_overlay() + self._add_provider_dialog() + + def on_edit(_): + if self._selected_provider_name: + self.close_overlay() + self._edit_provider_menu(self._selected_provider_name) + else: + self.message_box("Edit", "No provider selected.") + + def on_cancel(_): + self.close_overlay() + + # Build provider list + items = [] + for p in providers: + item = SelectableToolItem(f"{p.name}: {p.command}", on_select=on_provider_activate) + item.name = p.name # Store the actual provider name + items.append(item) + + if not items: + items.append(urwid.Text(('label', " (no providers) "))) + + self._provider_walker = urwid.SimpleFocusListWalker(items) + listbox = ToolListBox(self._provider_walker, on_focus_change=on_provider_focus) + listbox_box = urwid.LineBox(listbox, title='Providers') + + # Buttons row + add_btn = ClickableButton("Add", on_add) + edit_btn = ClickableButton("Edit", on_edit) + cancel_btn = ClickableButton("Cancel", on_cancel) + buttons = urwid.Columns([ + ('pack', add_btn), + ('pack', urwid.Text(" ")), + ('pack', edit_btn), + ('pack', urwid.Text(" ")), + ('pack', cancel_btn), + ]) + buttons_centered = urwid.Padding(buttons, align='center', width='pack') + + # Layout + body = urwid.Pile([ + ('weight', 1, listbox_box), + ('pack', urwid.Divider()), + ('pack', buttons_centered), + ]) + + # Wrap in a frame with title + header = urwid.Text(('header', ' Manage Providers '), align='center') + frame = urwid.Frame(body, header=header) + frame = urwid.LineBox(frame) + frame = urwid.AttrMap(frame, 'dialog') + + height = min(len(providers) + 10, 18) + self.show_overlay(frame, width=55, height=height) + + # Set initial selection + if providers: + self._selected_provider_name = providers[0].name + on_provider_focus(providers[0].name) + + def _add_provider_dialog(self): + """Add a new provider.""" + name_edit = urwid.Edit(('label', "Name: "), "") + cmd_edit = urwid.Edit(('label', "Command: "), "") + desc_edit = urwid.Edit(('label', "Description: "), "") + + def on_ok(_): + name = name_edit.edit_text.strip() + cmd = cmd_edit.edit_text.strip() + desc = desc_edit.edit_text.strip() + + if name and cmd: + add_provider(Provider(name=name, command=cmd, description=desc)) + self.close_overlay() + self.message_box("Success", f"Provider '{name}' added.", self._show_providers_menu) + else: + self.message_box("Error", "Name and command are required.") + + def on_cancel(_): + self.close_overlay() + self._show_providers_menu() + + body = urwid.Pile([ + urwid.AttrMap(name_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Add Provider", body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=55, height=14) + + def _edit_provider_menu(self, name): + """Edit a provider.""" + provider = get_provider(name) + if not provider: + return + + name_edit = urwid.Edit(('label', "Name: "), provider.name) + cmd_edit = urwid.Edit(('label', "Command: "), provider.command) + desc_edit = urwid.Edit(('label', "Description: "), provider.description or "") + + def on_save(_): + new_name = name_edit.edit_text.strip() + cmd = cmd_edit.edit_text.strip() + desc = desc_edit.edit_text.strip() + + if new_name and cmd: + # Delete old provider if name changed + if new_name != name: + delete_provider(name) + # Save with new/same name + add_provider(Provider(name=new_name, command=cmd, description=desc)) + self.close_overlay() + self.message_box("Success", f"Provider '{new_name}' saved.", self._show_providers_menu) + else: + self.message_box("Error", "Name and command are required.") + + def on_delete(_): + self.close_overlay() + def do_delete(): + delete_provider(name) + self.message_box("Deleted", f"Provider '{name}' deleted.", self._show_providers_menu) + self.yes_no("Confirm", f"Delete provider '{name}'?", on_yes=do_delete, on_no=self._show_providers_menu) + + def on_cancel(_): + self.close_overlay() + self._show_providers_menu() + + body = urwid.Pile([ + urwid.AttrMap(name_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Edit Provider", body, [("Save", on_save), ("Delete", on_delete), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=55, height=16) + + +def run_ui(): + """Entry point for the urwid UI.""" + ui = SmartToolsUI() + ui.run() + + +if __name__ == "__main__": + run_ui()