Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3081895850 | ||
|
|
da2027a8fd | ||
|
|
c755345a2f | ||
|
|
f052628a9e | ||
|
|
18240fe9f9 | ||
|
|
ae767de2d7 | ||
|
|
fcb3aaebce | ||
|
|
71b07c0d24 | ||
|
|
29b56b4805 | ||
|
|
8cdc9f281f | ||
|
|
1eb7f53ebc | ||
|
|
a180acdc40 | ||
|
|
b618cc84e7 | ||
|
|
737d8af3d8 | ||
|
|
32b77c3378 | ||
|
|
5d2bea0b85 |
@@ -1,35 +0,0 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info
|
||||
.eggs
|
||||
*.egg
|
||||
dist
|
||||
build
|
||||
.venv
|
||||
venv
|
||||
ENV
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.docker
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
.coverage
|
||||
.pytest_cache
|
||||
htmlcov
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
*.log
|
||||
@@ -1,80 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SmartTools is a lightweight personal tool builder for AI-powered CLI commands. It lets users create custom terminal commands that call AI providers, chain prompts with Python code steps, and use them like any Unix pipe command.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Install for development
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run a single test
|
||||
pytest tests/test.py::test_name
|
||||
|
||||
# Run the CLI
|
||||
python -m smarttools.cli
|
||||
|
||||
# Launch the UI
|
||||
smarttools ui
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Modules (`src/smarttools/`)
|
||||
|
||||
- **cli.py**: Entry point (`smarttools` command). Routes subcommands: list, create, edit, delete, test, run, ui, refresh
|
||||
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
|
||||
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
|
||||
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.smarttools/providers.yaml`
|
||||
- **ui.py**: UI dispatcher - selects between urwid and snack implementations
|
||||
- **ui_urwid.py**: Full TUI implementation using urwid library
|
||||
- **ui_snack.py**: Fallback TUI using python-newt/snack
|
||||
|
||||
### Key Paths
|
||||
|
||||
- **Tools storage**: `~/.smarttools/<toolname>/config.yaml`
|
||||
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
|
||||
- **Provider config**: `~/.smarttools/providers.yaml`
|
||||
|
||||
### Tool Structure
|
||||
|
||||
Tools are YAML configs with:
|
||||
- `name`, `description`, `category`
|
||||
- `arguments`: Custom flags with defaults (e.g., `--max` → `{max}`)
|
||||
- `steps`: Ordered list of `prompt` or `code` steps
|
||||
- `output`: Template for final output (e.g., `"{response}"`)
|
||||
|
||||
### Step Types
|
||||
|
||||
1. **Prompt Step**: Calls AI provider with template, stores result in `output_var`
|
||||
2. **Code Step**: Executes Python code via `exec()`, captures specified variables
|
||||
|
||||
### Variable Flow
|
||||
|
||||
Variables are passed between steps:
|
||||
- `{input}` - always available (stdin/file content)
|
||||
- `{argname}` - from tool arguments
|
||||
- `{step_output_var}` - from previous step's `output_var`
|
||||
|
||||
Variable substitution is simple string replacement in `runner.py:substitute_variables()`.
|
||||
|
||||
## Provider System
|
||||
|
||||
Providers are CLI tools that accept prompts via stdin and output to stdout. Defined in `~/.smarttools/providers.yaml`:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- name: claude
|
||||
command: "claude -p"
|
||||
- name: mock
|
||||
command: "echo '[MOCK]'"
|
||||
```
|
||||
|
||||
The `mock` provider is built-in for testing without API calls.
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
# SmartTools - AI-powered CLI command builder
|
||||
# Build: docker build -t smarttools .
|
||||
# Run: docker run -it --rm -v ~/.smarttools:/root/.smarttools smarttools
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer="rob"
|
||||
LABEL description="SmartTools - Personal AI-powered CLI command builder"
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
# Node.js for Claude CLI
|
||||
nodejs \
|
||||
npm \
|
||||
# Browser for OAuth authentication
|
||||
firefox-esr \
|
||||
# xdg-open for opening URLs (used by Claude CLI)
|
||||
xdg-utils \
|
||||
# X11 libraries for display
|
||||
libx11-6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ ./src/
|
||||
COPY examples/ ./examples/
|
||||
COPY docs/ ./docs/
|
||||
|
||||
# Install SmartTools and dependencies
|
||||
RUN pip install --no-cache-dir -e ".[dev]"
|
||||
|
||||
# Create smarttools directory
|
||||
RUN mkdir -p /root/.smarttools /root/.local/bin
|
||||
|
||||
# Install example tools
|
||||
RUN python examples/install.py
|
||||
|
||||
# Generate CLI wrappers
|
||||
RUN smarttools refresh
|
||||
|
||||
# Add local bin to PATH
|
||||
ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
# Default command - show help
|
||||
CMD ["smarttools", "--help"]
|
||||
|
||||
# For interactive use:
|
||||
# docker run -it --rm smarttools smarttools ui
|
||||
# docker run -it --rm smarttools bash
|
||||
@@ -1,560 +0,0 @@
|
||||
# SmartTools
|
||||
|
||||
**A lightweight personal tool builder for AI-powered CLI commands.**
|
||||
|
||||
Turn any AI model into a Unix-style pipe command. Build once, use forever.
|
||||
|
||||
```bash
|
||||
# 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"}
|
||||
```
|
||||
|
||||
## Why SmartTools?
|
||||
|
||||
- **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
|
||||
|
||||
## Try It Now (60 seconds)
|
||||
|
||||
See SmartTools in action without installing anything on your system:
|
||||
|
||||
```bash
|
||||
# Pull the container
|
||||
docker pull gitea.brrd.tech/rob/smarttools:latest
|
||||
|
||||
# Run it
|
||||
docker run -it --rm gitea.brrd.tech/rob/smarttools bash
|
||||
|
||||
# Inside the container - install OpenCode (includes free AI models)
|
||||
smarttools providers install # Select option 4, then 'y'
|
||||
source ~/.bashrc
|
||||
|
||||
# Try it! This explains the README using the free Big Pickle model
|
||||
cat README.md | eli5
|
||||
```
|
||||
|
||||
That's it - you just used AI to explain itself. The `eli5` tool uses a free model by default, no sign-up required.
|
||||
|
||||
## Quick Start (Native Install)
|
||||
|
||||
For regular use, install natively:
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
pip install -e .
|
||||
|
||||
# Ensure ~/.local/bin is in PATH
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Install an AI provider (interactive guide)
|
||||
smarttools providers install
|
||||
|
||||
# Launch the UI
|
||||
smarttools ui
|
||||
|
||||
# Or create your first tool
|
||||
smarttools create summarize
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Native Install
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### With Development Dependencies
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- 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
|
||||
```
|
||||
|
||||
### Composing Tools
|
||||
|
||||
SmartTools follows Unix philosophy: each tool does one thing well, and tools chain together for complex workflows.
|
||||
|
||||
```bash
|
||||
# Chain SmartTools together
|
||||
cat error.log | log-errors | summarize | translate --lang Spanish
|
||||
|
||||
# Code review pipeline: focus on changes, review, then summarize
|
||||
git diff | diff-focus | review-code | tldr
|
||||
|
||||
# Extract data, convert format, analyze
|
||||
cat report.pdf | json-extract --fields "revenue, costs, date" | json2csv | csv-insights
|
||||
|
||||
# Process text through multiple transformations
|
||||
cat technical_doc.txt | simplify | fix-grammar | tone-shift --tone friendly
|
||||
|
||||
# Generate code, validate it, then explain what it does
|
||||
echo "parse CSV and calculate averages" | code-validate | explain-code
|
||||
```
|
||||
|
||||
**Mix with standard Unix tools:**
|
||||
|
||||
```bash
|
||||
# Extract emails, deduplicate, count
|
||||
cat inbox.txt | extract-emails | sort -u | wc -l
|
||||
|
||||
# Find errors in multiple logs, explain them
|
||||
cat *.log | grep -i error | explain-error
|
||||
|
||||
# Generate changelog for specific author
|
||||
git log --author="rob" --oneline | changelog > CHANGELOG.md
|
||||
|
||||
# Review only large files in a commit
|
||||
git diff --stat | awk '$3 > 100 {print $1}' | xargs cat | review-code
|
||||
```
|
||||
|
||||
**Build shell functions for common pipelines:**
|
||||
|
||||
```bash
|
||||
# ~/.bashrc
|
||||
quick-review() {
|
||||
git diff "$@" | diff-focus | review-code --focus "bugs and security" | tldr
|
||||
}
|
||||
|
||||
translate-doc() {
|
||||
cat "$1" | summarize | translate --lang "$2"
|
||||
}
|
||||
|
||||
# Usage: quick-review HEAD~3
|
||||
# Usage: translate-doc manual.txt French
|
||||
```
|
||||
|
||||
## 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
|
||||
# Run the example installer (from the SmartTools directory)
|
||||
python examples/install.py
|
||||
smarttools refresh
|
||||
```
|
||||
|
||||
## Providers
|
||||
|
||||
SmartTools works with any AI CLI tool. We've profiled 12 providers:
|
||||
|
||||
| Provider | Speed | Accuracy | Cost | Best For |
|
||||
|----------|-------|----------|------|----------|
|
||||
| `opencode-deepseek` | 13s | 4/4 | Cheap | **Best value** - daily use |
|
||||
| `opencode-pickle` | 13s | 4/4 | FREE | **Best free** - accurate |
|
||||
| `claude-haiku` | 14s | 4/4 | Paid | Fast + accurate |
|
||||
| `codex` | 14s | 4/4 | Paid | Reliable |
|
||||
| `claude-opus` | 18s | 4/4 | $$$ | Highest quality |
|
||||
| `gemini` | 91s | 3/4 | Paid | Large docs (1M tokens) |
|
||||
|
||||
See [docs/PROVIDERS.md](docs/PROVIDERS.md) for setup instructions.
|
||||
|
||||
## Tool Anatomy
|
||||
|
||||
A tool is a YAML config file in `~/.smarttools/<name>/config.yaml`:
|
||||
|
||||
```yaml
|
||||
name: summarize
|
||||
description: Condense documents to key points
|
||||
category: Text # Optional: Text, Developer, Data, or Other
|
||||
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}"
|
||||
```
|
||||
|
||||
### Categories
|
||||
|
||||
Tools can be organized into categories for easier navigation in the UI:
|
||||
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| `Text` | Text processing (summarize, translate, grammar) |
|
||||
| `Developer` | Dev tools (explain-error, gen-tests, commit-msg) |
|
||||
| `Data` | Data extraction and conversion (json-extract, csv) |
|
||||
| `Other` | Default for uncategorized tools |
|
||||
|
||||
### Multi-Step Tools
|
||||
|
||||
Chain AI prompts with Python code for powerful workflows:
|
||||
|
||||
```yaml
|
||||
name: json-extract
|
||||
description: Extract data as validated JSON
|
||||
steps:
|
||||
# Step 1: AI extracts data
|
||||
- type: prompt
|
||||
prompt: "Extract {fields} as JSON from: {input}"
|
||||
provider: opencode-deepseek
|
||||
output_var: raw_json
|
||||
|
||||
# Step 2: Code validates JSON
|
||||
- type: code
|
||||
code: |
|
||||
import json
|
||||
try:
|
||||
parsed = json.loads(raw_json)
|
||||
validated = json.dumps(parsed, indent=2)
|
||||
except:
|
||||
validated = "ERROR: Invalid JSON"
|
||||
output_var: validated
|
||||
output: "{validated}"
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `{input}` | The piped/file input |
|
||||
| `{argname}` | Custom argument value |
|
||||
| `{step_output}` | Output from previous step |
|
||||
|
||||
## Shell Integration
|
||||
|
||||
### Git Hooks
|
||||
|
||||
```bash
|
||||
# .git/hooks/prepare-commit-msg
|
||||
#!/bin/bash
|
||||
git diff --cached | commit-msg > "$1"
|
||||
```
|
||||
|
||||
### Aliases
|
||||
|
||||
```bash
|
||||
# ~/.bashrc
|
||||
alias gc='git diff --staged | commit-msg'
|
||||
alias wtf='explain-error'
|
||||
alias fixme='fix-grammar'
|
||||
```
|
||||
|
||||
### Vim Integration
|
||||
|
||||
```vim
|
||||
" Fix grammar in selection
|
||||
vnoremap <leader>fg :!fix-grammar<CR>
|
||||
|
||||
" Explain selected code
|
||||
vnoremap <leader>ec :!explain-code<CR>
|
||||
```
|
||||
|
||||
## UI Navigation
|
||||
|
||||
| Action | Keys |
|
||||
|--------|------|
|
||||
| Cycle sections | `Tab` |
|
||||
| Go back | `Escape` |
|
||||
| Select | `Enter` or click |
|
||||
| Navigate | Arrow keys |
|
||||
| **Scroll content** | Mouse wheel |
|
||||
| **Scroll up** | Click top of scrollbar |
|
||||
| **Scroll down** | Click bottom of scrollbar |
|
||||
| **Page up/down** | Click middle of scrollbar |
|
||||
| **Select text** | `Shift` + mouse drag |
|
||||
| **Copy** | Terminal native (with Shift) |
|
||||
| **Paste** | `Ctrl+Shift+V` |
|
||||
| **Undo** (code editor) | `Alt+U` |
|
||||
| **Redo** (code editor) | `Alt+R` |
|
||||
|
||||
**Tips:**
|
||||
- Hold `Shift` while using mouse for terminal-native text selection.
|
||||
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
|
||||
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
|
||||
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`.
|
||||
- Use the `$EDITOR` button to open code or prompts in your external editor.
|
||||
|
||||
## AI-Assisted Code Generation
|
||||
|
||||
When adding or editing a **Code Step**, the dialog includes an AI assist panel:
|
||||
|
||||
```
|
||||
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐
|
||||
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │
|
||||
│ │ │ ┌─ Prompt ──────────────────────────┐ │
|
||||
│ │ │ │ Modify this code to... │ │
|
||||
│ │ │ │ {code} │ │
|
||||
│ │ │ └──────────────────────────────────┘ │
|
||||
│ │ │ ┌─ Output & Feedback ───────────────┐ │
|
||||
│ │ │ │ ✓ Code updated successfully! │ │
|
||||
│ │ │ └──────────────────────────────────┘ │
|
||||
│ │ │ < Auto-adjust > │
|
||||
└────────────────────┘ └───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Provider**: Select any configured AI provider
|
||||
- **Prompt**: Fully editable template - use `{code}` placeholder for current code
|
||||
- **Output**: Shows status, success/error messages, and provider feedback
|
||||
- **Auto-adjust**: Sends prompt to AI and replaces code with response
|
||||
|
||||
This lets you generate or modify Python code using AI directly within the tool builder.
|
||||
|
||||
## Philosophy
|
||||
|
||||
SmartTools is built on **Unix philosophy**:
|
||||
|
||||
1. **Do one thing well** - Each tool solves one problem. `summarize` summarizes. `translate` translates. No bloated mega-tools.
|
||||
|
||||
2. **Compose freely** - Tools read stdin, write stdout. Chain them: `cat doc.txt | summarize | translate --lang French`
|
||||
|
||||
3. **Text is the interface** - No proprietary formats. Pipe text in, get text out. Works with `grep`, `awk`, `sed`, and every other Unix tool.
|
||||
|
||||
4. **You own everything** - Tools are YAML files. Prompts are plain text. No vendor lock-in, no cloud dependency for your tool definitions.
|
||||
|
||||
5. **Swap parts freely** - Change AI providers per-tool or per-run. Today's best model might not be tomorrow's.
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/smarttools.git
|
||||
cd smarttools
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
If SmartTools saves you time, consider supporting the project:
|
||||
|
||||
**Bitcoin:** `3KeLqGv2Xo8jGYkqA1i68a6nXwgQnuJoza`
|
||||
|
||||
**Questions?** robdickson444@hotmail.com
|
||||
|
||||
## License
|
||||
|
||||
MIT - Use it, modify it, share it.
|
||||
|
||||
## Docker
|
||||
|
||||
### Pre-built Container (Fastest)
|
||||
|
||||
Pull and run the pre-built image directly:
|
||||
|
||||
```bash
|
||||
# Pull from registry
|
||||
docker pull gitea.brrd.tech/rob/smarttools:latest
|
||||
|
||||
# Run SmartTools
|
||||
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools --help
|
||||
|
||||
# List available tools
|
||||
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools list
|
||||
|
||||
# Interactive shell
|
||||
docker run -it --rm gitea.brrd.tech/rob/smarttools bash
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
Alternatively, build the container yourself:
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
|
||||
# Build the container
|
||||
docker-compose build
|
||||
|
||||
# Run tests
|
||||
docker-compose run --rm test
|
||||
|
||||
# Use the CLI
|
||||
docker-compose run --rm cli smarttools list
|
||||
|
||||
# Interactive shell
|
||||
docker-compose run --rm shell
|
||||
```
|
||||
|
||||
### Installing AI Providers in Docker
|
||||
|
||||
AI providers require browser-based authentication. You need display access for the browser to open.
|
||||
|
||||
**Using the pre-built container:**
|
||||
|
||||
```bash
|
||||
# Allow Docker to access your display (Linux)
|
||||
xhost +local:docker
|
||||
|
||||
# Run with display access and persistent storage
|
||||
docker run -it --rm \
|
||||
-e DISPLAY=$DISPLAY \
|
||||
-v /tmp/.X11-unix:/tmp/.X11-unix \
|
||||
-v smarttools-data:/root/.smarttools \
|
||||
--network host \
|
||||
gitea.brrd.tech/rob/smarttools bash
|
||||
|
||||
# Inside the container:
|
||||
smarttools providers install
|
||||
```
|
||||
|
||||
**Using docker-compose (build from source):**
|
||||
|
||||
```bash
|
||||
xhost +local:docker
|
||||
docker-compose run --rm setup
|
||||
|
||||
# Inside the container:
|
||||
smarttools providers install
|
||||
```
|
||||
|
||||
**After installing a provider:**
|
||||
|
||||
```bash
|
||||
# Test the provider works
|
||||
smarttools providers test claude
|
||||
```
|
||||
|
||||
**Available Providers:**
|
||||
| # | Provider | Cost | Notes |
|
||||
|---|----------|------|-------|
|
||||
| 1 | Claude | Pay-per-use | Anthropic account |
|
||||
| 2 | Codex | Pay-per-use | OpenAI account |
|
||||
| 3 | Gemini | Free tier | Google account |
|
||||
| 4 | OpenCode | Free tier | Multiple models |
|
||||
| 5 | Ollama | FREE | Local, no internet |
|
||||
|
||||
## Links
|
||||
|
||||
- [Installation Guide](docs/INSTALL.md)
|
||||
- [Provider Setup](docs/PROVIDERS.md)
|
||||
- [Example Tools](docs/EXAMPLES.md)
|
||||
- [Design Document](docs/DESIGN.md)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[237],{3363(e,t,i){i.d(t,{A:()=>a});i(6540);var o=i(4164),n=i(1312),s=i(1107),r=i(4848);function a({className:e}){return(0,r.jsx)("main",{className:(0,o.A)("container margin-vert--xl",e),children:(0,r.jsx)("div",{className:"row",children:(0,r.jsxs)("div",{className:"col col--6 col--offset-3",children:[(0,r.jsx)(s.A,{as:"h1",className:"hero__title",children:(0,r.jsx)(n.A,{id:"theme.NotFound.title",description:"The title of the 404 page",children:"Page Not Found"})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p1",description:"The first paragraph of the 404 page",children:"We could not find what you were looking for."})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p2",description:"The 2nd paragraph of the 404 page",children:"Please contact the owner of the site that linked you to the original URL and let them know their link is broken."})})]})})})}},2237(e,t,i){i.r(t),i.d(t,{default:()=>l});i(6540);var o=i(1312),n=i(5500),s=i(1656),r=i(3363),a=i(4848);function l(){const e=(0,o.T)({id:"theme.NotFound.title",message:"Page Not Found"});return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(n.be,{title:e}),(0,a.jsx)(s.A,{children:(0,a.jsx)(r.A,{})})]})}}}]);
|
||||
@@ -0,0 +1 @@
|
||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[854],{5253(e){e.exports=JSON.parse('{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"docs":[{"type":"link","href":"/rob/CmdForge/","label":"CmdForge Overview","docId":"overview","unlisted":false},{"type":"link","href":"/rob/CmdForge/architecture","label":"CmdForge Architecture","docId":"architecture","unlisted":false},{"type":"link","href":"/rob/CmdForge/goals","label":"Goals","docId":"goals","unlisted":false},{"type":"link","href":"/rob/CmdForge/ideas-and-exploration","label":"Ideas & Exploration","docId":"ideas-and-exploration","unlisted":false},{"type":"link","href":"/rob/CmdForge/milestones","label":"Milestones","docId":"milestones","unlisted":false}]},"docs":{"architecture":{"id":"architecture","title":"CmdForge Architecture","description":"Module Structure","sidebar":"docs"},"goals":{"id":"goals","title":"Goals","description":"Vision","sidebar":"docs"},"ideas-and-exploration":{"id":"ideas-and-exploration","title":"Ideas & Exploration","description":"Completed","sidebar":"docs"},"milestones":{"id":"milestones","title":"Milestones","description":"Active","sidebar":"docs"},"overview":{"id":"overview","title":"CmdForge Overview","description":"A lightweight personal tool builder for AI-powered CLI commands.","sidebar":"docs"}}}}')}}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[647],{7121(e,c,s){s.r(c),s.d(c,{default:()=>t});s(6540);var r=s(4164),u=s(7559),a=s(5500),l=s(2831),o=s(1656),p=s(4848);function t(e){return(0,p.jsx)(a.e3,{className:(0,r.A)(u.G.wrapper.docsPages),children:(0,p.jsx)(o.A,{children:(0,l.v)(e.route.routes)})})}}}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[98],{1723(n,e,s){s.r(e),s.d(e,{default:()=>d});s(6540);var r=s(5500);function o(n,e){return`docs-${n}-${e}`}var c=s(3025),t=s(2831),i=s(1463),u=s(4848);function l(n){const{version:e}=n;return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(i.A,{version:e.version,tag:o(e.pluginId,e.version)}),(0,u.jsx)(r.be,{children:e.noIndex&&(0,u.jsx)("meta",{name:"robots",content:"noindex, nofollow"})})]})}function a(n){const{version:e,route:s}=n;return(0,u.jsx)(r.e3,{className:e.className,children:(0,u.jsx)(c.n,{version:e,children:(0,t.v)(s.routes)})})}function d(n){return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(l,{...n}),(0,u.jsx)(a,{...n})]})}}}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[742],{7093(c){c.exports=JSON.parse('{"name":"docusaurus-plugin-content-docs","id":"default"}')}}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
||||
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
|
||||
* @license MIT */
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{"use strict";var e,r,t,a,o,n={},c={};function i(e){var r=c[e];if(void 0!==r)return r.exports;var t=c[e]={id:e,loaded:!1,exports:{}};return n[e].call(t.exports,t,t.exports,i),t.loaded=!0,t.exports}i.m=n,i.c=c,e=[],i.O=(r,t,a,o)=>{if(!t){var n=1/0;for(l=0;l<e.length;l++){for(var[t,a,o]=e[l],c=!0,f=0;f<t.length;f++)(!1&o||n>=o)&&Object.keys(i.O).every(e=>i.O[e](t[f]))?t.splice(f--,1):(c=!1,o<n&&(n=o));if(c){e.splice(l--,1);var d=a();void 0!==d&&(r=d)}}return r}o=o||0;for(var l=e.length;l>0&&e[l-1][2]>o;l--)e[l]=e[l-1];e[l]=[t,a,o]},i.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return i.d(r,{a:r}),r},t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__,i.t=function(e,a){if(1&a&&(e=this(e)),8&a)return e;if("object"==typeof e&&e){if(4&a&&e.__esModule)return e;if(16&a&&"function"==typeof e.then)return e}var o=Object.create(null);i.r(o);var n={};r=r||[null,t({}),t([]),t(t)];for(var c=2&a&&e;("object"==typeof c||"function"==typeof c)&&!~r.indexOf(c);c=t(c))Object.getOwnPropertyNames(c).forEach(r=>n[r]=()=>e[r]);return n.default=()=>e,i.d(o,n),o},i.d=(e,r)=>{for(var t in r)i.o(r,t)&&!i.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},i.f={},i.e=e=>Promise.all(Object.keys(i.f).reduce((r,t)=>(i.f[t](e,r),r),[])),i.u=e=>"assets/js/"+({48:"a94703ab",98:"a7bd4aaa",207:"e719f3dc",401:"17896441",413:"1db64337",443:"5281b7a2",574:"817f7194",647:"5e95c892",742:"aba21aa0",854:"3fbf7384",894:"5eebbccf"}[e]||e)+"."+{48:"c0ce3492",98:"3ba34601",207:"5bfd6599",237:"46c4f719",401:"a2525508",413:"ab5ebde9",443:"1c1a5adb",574:"070f961b",647:"a3b66919",742:"4a552a5c",854:"1561f6b5",894:"7d0f69ae"}[e]+".js",i.miniCssF=e=>{},i.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),a={},o="project-public-docs:",i.l=(e,r,t,n)=>{if(a[e])a[e].push(r);else{var c,f;if(void 0!==t)for(var d=document.getElementsByTagName("script"),l=0;l<d.length;l++){var u=d[l];if(u.getAttribute("src")==e||u.getAttribute("data-webpack")==o+t){c=u;break}}c||(f=!0,(c=document.createElement("script")).charset="utf-8",i.nc&&c.setAttribute("nonce",i.nc),c.setAttribute("data-webpack",o+t),c.src=e),a[e]=[r];var b=(r,t)=>{c.onerror=c.onload=null,clearTimeout(s);var o=a[e];if(delete a[e],c.parentNode&&c.parentNode.removeChild(c),o&&o.forEach(e=>e(t)),r)return r(t)},s=setTimeout(b.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=b.bind(null,c.onerror),c.onload=b.bind(null,c.onload),f&&document.head.appendChild(c)}},i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.p="/rob/CmdForge/",i.gca=function(e){return e={17896441:"401",a94703ab:"48",a7bd4aaa:"98",e719f3dc:"207","1db64337":"413","5281b7a2":"443","817f7194":"574","5e95c892":"647",aba21aa0:"742","3fbf7384":"854","5eebbccf":"894"}[e]||e,i.p+i.u(e)},(()=>{var e={354:0,869:0};i.f.j=(r,t)=>{var a=i.o(e,r)?e[r]:void 0;if(0!==a)if(a)t.push(a[2]);else if(/^(354|869)$/.test(r))e[r]=0;else{var o=new Promise((t,o)=>a=e[r]=[t,o]);t.push(a[2]=o);var n=i.p+i.u(r),c=new Error;i.l(n,t=>{if(i.o(e,r)&&(0!==(a=e[r])&&(e[r]=void 0),a)){var o=t&&("load"===t.type?"missing":t.type),n=t&&t.target&&t.target.src;c.message="Loading chunk "+r+" failed.\n("+o+": "+n+")",c.name="ChunkLoadError",c.type=o,c.request=n,a[1](c)}},"chunk-"+r,r)}},i.O.j=r=>0===e[r];var r=(r,t)=>{var a,o,[n,c,f]=t,d=0;if(n.some(r=>0!==e[r])){for(a in c)i.o(c,a)&&(i.m[a]=c[a]);if(f)var l=f(i)}for(r&&r(t);d<n.length;d++)o=n[d],i.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return i.O(l)},t=globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})()})();
|
||||
@@ -1,97 +0,0 @@
|
||||
# SmartTools - AI-powered CLI command builder
|
||||
#
|
||||
# Quick Start:
|
||||
# docker-compose build # Build the image
|
||||
# docker-compose run --rm test # Run tests
|
||||
# docker-compose run --rm cli smarttools list
|
||||
#
|
||||
# This is a standalone project with no external dependencies.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ============================================================================
|
||||
# Main CLI
|
||||
# ============================================================================
|
||||
cli:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
command: ["smarttools", "--help"]
|
||||
|
||||
# ============================================================================
|
||||
# Tests
|
||||
# ============================================================================
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
command: ["pytest", "-v"]
|
||||
|
||||
# ============================================================================
|
||||
# Interactive Shell
|
||||
# ============================================================================
|
||||
shell:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
command: ["/bin/bash"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
# ============================================================================
|
||||
# Setup (with display for browser-based auth)
|
||||
# ============================================================================
|
||||
# Use this for installing providers that need browser authentication
|
||||
# First run: xhost +local:docker
|
||||
setup:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
environment:
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
||||
command: ["/bin/bash"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
network_mode: host
|
||||
|
||||
volumes:
|
||||
smarttools-data:
|
||||
# Persists ~/.smarttools between container runs
|
||||
|
||||
# ==============================================================================
|
||||
# Usage Examples
|
||||
# ==============================================================================
|
||||
#
|
||||
# Build:
|
||||
# docker-compose build
|
||||
#
|
||||
# Run tests:
|
||||
# docker-compose run --rm test
|
||||
#
|
||||
# Use CLI:
|
||||
# docker-compose run --rm cli smarttools list
|
||||
# docker-compose run --rm cli smarttools run hello-world
|
||||
#
|
||||
# Interactive shell:
|
||||
# docker-compose run --rm shell
|
||||
#
|
||||
# Install providers (with browser auth):
|
||||
# xhost +local:docker
|
||||
# docker-compose run --rm setup
|
||||
# # Inside container:
|
||||
# smarttools providers install # Interactive guide
|
||||
# # Select a provider, it will install and open browser for auth
|
||||
-408
@@ -1,408 +0,0 @@
|
||||
# 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, steps, provider), then use it like any Linux command.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Create a summarizer tool, then use it:
|
||||
sum -i text.txt -o summary.txt --max 512
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Tool = Directory + Config
|
||||
|
||||
```
|
||||
~/.smarttools/
|
||||
sum/
|
||||
config.yaml
|
||||
processed.py # Optional external code file
|
||||
reviewer/
|
||||
config.yaml
|
||||
translator/
|
||||
config.yaml
|
||||
```
|
||||
|
||||
### config.yaml Format
|
||||
|
||||
```yaml
|
||||
name: sum
|
||||
description: "Summarize documents"
|
||||
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: claude
|
||||
output_var: response
|
||||
output: "{response}"
|
||||
```
|
||||
|
||||
### 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 (wrapper script in ~/.local/bin)
|
||||
sum -i document.txt -o summary.txt
|
||||
|
||||
# With custom args
|
||||
sum -i document.txt --max 200
|
||||
|
||||
# Preview prompt without calling AI
|
||||
sum -i document.txt --dry-run
|
||||
|
||||
# See what prompt gets sent
|
||||
sum -i document.txt --show-prompt
|
||||
|
||||
# Test with mock (no API call)
|
||||
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 |
|
||||
| `--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`) |
|
||||
| `--verbose` | `-v` | Show debug info |
|
||||
| `--help` | `-h` | Show help |
|
||||
|
||||
### Managing Tools
|
||||
|
||||
```bash
|
||||
# Launch the UI to manage tools
|
||||
smarttools
|
||||
|
||||
# Or use CLI directly:
|
||||
smarttools list # List all tools
|
||||
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
|
||||
```
|
||||
|
||||
## Terminal UI
|
||||
|
||||
A BIOS-style terminal UI using `urwid` with full mouse support.
|
||||
|
||||
### Main Menu
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ SmartTools Manager │
|
||||
├──────────────────────────────────────┤
|
||||
│ Tools: │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ sum │ │
|
||||
│ │ reviewer │ │
|
||||
│ │ translator │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Create] [Edit] [Delete] [Test] │
|
||||
│ │
|
||||
│ Tool Info: │
|
||||
│ Name: sum │
|
||||
│ Description: Summarize documents │
|
||||
│ Arguments: --max │
|
||||
│ Steps: PROMPT[claude] -> {response} │
|
||||
│ │
|
||||
│ [EXIT] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tool Builder
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 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] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Navigation:**
|
||||
- **Tab** - Cycle between sections (Tool Info -> Arguments -> Steps -> Buttons)
|
||||
- **Arrow keys** - Navigate within lists
|
||||
- **Enter/Click** - Select/activate
|
||||
- **Escape** - Cancel/back
|
||||
- **Shift + mouse** - Terminal-native text selection (bypasses UI mouse handling)
|
||||
- **Ctrl+Shift+V** - Paste
|
||||
|
||||
The current section's title is highlighted with brackets: `[ Tool Info ]`
|
||||
|
||||
**Copy/Paste tip:** Hold `Shift` while dragging with mouse to select text, then use your terminal's copy function (usually right-click or Ctrl+Shift+C).
|
||||
|
||||
### Code Step Dialog
|
||||
|
||||
```
|
||||
┌──────────────── Add Code Step ─────────────────┐
|
||||
│ Variables: input, max, response │
|
||||
│ │
|
||||
│ File: [processed.py______________] < Load > │
|
||||
│ │
|
||||
│ ┌─ Code ─────────────────────────────────────┐ │
|
||||
│ │processed = input.upper() │ │
|
||||
│ │count = len(processed.split()) │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Output var: [processed, count____] │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ < OK > < Cancel > │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Code Step Features:**
|
||||
- **Multiline editor** - Write multi-line Python code
|
||||
- **External file storage** - Code is auto-saved to `~/.smarttools/<toolname>/<filename>` on OK
|
||||
- **Load button** - Load code from external file (with confirmation)
|
||||
- **Multiple output vars** - Capture multiple variables (comma-separated)
|
||||
|
||||
## Implementation
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
smarttools/
|
||||
__init__.py
|
||||
cli.py # Entry point, argument parsing
|
||||
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
|
||||
```
|
||||
|
||||
### Provider System
|
||||
|
||||
Providers are defined in `~/.smarttools/providers.yaml`:
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
The provider command receives the prompt via stdin and outputs to stdout.
|
||||
|
||||
### Wrapper Scripts
|
||||
|
||||
When you save a tool, SmartTools creates a wrapper script in `~/.local/bin/`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# SmartTools wrapper for 'sum'
|
||||
# Auto-generated - do not edit
|
||||
exec /path/to/python -m smarttools.runner sum "$@"
|
||||
```
|
||||
|
||||
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 (`/\|&;$`"'<>(){}[]!?*#~`)
|
||||
|
||||
## Tool Composition
|
||||
|
||||
SmartTools are designed to chain together like any Unix command.
|
||||
|
||||
### External Pipelines (Tool-to-Tool)
|
||||
|
||||
```bash
|
||||
# Chain multiple SmartTools
|
||||
cat logs.txt | log-errors | summarize | translate --lang Japanese
|
||||
|
||||
# Mix with standard Unix tools
|
||||
git log --oneline | head -20 | changelog | tee CHANGELOG.md
|
||||
|
||||
# Build complex workflows
|
||||
cat *.py | review-code --focus security | json-extract --fields "issue, severity, file" | json2csv
|
||||
```
|
||||
|
||||
Each tool reads stdin and writes stdout. No special integration needed.
|
||||
|
||||
### Internal Pipelines (Multi-Step)
|
||||
|
||||
Within a single tool, chain steps for preprocessing/validation:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- type: code # Preprocess
|
||||
code: |
|
||||
filtered = '\n'.join(l for l in input.split('\n') if 'ERROR' in l)
|
||||
output_var: filtered
|
||||
- type: prompt # AI processes filtered input
|
||||
prompt: "Explain these errors: {filtered}"
|
||||
provider: claude-haiku
|
||||
output_var: explanation
|
||||
- type: code # Post-process
|
||||
code: |
|
||||
result = f"Found {len(filtered.split(chr(10)))} errors:\n\n{explanation}"
|
||||
output_var: result
|
||||
output: "{result}"
|
||||
```
|
||||
|
||||
**When to use which:**
|
||||
- **External pipelines**: Reusable tools, different providers per stage, standard Unix interop
|
||||
- **Internal pipelines**: Tightly coupled steps, shared context, validation of AI output
|
||||
|
||||
## What This Design Doesn't Include
|
||||
|
||||
Intentionally omitted (not needed for personal use):
|
||||
|
||||
- Trust tiers / security levels
|
||||
- Cryptographic signing
|
||||
- Container isolation / sandboxing
|
||||
- Certification testing
|
||||
- Distribution packaging
|
||||
- PII redaction
|
||||
- Audit logging
|
||||
- Provider capability negotiation
|
||||
|
||||
**Why?** This is a personal tool builder. You write the tools, you run the tools, you accept the responsibility. Just like any bash script you write.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Required:
|
||||
- Python 3.10+
|
||||
- PyYAML
|
||||
- urwid (for TUI)
|
||||
|
||||
Optional fallbacks:
|
||||
- python3-newt/snack (simpler TUI)
|
||||
- dialog/whiptail (basic TUI)
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. Run `smarttools` to open UI
|
||||
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` |
|
||||
-1058
File diff suppressed because it is too large
Load Diff
-249
@@ -1,249 +0,0 @@
|
||||
# Installation Guide
|
||||
|
||||
Complete installation instructions for SmartTools.
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
pip install -e .
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
smarttools providers install
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**With TUI and development dependencies:**
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### 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. The easiest way is the interactive installer:
|
||||
|
||||
```bash
|
||||
smarttools providers install
|
||||
```
|
||||
|
||||
This guides you through installing and authenticating providers.
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
**OpenCode (free tier available):**
|
||||
```bash
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
source ~/.bashrc # or start new shell
|
||||
opencode # Opens browser for sign-in
|
||||
```
|
||||
|
||||
**Claude CLI (pay-per-use):**
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
claude # Opens browser for sign-in
|
||||
```
|
||||
|
||||
**Codex (pay-per-use):**
|
||||
```bash
|
||||
npm install -g @openai/codex
|
||||
codex # Opens browser for sign-in
|
||||
```
|
||||
|
||||
**Gemini (free tier available):**
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
gemini # Opens browser for Google sign-in
|
||||
```
|
||||
|
||||
**Ollama (free, runs locally):**
|
||||
```bash
|
||||
curl -fsSL https://ollama.ai/install.sh | bash
|
||||
ollama pull llama3
|
||||
smarttools providers add ollama "ollama run llama3" -d "Local Llama 3"
|
||||
```
|
||||
|
||||
See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
|
||||
|
||||
### Step 5: Verify Installation
|
||||
|
||||
```bash
|
||||
# Check SmartTools is installed
|
||||
smarttools --version
|
||||
|
||||
# List configured providers
|
||||
smarttools providers list
|
||||
|
||||
# Check which providers are available
|
||||
smarttools providers check
|
||||
|
||||
# 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
|
||||
python examples/install.py
|
||||
smarttools refresh
|
||||
```
|
||||
|
||||
### Manual Install
|
||||
|
||||
Copy individual tool configs to `~/.smarttools/<toolname>/config.yaml`.
|
||||
|
||||
See [EXAMPLES.md](EXAMPLES.md) for all tool configurations.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "smarttools: command not found"
|
||||
|
||||
PATH is not configured. Add to your shell config:
|
||||
```bash
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
### "Provider 'X' not found"
|
||||
|
||||
1. Check provider is installed: `which claude` or `which opencode`
|
||||
2. Check provider is configured: `smarttools providers`
|
||||
3. Add missing provider: `smarttools providers add`
|
||||
|
||||
### "Permission denied" on tool execution
|
||||
|
||||
Refresh wrapper scripts:
|
||||
```bash
|
||||
smarttools refresh
|
||||
```
|
||||
|
||||
### TUI doesn't start / looks broken
|
||||
|
||||
Install urwid:
|
||||
```bash
|
||||
pip install urwid
|
||||
```
|
||||
|
||||
Ensure your terminal supports mouse (most modern terminals do).
|
||||
|
||||
### Tools work with `smarttools run` but not directly
|
||||
|
||||
The wrapper scripts may be outdated. Refresh them:
|
||||
```bash
|
||||
smarttools refresh
|
||||
```
|
||||
|
||||
### Python version mismatch
|
||||
|
||||
If you have multiple Python versions, ensure SmartTools uses the right one:
|
||||
```bash
|
||||
python3.11 -m pip install smarttools
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
pip uninstall smarttools
|
||||
|
||||
# Remove config and tools (optional)
|
||||
rm -rf ~/.smarttools
|
||||
|
||||
# Remove wrapper scripts (optional)
|
||||
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
pip install --upgrade smarttools
|
||||
smarttools refresh
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
After installation:
|
||||
|
||||
```
|
||||
~/.smarttools/
|
||||
├── providers.yaml # Provider configurations
|
||||
├── summarize/
|
||||
│ └── config.yaml # Tool config
|
||||
├── translate/
|
||||
│ ├── config.yaml
|
||||
│ └── prompt.txt # External prompt file (optional)
|
||||
└── ...
|
||||
|
||||
~/.local/bin/
|
||||
├── smarttools # Main command
|
||||
├── summarize # Tool wrapper script
|
||||
├── translate # Tool wrapper script
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. [Set up providers](PROVIDERS.md)
|
||||
2. [Browse example tools](EXAMPLES.md)
|
||||
3. Run `smarttools ui` to create your first tool
|
||||
@@ -1,328 +0,0 @@
|
||||
# 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-code
|
||||
```
|
||||
|
||||
**Authenticate:**
|
||||
```bash
|
||||
claude # Opens browser for sign-in (auto-saves auth tokens)
|
||||
```
|
||||
|
||||
**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 @openai/codex
|
||||
```
|
||||
|
||||
**Authenticate:**
|
||||
```bash
|
||||
codex # Opens browser for sign-in (auto-saves auth tokens)
|
||||
```
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
echo "Hello" | codex exec -
|
||||
```
|
||||
|
||||
### Gemini
|
||||
|
||||
Google's Gemini models. Best for large context (1M tokens).
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
**Authenticate:**
|
||||
```bash
|
||||
gemini # Opens browser for Google sign-in
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
### Interactive Installation (Recommended)
|
||||
|
||||
The easiest way to install providers:
|
||||
|
||||
```bash
|
||||
smarttools providers install
|
||||
```
|
||||
|
||||
This interactive guide:
|
||||
- Shows available AI providers with costs
|
||||
- Runs the installation command
|
||||
- Updates PATH automatically
|
||||
- Shows next steps for authentication
|
||||
|
||||
### List Providers
|
||||
|
||||
```bash
|
||||
smarttools providers list
|
||||
```
|
||||
|
||||
### Check Availability
|
||||
|
||||
```bash
|
||||
smarttools providers check
|
||||
```
|
||||
|
||||
### Add Custom Provider
|
||||
|
||||
```bash
|
||||
smarttools providers add myname "my-command --args" -d "Description"
|
||||
```
|
||||
|
||||
Or edit `~/.smarttools/providers.yaml`:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- name: my-custom
|
||||
command: my-ai-tool --prompt
|
||||
description: My custom AI tool
|
||||
```
|
||||
|
||||
### Provider Command Format
|
||||
|
||||
The command should:
|
||||
1. Accept input via stdin
|
||||
2. Output response to stdout
|
||||
3. Exit 0 on success
|
||||
|
||||
Example commands:
|
||||
```bash
|
||||
# Claude
|
||||
claude -p
|
||||
|
||||
# OpenCode
|
||||
$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat
|
||||
|
||||
# Gemini
|
||||
gemini --model gemini-2.5-flash
|
||||
|
||||
# Codex
|
||||
codex exec -
|
||||
|
||||
# Custom (any tool that reads stdin)
|
||||
my-tool --input -
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Provider commands can use environment variables:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- name: opencode
|
||||
command: $HOME/.opencode/bin/opencode run
|
||||
```
|
||||
|
||||
`$HOME` and `~` are expanded automatically.
|
||||
|
||||
## Using Providers in Tools
|
||||
|
||||
### In Tool Config
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- type: prompt
|
||||
prompt: "Summarize: {input}"
|
||||
provider: opencode-pickle # Use this provider
|
||||
output_var: response
|
||||
```
|
||||
|
||||
### Override at Runtime
|
||||
|
||||
```bash
|
||||
# Use a different provider for this run
|
||||
cat file.txt | summarize --provider claude-opus
|
||||
```
|
||||
|
||||
### Provider Selection Strategy
|
||||
|
||||
1. **Tool default** - Set in tool's config.yaml
|
||||
2. **Runtime override** - `--provider` flag
|
||||
3. **Cost optimization** - Use cheap providers for simple tasks
|
||||
4. **Quality needs** - Use opus/sonnet for important work
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Provider 'X' not found"
|
||||
|
||||
1. Check it's in your providers list: `smarttools providers`
|
||||
2. Verify the command works: `echo "test" | <command>`
|
||||
3. Add it: `smarttools providers add`
|
||||
|
||||
### "Command 'X' not found"
|
||||
|
||||
The AI CLI tool isn't installed or not in PATH:
|
||||
```bash
|
||||
which claude # Should show path
|
||||
which opencode # Might need full path
|
||||
```
|
||||
|
||||
For OpenCode, use full path in provider:
|
||||
```yaml
|
||||
command: $HOME/.opencode/bin/opencode run
|
||||
```
|
||||
|
||||
### Slow Provider
|
||||
|
||||
- Use `gemini-flash` instead of `gemini`
|
||||
- Use `claude-haiku` instead of `claude-opus`
|
||||
- Use `opencode-deepseek` for best speed/quality ratio
|
||||
|
||||
### Provider Errors
|
||||
|
||||
Check the provider works directly:
|
||||
```bash
|
||||
echo "Say hello" | claude -p
|
||||
echo "Say hello" | ~/.opencode/bin/opencode run
|
||||
```
|
||||
|
||||
If it works directly but not in SmartTools, check:
|
||||
1. Provider command in `~/.smarttools/providers.yaml`
|
||||
2. Environment variables are expanded correctly
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
### Free Providers
|
||||
|
||||
- `opencode-pickle` - Big Pickle model (FREE, accurate)
|
||||
- `opencode-grok` - Grok Code (FREE, fast but less reliable)
|
||||
|
||||
### Cheap Providers
|
||||
|
||||
- `opencode-deepseek` - ~$0.28/M tokens
|
||||
- `opencode-reasoner` - ~$0.28/M tokens
|
||||
- `claude-haiku` - ~$0.25/M tokens
|
||||
|
||||
### Tips
|
||||
|
||||
1. Use `opencode-pickle` for simple tasks (free + accurate)
|
||||
2. Use `claude-haiku` when you need reliability
|
||||
3. Reserve `claude-opus` for important work
|
||||
4. Use `gemini` only for large document analysis
|
||||
|
||||
## Adding New Providers
|
||||
|
||||
Any CLI tool that:
|
||||
- Reads from stdin
|
||||
- Writes to stdout
|
||||
- Exits 0 on success
|
||||
|
||||
Can be a provider. Examples:
|
||||
|
||||
**Local LLM (Ollama):**
|
||||
```yaml
|
||||
- name: ollama-llama
|
||||
command: ollama run llama3
|
||||
description: Local Llama 3
|
||||
```
|
||||
|
||||
**Custom API wrapper:**
|
||||
```yaml
|
||||
- name: my-api
|
||||
command: curl -s -X POST https://my-api.com/chat -d @-
|
||||
description: My custom API
|
||||
```
|
||||
|
||||
**Python script:**
|
||||
```yaml
|
||||
- name: my-python
|
||||
command: python3 ~/scripts/my_ai.py
|
||||
description: Custom Python AI
|
||||
```
|
||||
@@ -1,342 +0,0 @@
|
||||
#!/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://gitea.brrd.tech/rob/smarttools/raw/branch/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()
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+55
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,63 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "smarttools"
|
||||
version = "0.1.0"
|
||||
description = "A lightweight personal tool builder for AI-powered CLI commands"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Rob"}
|
||||
]
|
||||
keywords = ["ai", "cli", "tools", "automation", "llm", "terminal", "unix", "pipe"]
|
||||
classifiers = [
|
||||
"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]
|
||||
smarttools = "smarttools.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.brrd.tech/rob/smarttools"
|
||||
Documentation = "https://gitea.brrd.tech/rob/smarttools#readme"
|
||||
Repository = "https://gitea.brrd.tech/rob/smarttools.git"
|
||||
Issues = "https://gitea.brrd.tech/rob/smarttools/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://pages.brrd.tech/rob/CmdForge/architecture/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/goals/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/ideas-and-exploration/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/milestones/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
|
||||
@@ -1,3 +0,0 @@
|
||||
"""SmartTools - A lightweight personal tool builder for AI-powered CLI commands."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,735 +0,0 @@
|
||||
"""CLI entry point for SmartTools."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolArgument, PromptStep, CodeStep
|
||||
from .ui import run_ui
|
||||
from .providers import load_providers, add_provider, delete_provider, Provider, call_provider
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
"""List all tools."""
|
||||
tools = list_tools()
|
||||
|
||||
if not tools:
|
||||
print("No tools found.")
|
||||
print("Create your first tool with: smarttools ui")
|
||||
return 0
|
||||
|
||||
print(f"Available tools ({len(tools)}):\n")
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
print(f" {name}")
|
||||
print(f" {tool.description or 'No description'}")
|
||||
|
||||
# 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 (basic CLI creation - use 'ui' for full builder)."""
|
||||
name = args.name
|
||||
|
||||
# Check if already exists
|
||||
existing = load_tool(name)
|
||||
if existing and not args.force:
|
||||
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
|
||||
return 1
|
||||
|
||||
# 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 "",
|
||||
arguments=[],
|
||||
steps=steps,
|
||||
output="{response}" if steps else "{input}"
|
||||
)
|
||||
|
||||
path = save_tool(tool)
|
||||
print(f"Created tool '{name}'")
|
||||
print(f"Config: {path}")
|
||||
print(f"\nUse 'smarttools ui' to add arguments, steps, and customize.")
|
||||
print(f"Or run: {name} < input.txt")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_edit(args):
|
||||
"""Edit a tool (opens in $EDITOR or nano)."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
tool = load_tool(args.name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
from .tool import get_tools_dir
|
||||
config_path = get_tools_dir() / args.name / "config.yaml"
|
||||
|
||||
editor = os.environ.get("EDITOR", "nano")
|
||||
|
||||
try:
|
||||
subprocess.run([editor, str(config_path)], check=True)
|
||||
print(f"Tool '{args.name}' updated.")
|
||||
return 0
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Error: Editor failed.")
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_delete(args):
|
||||
"""Delete a tool."""
|
||||
if not load_tool(args.name):
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
if not args.force:
|
||||
confirm = input(f"Delete tool '{args.name}'? [y/N] ")
|
||||
if confirm.lower() != 'y':
|
||||
print("Cancelled.")
|
||||
return 0
|
||||
|
||||
if delete_tool(args.name):
|
||||
print(f"Deleted tool '{args.name}'.")
|
||||
return 0
|
||||
else:
|
||||
print(f"Error: Failed to delete '{args.name}'.")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_test(args):
|
||||
"""Test a tool with mock provider."""
|
||||
tool = load_tool(args.name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
from .runner import run_tool
|
||||
|
||||
# Read test input
|
||||
if args.input:
|
||||
from pathlib import Path
|
||||
input_text = Path(args.input).read_text()
|
||||
else:
|
||||
print("Enter test input (Ctrl+D to end):")
|
||||
input_text = sys.stdin.read()
|
||||
|
||||
print("\n--- Running with mock provider ---\n")
|
||||
|
||||
output, code = run_tool(
|
||||
tool=tool,
|
||||
input_text=input_text,
|
||||
custom_args={},
|
||||
provider_override="mock",
|
||||
dry_run=args.dry_run,
|
||||
show_prompt=True,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
if output:
|
||||
print("\n--- Output ---\n")
|
||||
print(output)
|
||||
|
||||
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 cmd_docs(args):
|
||||
"""View or edit tool documentation."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .tool import get_tools_dir
|
||||
|
||||
tool = load_tool(args.name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
readme_path = get_tools_dir() / args.name / "README.md"
|
||||
|
||||
if args.edit:
|
||||
# Edit/create README
|
||||
editor = os.environ.get("EDITOR", "nano")
|
||||
|
||||
# Create a template if README doesn't exist
|
||||
if not readme_path.exists():
|
||||
template = f"""# {args.name}
|
||||
|
||||
{tool.description or 'No description provided.'}
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
echo "input" | {args.name}
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
"""
|
||||
for arg in tool.arguments:
|
||||
template += f"| `{arg.flag}` | {arg.default or ''} | {arg.description or ''} |\n"
|
||||
|
||||
template += """
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Example 1
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- List any dependencies here
|
||||
"""
|
||||
readme_path.write_text(template)
|
||||
print(f"Created template: {readme_path}")
|
||||
|
||||
try:
|
||||
subprocess.run([editor, str(readme_path)], check=True)
|
||||
print(f"Documentation updated: {readme_path}")
|
||||
return 0
|
||||
except subprocess.CalledProcessError:
|
||||
print("Error: Editor failed.")
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
|
||||
return 1
|
||||
else:
|
||||
# View README
|
||||
if not readme_path.exists():
|
||||
print(f"No documentation found for '{args.name}'.")
|
||||
print(f"Create it with: smarttools docs {args.name} --edit")
|
||||
return 1
|
||||
|
||||
print(readme_path.read_text())
|
||||
return 0
|
||||
|
||||
|
||||
PROVIDER_INSTALL_INFO = {
|
||||
"claude": {
|
||||
"group": "Anthropic Claude",
|
||||
"install_cmd": "npm install -g @anthropic-ai/claude-code",
|
||||
"requires": "Node.js 18+ and npm",
|
||||
"setup": "Run 'claude' - opens browser for sign-in (auto-saves auth tokens)",
|
||||
"cost": "Pay-per-use (billed to your Anthropic account)",
|
||||
"variants": ["claude", "claude-haiku", "claude-opus", "claude-sonnet"],
|
||||
},
|
||||
"codex": {
|
||||
"group": "OpenAI Codex",
|
||||
"install_cmd": "npm install -g @openai/codex",
|
||||
"requires": "Node.js 18+ and npm",
|
||||
"setup": "Run 'codex' - opens browser for sign-in (auto-saves auth tokens)",
|
||||
"cost": "Pay-per-use (billed to your OpenAI account)",
|
||||
"variants": ["codex"],
|
||||
},
|
||||
"gemini": {
|
||||
"group": "Google Gemini",
|
||||
"install_cmd": "npm install -g @google/gemini-cli",
|
||||
"requires": "Node.js 18+ and npm",
|
||||
"setup": "Run 'gemini' - opens browser for Google sign-in",
|
||||
"cost": "Free tier available, pay-per-use for more",
|
||||
"variants": ["gemini", "gemini-flash"],
|
||||
},
|
||||
"opencode": {
|
||||
"group": "OpenCode (75+ providers)",
|
||||
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
|
||||
"requires": "curl, bash",
|
||||
"setup": "Run 'opencode' - opens browser to connect more providers",
|
||||
"cost": "4 FREE models included (Big Pickle, GLM-4.7, Grok Code Fast 1, MiniMax M2.1), 75+ more available",
|
||||
"variants": ["opencode-pickle", "opencode-deepseek", "opencode-nano", "opencode-reasoner", "opencode-grok"],
|
||||
},
|
||||
"ollama": {
|
||||
"group": "Ollama (Local LLMs)",
|
||||
"install_cmd": "curl -fsSL https://ollama.ai/install.sh | bash",
|
||||
"requires": "curl, bash, 8GB+ RAM (GPU recommended)",
|
||||
"setup": "Run 'ollama pull llama3' to download a model, then add provider",
|
||||
"cost": "FREE (runs entirely on your machine)",
|
||||
"variants": [],
|
||||
"custom": True,
|
||||
"post_install_note": "After installing, add the provider:\n smarttools providers add ollama 'ollama run llama3' -d 'Local Llama 3'",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def cmd_providers(args):
|
||||
"""Manage AI providers."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if args.providers_cmd == "install":
|
||||
print("=" * 60)
|
||||
print("SmartTools Provider Installation Guide")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Check what's already installed
|
||||
providers = load_providers()
|
||||
installed_groups = set()
|
||||
for p in providers:
|
||||
if p.name.lower() == "mock":
|
||||
continue
|
||||
cmd_parts = p.command.split()[0]
|
||||
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
|
||||
if shutil.which(cmd_expanded) or Path(cmd_expanded).exists():
|
||||
# Find which group this belongs to
|
||||
for group, info in PROVIDER_INSTALL_INFO.items():
|
||||
if p.name in info.get("variants", []):
|
||||
installed_groups.add(group)
|
||||
|
||||
# Show available provider groups
|
||||
print("Available AI Provider Groups:\n")
|
||||
options = []
|
||||
for i, (key, info) in enumerate(PROVIDER_INSTALL_INFO.items(), 1):
|
||||
status = "[INSTALLED]" if key in installed_groups else ""
|
||||
print(f" {i}. {info['group']} {status}")
|
||||
print(f" Cost: {info['cost']}")
|
||||
print(f" Models: {', '.join(info['variants']) if info['variants'] else 'Custom'}")
|
||||
print()
|
||||
options.append(key)
|
||||
|
||||
print(" 0. Cancel")
|
||||
print()
|
||||
|
||||
try:
|
||||
choice = input("Select a provider to install (1-{}, or 0 to cancel): ".format(len(options)))
|
||||
choice = int(choice)
|
||||
except (ValueError, EOFError):
|
||||
print("Cancelled.")
|
||||
return 0
|
||||
|
||||
if choice == 0 or choice > len(options):
|
||||
print("Cancelled.")
|
||||
return 0
|
||||
|
||||
selected = options[choice - 1]
|
||||
info = PROVIDER_INSTALL_INFO[selected]
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Installing: {info['group']}")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f"Requirements: {info['requires']}")
|
||||
print(f"Install command: {info['install_cmd']}")
|
||||
print(f"Post-install: {info['setup']}")
|
||||
print()
|
||||
|
||||
try:
|
||||
confirm = input("Run installation command? (y/N): ").strip().lower()
|
||||
except EOFError:
|
||||
confirm = "n"
|
||||
|
||||
if confirm == "y":
|
||||
print()
|
||||
print(f"Running: {info['install_cmd']}")
|
||||
print("-" * 40)
|
||||
result = subprocess.run(info['install_cmd'], shell=True)
|
||||
print("-" * 40)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Refresh PATH to pick up newly installed tools
|
||||
import os
|
||||
new_paths = []
|
||||
|
||||
# Common install locations that might have been added
|
||||
potential_paths = [
|
||||
Path.home() / ".opencode" / "bin", # OpenCode
|
||||
Path.home() / ".local" / "bin", # pip/pipx installs
|
||||
Path("/usr/local/bin"), # Ollama, system installs
|
||||
]
|
||||
|
||||
# Also try to get npm global bin path
|
||||
try:
|
||||
npm_result = subprocess.run(
|
||||
["npm", "bin", "-g"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if npm_result.returncode == 0:
|
||||
npm_bin = npm_result.stdout.strip()
|
||||
if npm_bin:
|
||||
potential_paths.append(Path(npm_bin))
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
current_path = os.environ.get("PATH", "")
|
||||
for p in potential_paths:
|
||||
if p.exists() and str(p) not in current_path:
|
||||
new_paths.append(str(p))
|
||||
|
||||
if new_paths:
|
||||
os.environ["PATH"] = ":".join(new_paths) + ":" + current_path
|
||||
|
||||
print()
|
||||
print("Installation completed!")
|
||||
print()
|
||||
print("IMPORTANT: Refresh your shell PATH before continuing:")
|
||||
print(" source ~/.bashrc")
|
||||
print()
|
||||
print(f"Next steps:")
|
||||
print(f" 1. source ~/.bashrc (required!)")
|
||||
print(f" 2. {info['setup']}")
|
||||
if info.get('post_install_note'):
|
||||
print(f" 3. {info['post_install_note']}")
|
||||
print(f" 4. Test with: smarttools providers test {selected}")
|
||||
else:
|
||||
print(f" 3. Test with: smarttools providers test {info['variants'][0] if info['variants'] else selected}")
|
||||
else:
|
||||
print()
|
||||
print(f"Installation failed (exit code {result.returncode})")
|
||||
print("Try running the command manually:")
|
||||
print(f" {info['install_cmd']}")
|
||||
else:
|
||||
print()
|
||||
print("To install manually, run:")
|
||||
print(f" {info['install_cmd']}")
|
||||
print()
|
||||
print(f"Then: {info['setup']}")
|
||||
|
||||
return 0
|
||||
|
||||
elif args.providers_cmd == "list":
|
||||
providers = load_providers()
|
||||
print(f"Configured providers ({len(providers)}):\n")
|
||||
for p in providers:
|
||||
# Mock provider is always available
|
||||
if p.name.lower() == "mock":
|
||||
print(f" [+] {p.name}")
|
||||
print(f" Command: (built-in)")
|
||||
print(f" Status: OK (always available)")
|
||||
if p.description:
|
||||
print(f" Info: {p.description}")
|
||||
print()
|
||||
continue
|
||||
|
||||
# Check if command exists
|
||||
cmd_parts = p.command.split()[0]
|
||||
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
|
||||
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
|
||||
status = "OK" if exists else "NOT FOUND"
|
||||
status_icon = "+" if exists else "-"
|
||||
|
||||
print(f" [{status_icon}] {p.name}")
|
||||
print(f" Command: {p.command}")
|
||||
print(f" Status: {status}")
|
||||
if p.description:
|
||||
print(f" Info: {p.description}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
elif args.providers_cmd == "add":
|
||||
name = args.name
|
||||
command = args.command
|
||||
description = args.description or ""
|
||||
|
||||
provider = Provider(name, command, description)
|
||||
add_provider(provider)
|
||||
print(f"Provider '{name}' added/updated.")
|
||||
return 0
|
||||
|
||||
elif args.providers_cmd == "remove":
|
||||
name = args.name
|
||||
if delete_provider(name):
|
||||
print(f"Provider '{name}' removed.")
|
||||
return 0
|
||||
else:
|
||||
print(f"Provider '{name}' not found.")
|
||||
return 1
|
||||
|
||||
elif args.providers_cmd == "test":
|
||||
name = args.name
|
||||
print(f"Testing provider '{name}'...")
|
||||
result = call_provider(name, "Say 'hello' and nothing else.", timeout=30)
|
||||
if result.success:
|
||||
print(f"SUCCESS: {result.text[:200]}...")
|
||||
else:
|
||||
print(f"FAILED: {result.error}")
|
||||
return 0 if result.success else 1
|
||||
|
||||
elif args.providers_cmd == "check":
|
||||
providers = load_providers()
|
||||
print("Checking all providers...\n")
|
||||
available = []
|
||||
missing = []
|
||||
|
||||
for p in providers:
|
||||
# Mock provider is always available (handled specially)
|
||||
if p.name.lower() == "mock":
|
||||
available.append(p.name)
|
||||
print(f" [+] {p.name}: OK (built-in)")
|
||||
continue
|
||||
|
||||
cmd_parts = p.command.split()[0]
|
||||
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
|
||||
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
|
||||
|
||||
if exists:
|
||||
available.append(p.name)
|
||||
print(f" [+] {p.name}: OK")
|
||||
else:
|
||||
missing.append(p.name)
|
||||
print(f" [-] {p.name}: NOT FOUND ({cmd_parts})")
|
||||
|
||||
print(f"\nSummary: {len(available)} available, {len(missing)} missing")
|
||||
if len(available) == 1 and available[0] == "mock":
|
||||
print(f"\nNo real AI providers found. Install one of these:")
|
||||
print(f" - claude: npm install -g @anthropic-ai/claude-cli")
|
||||
print(f" - codex: pip install openai-codex")
|
||||
print(f" - gemini: pip install google-generative-ai")
|
||||
print(f"\nMeanwhile, use mock provider for testing:")
|
||||
print(f" echo 'test' | summarize --provider mock")
|
||||
elif missing:
|
||||
print(f"\nAvailable providers: {', '.join(available)}")
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="smarttools",
|
||||
description="A lightweight personal tool builder for AI-powered CLI commands"
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# No command = launch UI
|
||||
# 'list' command
|
||||
p_list = subparsers.add_parser("list", help="List all tools")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
# 'create' command
|
||||
p_create = subparsers.add_parser("create", help="Create a new tool")
|
||||
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: mock)")
|
||||
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
|
||||
p_create.set_defaults(func=cmd_create)
|
||||
|
||||
# 'edit' command
|
||||
p_edit = subparsers.add_parser("edit", help="Edit a tool config")
|
||||
p_edit.add_argument("name", help="Tool name")
|
||||
p_edit.set_defaults(func=cmd_edit)
|
||||
|
||||
# 'delete' command
|
||||
p_delete = subparsers.add_parser("delete", help="Delete a tool")
|
||||
p_delete.add_argument("name", help="Tool name")
|
||||
p_delete.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
|
||||
p_delete.set_defaults(func=cmd_delete)
|
||||
|
||||
# 'test' command
|
||||
p_test = subparsers.add_parser("test", help="Test a tool with mock provider")
|
||||
p_test.add_argument("name", help="Tool name")
|
||||
p_test.add_argument("-i", "--input", help="Input file for testing")
|
||||
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)
|
||||
|
||||
# 'docs' command
|
||||
p_docs = subparsers.add_parser("docs", help="View or edit tool documentation")
|
||||
p_docs.add_argument("name", help="Tool name")
|
||||
p_docs.add_argument("-e", "--edit", action="store_true", help="Edit/create README in $EDITOR")
|
||||
p_docs.set_defaults(func=cmd_docs)
|
||||
|
||||
# 'providers' command
|
||||
p_providers = subparsers.add_parser("providers", help="Manage AI providers")
|
||||
providers_sub = p_providers.add_subparsers(dest="providers_cmd", help="Provider commands")
|
||||
|
||||
# providers list
|
||||
p_prov_list = providers_sub.add_parser("list", help="List all providers and their status")
|
||||
p_prov_list.set_defaults(func=cmd_providers)
|
||||
|
||||
# providers check
|
||||
p_prov_check = providers_sub.add_parser("check", help="Check which providers are available")
|
||||
p_prov_check.set_defaults(func=cmd_providers)
|
||||
|
||||
# providers install
|
||||
p_prov_install = providers_sub.add_parser("install", help="Interactive guide to install AI providers")
|
||||
p_prov_install.set_defaults(func=cmd_providers)
|
||||
|
||||
# providers add
|
||||
p_prov_add = providers_sub.add_parser("add", help="Add or update a provider")
|
||||
p_prov_add.add_argument("name", help="Provider name")
|
||||
p_prov_add.add_argument("command", help="Command to run (e.g., 'claude -p')")
|
||||
p_prov_add.add_argument("-d", "--description", help="Provider description")
|
||||
p_prov_add.set_defaults(func=cmd_providers)
|
||||
|
||||
# providers remove
|
||||
p_prov_remove = providers_sub.add_parser("remove", help="Remove a provider")
|
||||
p_prov_remove.add_argument("name", help="Provider name")
|
||||
p_prov_remove.set_defaults(func=cmd_providers)
|
||||
|
||||
# providers test
|
||||
p_prov_test = providers_sub.add_parser("test", help="Test a provider")
|
||||
p_prov_test.add_argument("name", help="Provider name")
|
||||
p_prov_test.set_defaults(func=cmd_providers)
|
||||
|
||||
# Default for providers with no subcommand
|
||||
p_providers.set_defaults(func=lambda args: cmd_providers(args) if args.providers_cmd else (setattr(args, 'providers_cmd', 'list') or cmd_providers(args)))
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# If no command, launch UI
|
||||
if args.command is None:
|
||||
return cmd_ui(args)
|
||||
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Provider abstraction for AI CLI tools."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
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
|
||||
class ProviderResult:
|
||||
"""Result from a provider call."""
|
||||
text: str
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# 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 provider with the given prompt.
|
||||
|
||||
Args:
|
||||
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
|
||||
"""
|
||||
# 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_name}' not found. Use 'smarttools providers' to manage providers."
|
||||
)
|
||||
|
||||
# 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?\n\nTo install AI providers, run: smarttools providers install"
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f"Provider exited with code {result.returncode}: {result.stderr}"
|
||||
if "not found" in result.stderr.lower() or "not installed" in result.stderr.lower():
|
||||
error_msg += "\n\nTo install AI providers, run: smarttools providers install"
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=error_msg
|
||||
)
|
||||
|
||||
# Warn if output is empty (provider ran but returned nothing)
|
||||
if not result.stdout.strip():
|
||||
stderr = result.stderr.strip()
|
||||
|
||||
# Check for OpenCode's ProviderModelNotFoundError
|
||||
if "ProviderModelNotFoundError" in stderr or "ModelNotFoundError" in stderr:
|
||||
# Extract provider and model info if possible
|
||||
import re
|
||||
provider_match = re.search(r'providerID:\s*"([^"]+)"', stderr)
|
||||
model_match = re.search(r'modelID:\s*"([^"]+)"', stderr)
|
||||
provider_id = provider_match.group(1) if provider_match else "unknown"
|
||||
model_id = model_match.group(1) if model_match else "unknown"
|
||||
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Model '{model_id}' from provider '{provider_id}' is not available.\n\n"
|
||||
f"To fix this, either:\n"
|
||||
f" 1. Run 'opencode' to connect the {provider_id} provider\n"
|
||||
f" 2. Use --provider to pick a different model (e.g., --provider opencode-pickle)\n"
|
||||
f" 3. Run 'smarttools ui' to edit the tool's default provider"
|
||||
)
|
||||
|
||||
stderr_hint = f" (stderr: {stderr[:200]}...)" if len(stderr) > 200 else (f" (stderr: {stderr})" if stderr else "")
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider returned empty output{stderr_hint}.\n\nThis may mean the model is not available. Try a different provider or run: smarttools providers install"
|
||||
)
|
||||
|
||||
return ProviderResult(text=result.stdout, success=True)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider timed out after {timeout} seconds"
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def mock_provider(prompt: str) -> ProviderResult:
|
||||
"""
|
||||
Return a mock response for testing.
|
||||
|
||||
Args:
|
||||
prompt: The prompt (used for generating mock response)
|
||||
|
||||
Returns:
|
||||
ProviderResult with mock response
|
||||
"""
|
||||
lines = prompt.strip().split('\n')
|
||||
preview = lines[0][:50] + "..." if len(lines[0]) > 50 else lines[0]
|
||||
|
||||
return ProviderResult(
|
||||
text=f"[MOCK RESPONSE]\n"
|
||||
f"Prompt length: {len(prompt)} chars, {len(lines)} lines\n"
|
||||
f"First line: {preview}\n"
|
||||
f"\n"
|
||||
f"This is a mock response. Use a real provider for actual output.",
|
||||
success=True
|
||||
)
|
||||
@@ -1,288 +0,0 @@
|
||||
"""Tool execution engine."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .tool import load_tool, Tool, PromptStep, CodeStep
|
||||
from .providers import call_provider, mock_provider
|
||||
|
||||
|
||||
def substitute_variables(template: str, variables: dict) -> str:
|
||||
"""
|
||||
Substitute {variable} placeholders in a template.
|
||||
|
||||
Args:
|
||||
template: String with {var} placeholders
|
||||
variables: Dict of variable name -> value
|
||||
|
||||
Returns:
|
||||
String with placeholders replaced
|
||||
"""
|
||||
result = template
|
||||
for name, value in variables.items():
|
||||
result = result.replace(f"{{{name}}}", str(value) if value else "")
|
||||
return result
|
||||
|
||||
|
||||
def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]:
|
||||
"""
|
||||
Execute a prompt step.
|
||||
|
||||
Args:
|
||||
step: The prompt step to execute
|
||||
variables: Current variable values
|
||||
provider_override: Override the step's provider
|
||||
|
||||
Returns:
|
||||
Tuple of (output_value, success)
|
||||
"""
|
||||
# Build prompt with variable substitution
|
||||
prompt = substitute_variables(step.prompt, variables)
|
||||
|
||||
# Call provider
|
||||
provider = provider_override or step.provider
|
||||
|
||||
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)
|
||||
"""
|
||||
# Substitute variables in code (like {outputfile} -> actual value)
|
||||
code = substitute_variables(step.code, variables)
|
||||
|
||||
# Create execution environment with variables
|
||||
local_vars = dict(variables)
|
||||
|
||||
try:
|
||||
# Execute the code with substituted variables
|
||||
exec(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"Error in code step: {e}", file=sys.stderr)
|
||||
return {}, False
|
||||
|
||||
|
||||
def run_tool(
|
||||
tool: Tool,
|
||||
input_text: str,
|
||||
custom_args: dict,
|
||||
provider_override: Optional[str] = None,
|
||||
dry_run: bool = False,
|
||||
show_prompt: bool = False,
|
||||
verbose: bool = False
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Execute a tool.
|
||||
|
||||
Args:
|
||||
tool: Tool definition
|
||||
input_text: Input content
|
||||
custom_args: Custom argument values
|
||||
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)
|
||||
"""
|
||||
# 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] Variables: {list(variables.keys())}", file=sys.stderr)
|
||||
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
|
||||
|
||||
# If no steps, just substitute output template
|
||||
if not tool.steps:
|
||||
output = substitute_variables(tool.output, variables)
|
||||
return output, 0
|
||||
|
||||
# Execute each step
|
||||
for i, step in enumerate(tool.steps):
|
||||
if verbose:
|
||||
step_type = "PROMPT" if isinstance(step, PromptStep) else "CODE"
|
||||
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
|
||||
|
||||
if isinstance(step, PromptStep):
|
||||
# Show prompt if requested
|
||||
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 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
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# Generate final output
|
||||
output = substitute_variables(tool.output, variables)
|
||||
|
||||
return output, 0
|
||||
|
||||
|
||||
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
|
||||
"""
|
||||
Create an argument parser for a tool.
|
||||
|
||||
Args:
|
||||
tool: Tool definition
|
||||
|
||||
Returns:
|
||||
Configured ArgumentParser
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=tool.name,
|
||||
description=tool.description or f"SmartTools: {tool.name}"
|
||||
)
|
||||
|
||||
# Universal flags
|
||||
parser.add_argument("-i", "--input", dest="input_file",
|
||||
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 what would happen without executing")
|
||||
parser.add_argument("--show-prompt", action="store_true",
|
||||
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 from arguments
|
||||
for arg in tool.arguments:
|
||||
parser.add_argument(
|
||||
arg.flag,
|
||||
dest=arg.variable,
|
||||
default=arg.default,
|
||||
help=arg.description or f"{arg.variable} (default: {arg.default})"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for tool execution via wrapper script."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m smarttools.runner <tool_name> [args...]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tool_name = sys.argv[1]
|
||||
tool = load_tool(tool_name)
|
||||
|
||||
if tool is None:
|
||||
print(f"Error: Tool '{tool_name}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse remaining arguments
|
||||
parser = create_argument_parser(tool)
|
||||
args = parser.parse_args(sys.argv[2:])
|
||||
|
||||
# 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()
|
||||
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 arg in tool.arguments:
|
||||
value = getattr(args, arg.variable, None)
|
||||
if value is not None:
|
||||
custom_args[arg.variable] = value
|
||||
|
||||
# Run tool
|
||||
output, exit_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 exit_code == 0 and output:
|
||||
if args.output_file:
|
||||
Path(args.output_file).write_text(output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,326 +0,0 @@
|
||||
"""Tool loading, saving, and management."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# Default tools directory
|
||||
TOOLS_DIR = Path.home() / ".smarttools"
|
||||
|
||||
# Default bin directory for wrapper scripts
|
||||
BIN_DIR = Path.home() / ".local" / "bin"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolArgument:
|
||||
"""Definition of a custom input argument."""
|
||||
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
|
||||
|
||||
|
||||
# Default categories for organizing tools
|
||||
DEFAULT_CATEGORIES = ["Text", "Developer", "Data", "Other"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
"""A SmartTools tool definition."""
|
||||
name: str
|
||||
description: str = ""
|
||||
category: str = "Other" # Tool category for organization
|
||||
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":
|
||||
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", ""),
|
||||
category=data.get("category", "Other"),
|
||||
arguments=arguments,
|
||||
steps=steps,
|
||||
output=data.get("output", "{input}")
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
}
|
||||
# Only include category if it's not the default
|
||||
if self.category and self.category != "Other":
|
||||
d["category"] = self.category
|
||||
d["arguments"] = [arg.to_dict() for arg in self.arguments]
|
||||
d["steps"] = [step.to_dict() for step in self.steps]
|
||||
d["output"] = self.output
|
||||
return d
|
||||
|
||||
def get_available_variables(self) -> List[str]:
|
||||
"""Get all variables available for use in templates."""
|
||||
variables = ["input"] # Always available
|
||||
|
||||
# Add argument variables
|
||||
for arg in self.arguments:
|
||||
variables.append(arg.variable)
|
||||
|
||||
# Add step output variables
|
||||
for step in self.steps:
|
||||
variables.append(step.output_var)
|
||||
|
||||
return variables
|
||||
|
||||
|
||||
def get_tools_dir() -> Path:
|
||||
"""Get the tools directory, creating it if needed."""
|
||||
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TOOLS_DIR
|
||||
|
||||
|
||||
def get_bin_dir() -> Path:
|
||||
"""Get the bin directory for wrapper scripts, creating it if needed."""
|
||||
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return BIN_DIR
|
||||
|
||||
|
||||
def list_tools() -> list[str]:
|
||||
"""List all available tools."""
|
||||
tools_dir = get_tools_dir()
|
||||
tools = []
|
||||
|
||||
for item in tools_dir.iterdir():
|
||||
if item.is_dir():
|
||||
config = item / "config.yaml"
|
||||
if config.exists():
|
||||
tools.append(item.name)
|
||||
|
||||
return sorted(tools)
|
||||
|
||||
|
||||
def load_tool(name: str) -> Optional[Tool]:
|
||||
"""Load a tool by name."""
|
||||
config_path = get_tools_dir() / name / "config.yaml"
|
||||
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
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 as e:
|
||||
print(f"Error loading tool {name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_tool(tool: Tool) -> Path:
|
||||
"""Save a tool to disk."""
|
||||
tool_dir = get_tools_dir() / tool.name
|
||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = tool_dir / "config.yaml"
|
||||
config_path.write_text(yaml.dump(tool.to_dict(), default_flow_style=False, sort_keys=False))
|
||||
|
||||
# Create wrapper script
|
||||
create_wrapper_script(tool.name)
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
def delete_tool(name: str) -> bool:
|
||||
"""Delete a tool."""
|
||||
tool_dir = get_tools_dir() / name
|
||||
|
||||
if not tool_dir.exists():
|
||||
return False
|
||||
|
||||
# Remove wrapper script
|
||||
wrapper = get_bin_dir() / name
|
||||
if wrapper.exists():
|
||||
wrapper.unlink()
|
||||
|
||||
# Remove tool directory
|
||||
import shutil
|
||||
shutil.rmtree(tool_dir)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_wrapper_script(name: str) -> Path:
|
||||
"""Create a wrapper script for a tool in ~/.local/bin."""
|
||||
import sys
|
||||
|
||||
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 {python_path} -m smarttools.runner {name} "$@"
|
||||
"""
|
||||
|
||||
wrapper_path.write_text(script)
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
return wrapper_path
|
||||
|
||||
|
||||
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, ""
|
||||
@@ -1,828 +0,0 @@
|
||||
"""Dialog-based UI for managing SmartTools."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
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:
|
||||
"""Check for available dialog program. Returns 'dialog', 'whiptail', or None."""
|
||||
for prog in ["dialog", "whiptail"]:
|
||||
try:
|
||||
subprocess.run([prog, "--version"], capture_output=True, check=False)
|
||||
return prog
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
|
||||
"""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", title, "--menu", "Choose an option:", "20", "75", str(len(choices))]
|
||||
for tag, desc in choices:
|
||||
args.extend([tag, desc])
|
||||
|
||||
code, output = run_dialog(args, dialog_prog)
|
||||
return output if code == 0 else None
|
||||
|
||||
|
||||
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", 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."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
||||
f.write(text)
|
||||
temp_path = f.name
|
||||
|
||||
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", title, "--yesno", text, "10", "60"]
|
||||
code, _ = run_dialog(args, dialog_prog)
|
||||
return code == 0
|
||||
|
||||
|
||||
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
|
||||
"""Show a message box."""
|
||||
args = ["--title", title, "--msgbox", text, "15", "70"]
|
||||
run_dialog(args, dialog_prog)
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
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()
|
||||
|
||||
if not name:
|
||||
show_message("Error", "Provider name is required.", dialog_prog)
|
||||
return None
|
||||
|
||||
if not command:
|
||||
show_message("Error", "Command is required.", dialog_prog)
|
||||
return None
|
||||
|
||||
return Provider(
|
||||
name=name,
|
||||
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,
|
||||
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()
|
||||
if not tools:
|
||||
show_message("Tools", "No tools found.\n\nCreate your first tool from the main menu.", dialog_prog)
|
||||
return
|
||||
|
||||
text = "Available tools:\n\n"
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
text += f" {name}: {tool.description or 'No description'}\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 = tool_builder(dialog_prog)
|
||||
if tool:
|
||||
path = save_tool(tool)
|
||||
|
||||
# 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):
|
||||
"""Edit an existing tool."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
show_message("Edit Tool", "No tools found.", dialog_prog)
|
||||
return
|
||||
|
||||
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 = tool_builder(dialog_prog, existing)
|
||||
if tool:
|
||||
save_tool(tool)
|
||||
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
|
||||
|
||||
|
||||
def ui_delete_tool(dialog_prog: str):
|
||||
"""Delete a tool."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
show_message("Delete Tool", "No tools found.", dialog_prog)
|
||||
return
|
||||
|
||||
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:
|
||||
if show_yesno("Confirm Delete", f"Delete tool '{selected}'?\n\nThis cannot be undone.", dialog_prog):
|
||||
if delete_tool(selected):
|
||||
show_message("Deleted", f"Tool '{selected}' deleted.", dialog_prog)
|
||||
else:
|
||||
show_message("Error", f"Failed to delete '{selected}'.", dialog_prog)
|
||||
|
||||
|
||||
def ui_test_tool(dialog_prog: str):
|
||||
"""Test a tool with mock provider."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
show_message("Test Tool", "No tools found.", dialog_prog)
|
||||
return
|
||||
|
||||
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:
|
||||
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
|
||||
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\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):
|
||||
"""Show the main menu."""
|
||||
while True:
|
||||
choice = show_menu(
|
||||
"SmartTools Manager",
|
||||
[
|
||||
("list", "List all tools"),
|
||||
("create", "Create new tool"),
|
||||
("edit", "Edit existing tool"),
|
||||
("delete", "Delete tool"),
|
||||
("test", "Test tool (mock provider)"),
|
||||
("providers", "Manage providers"),
|
||||
("exit", "Exit"),
|
||||
],
|
||||
dialog_prog
|
||||
)
|
||||
|
||||
if choice is None or choice == "exit":
|
||||
break
|
||||
elif choice == "list":
|
||||
ui_list_tools(dialog_prog)
|
||||
elif choice == "create":
|
||||
ui_create_tool(dialog_prog)
|
||||
elif choice == "edit":
|
||||
ui_edit_tool(dialog_prog)
|
||||
elif choice == "delete":
|
||||
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: 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:
|
||||
main_menu(dialog_prog)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
subprocess.run(["clear"], check=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_ui()
|
||||
@@ -1,706 +0,0 @@
|
||||
"""BIOS-style TUI for SmartTools using snack (python3-newt)."""
|
||||
|
||||
import sys
|
||||
# Ensure system packages are accessible
|
||||
if '/usr/lib/python3/dist-packages' not in sys.path:
|
||||
sys.path.insert(0, '/usr/lib/python3/dist-packages')
|
||||
|
||||
import snack
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from .tool import (
|
||||
Tool, ToolArgument, PromptStep, CodeStep, Step,
|
||||
list_tools, load_tool, save_tool, delete_tool, tool_exists
|
||||
)
|
||||
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
|
||||
|
||||
|
||||
class SmartToolsUI:
|
||||
"""BIOS-style UI for SmartTools."""
|
||||
|
||||
def __init__(self):
|
||||
self.screen = None
|
||||
|
||||
def run(self):
|
||||
"""Run the UI."""
|
||||
self.screen = snack.SnackScreen()
|
||||
# Enable mouse support
|
||||
self.screen.pushHelpLine(" Tab/Arrow:Navigate | Enter:Select | Mouse:Click | Esc:Back ")
|
||||
try:
|
||||
# Enable mouse - newt supports GPM and xterm mouse
|
||||
import os
|
||||
os.environ.setdefault('NEWT_MONO', '0')
|
||||
self.main_menu()
|
||||
finally:
|
||||
self.screen.finish()
|
||||
|
||||
def main_menu(self):
|
||||
"""Show the main menu."""
|
||||
while True:
|
||||
items = [
|
||||
("Create New Tool", "create"),
|
||||
("Edit Tool", "edit"),
|
||||
("Delete Tool", "delete"),
|
||||
("List Tools", "list"),
|
||||
("Test Tool", "test"),
|
||||
("Manage Providers", "providers"),
|
||||
("Exit", "exit"),
|
||||
]
|
||||
|
||||
listbox = snack.Listbox(height=7, width=30, returnExit=1)
|
||||
for label, value in items:
|
||||
listbox.append(label, value)
|
||||
|
||||
grid = snack.GridForm(self.screen, "SmartTools Manager", 1, 1)
|
||||
grid.add(listbox, 0, 0)
|
||||
|
||||
result = grid.runOnce()
|
||||
selected = listbox.current()
|
||||
|
||||
if selected == "exit" or result == "ESC":
|
||||
break
|
||||
elif selected == "create":
|
||||
self.tool_builder(None)
|
||||
elif selected == "edit":
|
||||
self.select_and_edit_tool()
|
||||
elif selected == "delete":
|
||||
self.select_and_delete_tool()
|
||||
elif selected == "list":
|
||||
self.show_tools_list()
|
||||
elif selected == "test":
|
||||
self.select_and_test_tool()
|
||||
elif selected == "providers":
|
||||
self.manage_providers()
|
||||
|
||||
def message_box(self, title: str, message: str):
|
||||
"""Show a message box."""
|
||||
snack.ButtonChoiceWindow(self.screen, title, message, ["OK"])
|
||||
|
||||
def yes_no(self, title: str, message: str) -> bool:
|
||||
"""Show a yes/no dialog."""
|
||||
result = snack.ButtonChoiceWindow(self.screen, title, message, ["Yes", "No"])
|
||||
return result == "yes"
|
||||
|
||||
def input_box(self, title: str, prompt: str, initial: str = "", width: int = 40) -> Optional[str]:
|
||||
"""Show an input dialog."""
|
||||
entry = snack.Entry(width, initial)
|
||||
grid = snack.GridForm(self.screen, title, 1, 3)
|
||||
grid.add(snack.Label(prompt), 0, 0)
|
||||
grid.add(entry, 0, 1, padding=(0, 1, 0, 1))
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 2)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
return entry.value()
|
||||
return None
|
||||
|
||||
def text_edit(self, title: str, initial: str = "", width: int = 60, height: int = 10) -> Optional[str]:
|
||||
"""Show a multi-line text editor."""
|
||||
text = snack.Textbox(width, height, initial, scroll=1, wrap=1)
|
||||
|
||||
# snack doesn't have a true multi-line editor, so we use Entry for now
|
||||
# For multi-line, we'll use a workaround with a simple entry
|
||||
entry = snack.Entry(width, initial, scroll=1)
|
||||
|
||||
grid = snack.GridForm(self.screen, title, 1, 2)
|
||||
grid.add(entry, 0, 0, padding=(0, 0, 0, 1))
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
return entry.value()
|
||||
return None
|
||||
|
||||
def select_provider(self) -> Optional[str]:
|
||||
"""Show provider selection dialog."""
|
||||
providers = load_providers()
|
||||
|
||||
listbox = snack.Listbox(height=min(len(providers) + 1, 8), width=50, returnExit=1)
|
||||
for p in providers:
|
||||
listbox.append(f"{p.name}: {p.command}", p.name)
|
||||
listbox.append("[ + Add New Provider ]", "__new__")
|
||||
|
||||
grid = snack.GridForm(self.screen, "Select Provider", 1, 2)
|
||||
grid.add(listbox, 0, 0)
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
|
||||
return None
|
||||
|
||||
selected = listbox.current()
|
||||
|
||||
if selected == "__new__":
|
||||
provider = self.add_provider_dialog()
|
||||
if provider:
|
||||
add_provider(provider)
|
||||
return provider.name
|
||||
return None
|
||||
|
||||
return selected
|
||||
|
||||
def add_provider_dialog(self) -> Optional[Provider]:
|
||||
"""Dialog to add a new provider."""
|
||||
name_entry = snack.Entry(30, "")
|
||||
cmd_entry = snack.Entry(40, "")
|
||||
desc_entry = snack.Entry(40, "")
|
||||
|
||||
grid = snack.GridForm(self.screen, "Add Provider", 2, 4)
|
||||
grid.add(snack.Label("Name:"), 0, 0, anchorLeft=1)
|
||||
grid.add(name_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||
grid.add(snack.Label("Command:"), 0, 1, anchorLeft=1)
|
||||
grid.add(cmd_entry, 1, 1, padding=(1, 0, 0, 0))
|
||||
grid.add(snack.Label("Description:"), 0, 2, anchorLeft=1)
|
||||
grid.add(desc_entry, 1, 2, padding=(1, 0, 0, 0))
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 3, growx=1, growy=1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
name = name_entry.value().strip()
|
||||
cmd = cmd_entry.value().strip()
|
||||
if name and cmd:
|
||||
return Provider(name=name, command=cmd, description=desc_entry.value().strip())
|
||||
self.message_box("Error", "Name and command are required.")
|
||||
|
||||
return None
|
||||
|
||||
def add_argument_dialog(self, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
|
||||
"""Dialog to add/edit an argument."""
|
||||
flag_entry = snack.Entry(20, existing.flag if existing else "--")
|
||||
var_entry = snack.Entry(20, existing.variable if existing else "")
|
||||
default_entry = snack.Entry(20, existing.default or "" if existing else "")
|
||||
desc_entry = snack.Entry(40, existing.description if existing else "")
|
||||
|
||||
title = "Edit Argument" if existing else "Add Argument"
|
||||
grid = snack.GridForm(self.screen, title, 2, 5)
|
||||
grid.add(snack.Label("Flag:"), 0, 0, anchorLeft=1)
|
||||
grid.add(flag_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||
grid.add(snack.Label("Variable:"), 0, 1, anchorLeft=1)
|
||||
grid.add(var_entry, 1, 1, padding=(1, 0, 0, 0))
|
||||
grid.add(snack.Label("Default:"), 0, 2, anchorLeft=1)
|
||||
grid.add(default_entry, 1, 2, padding=(1, 0, 0, 0))
|
||||
grid.add(snack.Label("Description:"), 0, 3, anchorLeft=1)
|
||||
grid.add(desc_entry, 1, 3, padding=(1, 0, 0, 0))
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 4, growx=1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
flag = flag_entry.value().strip()
|
||||
var = var_entry.value().strip()
|
||||
|
||||
if not flag:
|
||||
self.message_box("Error", "Flag is required.")
|
||||
return None
|
||||
|
||||
if not var:
|
||||
var = flag.lstrip("-").replace("-", "_")
|
||||
|
||||
return ToolArgument(
|
||||
flag=flag,
|
||||
variable=var,
|
||||
default=default_entry.value().strip() or None,
|
||||
description=desc_entry.value().strip()
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def add_step_dialog(self, tool: Tool, existing_step: Optional[Step] = None, step_idx: int = -1) -> Optional[Step]:
|
||||
"""Dialog to choose and add a step."""
|
||||
if existing_step:
|
||||
# Edit existing step
|
||||
if isinstance(existing_step, PromptStep):
|
||||
return self.add_prompt_dialog(tool, existing_step, step_idx)
|
||||
else:
|
||||
return self.add_code_dialog(tool, existing_step, step_idx)
|
||||
|
||||
# Choose step type
|
||||
listbox = snack.Listbox(height=2, width=30, returnExit=1)
|
||||
listbox.append("Prompt (AI call)", "prompt")
|
||||
listbox.append("Code (Python)", "code")
|
||||
|
||||
grid = snack.GridForm(self.screen, "Add Step", 1, 2)
|
||||
grid.add(listbox, 0, 0)
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
|
||||
return None
|
||||
|
||||
step_type = listbox.current()
|
||||
|
||||
if step_type == "prompt":
|
||||
return self.add_prompt_dialog(tool, None, step_idx)
|
||||
else:
|
||||
return self.add_code_dialog(tool, None, step_idx)
|
||||
|
||||
def get_available_variables(self, tool: Tool, up_to_step: int = -1) -> List[str]:
|
||||
"""Get available variables at a point in the tool."""
|
||||
variables = ["input"]
|
||||
for arg in tool.arguments:
|
||||
variables.append(arg.variable)
|
||||
if up_to_step == -1:
|
||||
up_to_step = len(tool.steps)
|
||||
for i, step in enumerate(tool.steps):
|
||||
if i >= up_to_step:
|
||||
break
|
||||
variables.append(step.output_var)
|
||||
return variables
|
||||
|
||||
def add_prompt_dialog(self, tool: Tool, existing: Optional[PromptStep] = None, step_idx: int = -1) -> Optional[PromptStep]:
|
||||
"""Dialog to add/edit a prompt step."""
|
||||
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
|
||||
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
|
||||
|
||||
# Provider selection first
|
||||
provider = self.select_provider()
|
||||
if not provider:
|
||||
provider = existing.provider if existing else "mock"
|
||||
|
||||
prompt_entry = snack.Entry(60, existing.prompt if existing else f"Process this:\n\n{{input}}", scroll=1)
|
||||
output_entry = snack.Entry(20, existing.output_var if existing else "result")
|
||||
|
||||
title = "Edit Prompt Step" if existing else "Add Prompt Step"
|
||||
grid = snack.GridForm(self.screen, title, 2, 4)
|
||||
|
||||
grid.add(snack.Label(f"Provider: {provider}"), 0, 0, anchorLeft=1, growx=1)
|
||||
grid.add(snack.Label(""), 1, 0)
|
||||
|
||||
grid.add(snack.Label(f"Prompt ({var_help}):"), 0, 1, anchorLeft=1, growx=1)
|
||||
grid.add(snack.Label(""), 1, 1)
|
||||
|
||||
grid.add(prompt_entry, 0, 2, growx=1)
|
||||
grid.add(snack.Label(""), 1, 2)
|
||||
|
||||
sub_grid = snack.Grid(2, 1)
|
||||
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
|
||||
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||
grid.add(sub_grid, 0, 3, anchorLeft=1)
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 1, 3)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
prompt = prompt_entry.value().strip()
|
||||
output_var = output_entry.value().strip()
|
||||
|
||||
if not prompt:
|
||||
self.message_box("Error", "Prompt is required.")
|
||||
return None
|
||||
if not output_var:
|
||||
output_var = "result"
|
||||
|
||||
return PromptStep(prompt=prompt, provider=provider, output_var=output_var)
|
||||
|
||||
return None
|
||||
|
||||
def add_code_dialog(self, tool: Tool, existing: Optional[CodeStep] = None, step_idx: int = -1) -> Optional[CodeStep]:
|
||||
"""Dialog to add/edit a code step."""
|
||||
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
|
||||
var_help = "Variables: " + ", ".join(available)
|
||||
|
||||
default_code = existing.code if existing else "# Set 'result' for output\nresult = input.upper()"
|
||||
code_entry = snack.Entry(60, default_code, scroll=1)
|
||||
output_entry = snack.Entry(20, existing.output_var if existing else "processed")
|
||||
|
||||
title = "Edit Code Step" if existing else "Add Code Step"
|
||||
grid = snack.GridForm(self.screen, title, 2, 4)
|
||||
|
||||
grid.add(snack.Label(f"Python Code ({var_help}):"), 0, 0, anchorLeft=1, growx=1)
|
||||
grid.add(snack.Label("Set 'result' variable for output"), 1, 0)
|
||||
|
||||
grid.add(code_entry, 0, 1, growx=1)
|
||||
grid.add(snack.Label(""), 1, 1)
|
||||
|
||||
sub_grid = snack.Grid(2, 1)
|
||||
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
|
||||
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
|
||||
grid.add(sub_grid, 0, 2, anchorLeft=1)
|
||||
|
||||
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 1, 2)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
code = code_entry.value().strip()
|
||||
output_var = output_entry.value().strip()
|
||||
|
||||
if not code:
|
||||
self.message_box("Error", "Code is required.")
|
||||
return None
|
||||
if not output_var:
|
||||
output_var = "processed"
|
||||
|
||||
return CodeStep(code=code, output_var=output_var)
|
||||
|
||||
return None
|
||||
|
||||
def tool_builder(self, existing: Optional[Tool] = None) -> Optional[Tool]:
|
||||
"""Main tool builder - BIOS-style unified form."""
|
||||
is_edit = existing is not None
|
||||
|
||||
# Initialize tool
|
||||
if existing:
|
||||
tool = Tool(
|
||||
name=existing.name,
|
||||
description=existing.description,
|
||||
arguments=list(existing.arguments),
|
||||
steps=list(existing.steps),
|
||||
output=existing.output
|
||||
)
|
||||
else:
|
||||
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
|
||||
|
||||
while True:
|
||||
# Create form elements
|
||||
name_entry = snack.Entry(25, tool.name, scroll=0)
|
||||
desc_entry = snack.Entry(25, tool.description, scroll=1)
|
||||
output_entry = snack.Entry(25, tool.output, scroll=1)
|
||||
|
||||
# Arguments listbox
|
||||
args_listbox = snack.Listbox(height=4, width=35, returnExit=0, scroll=1)
|
||||
for i, arg in enumerate(tool.arguments):
|
||||
args_listbox.append(f"{arg.flag} -> {{{arg.variable}}}", i)
|
||||
args_listbox.append("[ + Add ]", "add")
|
||||
|
||||
# Steps listbox
|
||||
steps_listbox = snack.Listbox(height=5, width=35, returnExit=0, scroll=1)
|
||||
for i, step in enumerate(tool.steps):
|
||||
if isinstance(step, PromptStep):
|
||||
steps_listbox.append(f"P:{step.provider} -> {{{step.output_var}}}", i)
|
||||
else:
|
||||
steps_listbox.append(f"C: -> {{{step.output_var}}}", i)
|
||||
steps_listbox.append("[ + Add ]", "add")
|
||||
|
||||
# Build the grid layout using nested grids for better control
|
||||
title = f"Edit Tool: {tool.name}" if is_edit and tool.name else "New Tool"
|
||||
|
||||
# Left column grid: Name, Description, Output
|
||||
left_grid = snack.Grid(1, 6)
|
||||
left_grid.setField(snack.Label("Name:"), 0, 0, anchorLeft=1)
|
||||
left_grid.setField(name_entry, 0, 1, anchorLeft=1)
|
||||
left_grid.setField(snack.Label("Description:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
|
||||
left_grid.setField(desc_entry, 0, 3, anchorLeft=1)
|
||||
left_grid.setField(snack.Label("Output:"), 0, 4, anchorLeft=1, padding=(0, 1, 0, 0))
|
||||
left_grid.setField(output_entry, 0, 5, anchorLeft=1)
|
||||
|
||||
# Right column grid: Arguments and Steps
|
||||
right_grid = snack.Grid(1, 4)
|
||||
right_grid.setField(snack.Label("Arguments:"), 0, 0, anchorLeft=1)
|
||||
right_grid.setField(args_listbox, 0, 1, anchorLeft=1)
|
||||
right_grid.setField(snack.Label("Execution Steps:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
|
||||
right_grid.setField(steps_listbox, 0, 3, anchorLeft=1)
|
||||
|
||||
# Main grid
|
||||
grid = snack.GridForm(self.screen, title, 2, 2)
|
||||
grid.add(left_grid, 0, 0, anchorTop=1, padding=(0, 0, 2, 0))
|
||||
grid.add(right_grid, 1, 0, anchorTop=1)
|
||||
|
||||
# Buttons at bottom
|
||||
buttons = snack.ButtonBar(self.screen, [("Save", "save"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 1, growx=1)
|
||||
|
||||
# Handle hotkeys for listbox interaction
|
||||
form = grid.form
|
||||
|
||||
while True:
|
||||
result = form.run()
|
||||
|
||||
# Update tool from entries
|
||||
if not is_edit:
|
||||
tool.name = name_entry.value().strip()
|
||||
tool.description = desc_entry.value().strip()
|
||||
tool.output = output_entry.value().strip()
|
||||
|
||||
# Check what was activated
|
||||
if result == args_listbox:
|
||||
selected = args_listbox.current()
|
||||
if selected == "add":
|
||||
new_arg = self.add_argument_dialog()
|
||||
if new_arg:
|
||||
tool.arguments.append(new_arg)
|
||||
break # Refresh form
|
||||
elif isinstance(selected, int):
|
||||
# Edit/delete existing argument
|
||||
action = self.arg_action_menu(tool.arguments[selected])
|
||||
if action == "edit":
|
||||
updated = self.add_argument_dialog(tool.arguments[selected])
|
||||
if updated:
|
||||
tool.arguments[selected] = updated
|
||||
elif action == "delete":
|
||||
tool.arguments.pop(selected)
|
||||
break # Refresh form
|
||||
|
||||
elif result == steps_listbox:
|
||||
selected = steps_listbox.current()
|
||||
if selected == "add":
|
||||
new_step = self.add_step_dialog(tool)
|
||||
if new_step:
|
||||
tool.steps.append(new_step)
|
||||
break # Refresh form
|
||||
elif isinstance(selected, int):
|
||||
# Edit/delete existing step
|
||||
action = self.step_action_menu(tool.steps[selected], selected, len(tool.steps))
|
||||
if action == "edit":
|
||||
updated = self.add_step_dialog(tool, tool.steps[selected], selected)
|
||||
if updated:
|
||||
tool.steps[selected] = updated
|
||||
elif action == "delete":
|
||||
tool.steps.pop(selected)
|
||||
elif action == "move_up" and selected > 0:
|
||||
tool.steps[selected], tool.steps[selected-1] = tool.steps[selected-1], tool.steps[selected]
|
||||
elif action == "move_down" and selected < len(tool.steps) - 1:
|
||||
tool.steps[selected], tool.steps[selected+1] = tool.steps[selected+1], tool.steps[selected]
|
||||
break # Refresh form
|
||||
|
||||
elif buttons.buttonPressed(result) == "save":
|
||||
if not tool.name:
|
||||
self.message_box("Error", "Tool name is required.")
|
||||
break
|
||||
if not is_edit and tool_exists(tool.name):
|
||||
if not self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?"):
|
||||
break
|
||||
self.screen.popWindow()
|
||||
save_tool(tool)
|
||||
self.message_box("Success", f"Tool '{tool.name}' saved!")
|
||||
return tool
|
||||
|
||||
elif buttons.buttonPressed(result) == "cancel" or result == "ESC":
|
||||
self.screen.popWindow()
|
||||
return None
|
||||
|
||||
else:
|
||||
# Tab between fields, continue
|
||||
continue
|
||||
|
||||
self.screen.popWindow()
|
||||
|
||||
def arg_action_menu(self, arg: ToolArgument) -> Optional[str]:
|
||||
"""Show action menu for an argument."""
|
||||
listbox = snack.Listbox(height=3, width=20, returnExit=1)
|
||||
listbox.append("Edit", "edit")
|
||||
listbox.append("Delete", "delete")
|
||||
listbox.append("Cancel", "cancel")
|
||||
|
||||
grid = snack.GridForm(self.screen, f"Argument: {arg.flag}", 1, 1)
|
||||
grid.add(listbox, 0, 0)
|
||||
|
||||
grid.runOnce()
|
||||
return listbox.current() if listbox.current() != "cancel" else None
|
||||
|
||||
def step_action_menu(self, step: Step, idx: int, total: int) -> Optional[str]:
|
||||
"""Show action menu for a step."""
|
||||
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
|
||||
|
||||
items = [("Edit", "edit")]
|
||||
if idx > 0:
|
||||
items.append(("Move Up", "move_up"))
|
||||
if idx < total - 1:
|
||||
items.append(("Move Down", "move_down"))
|
||||
items.append(("Delete", "delete"))
|
||||
items.append(("Cancel", "cancel"))
|
||||
|
||||
listbox = snack.Listbox(height=len(items), width=20, returnExit=1)
|
||||
for label, value in items:
|
||||
listbox.append(label, value)
|
||||
|
||||
grid = snack.GridForm(self.screen, f"Step {idx+1}: {step_type}", 1, 1)
|
||||
grid.add(listbox, 0, 0)
|
||||
|
||||
grid.runOnce()
|
||||
return listbox.current() if listbox.current() != "cancel" else None
|
||||
|
||||
def select_and_edit_tool(self):
|
||||
"""Select a tool to edit."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
self.message_box("Edit Tool", "No tools found.")
|
||||
return
|
||||
|
||||
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
desc = tool.description[:30] if tool and tool.description else "No description"
|
||||
listbox.append(f"{name}: {desc}", name)
|
||||
|
||||
grid = snack.GridForm(self.screen, "Select Tool to Edit", 1, 2)
|
||||
grid.add(listbox, 0, 0)
|
||||
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
selected = listbox.current()
|
||||
tool = load_tool(selected)
|
||||
if tool:
|
||||
self.tool_builder(tool)
|
||||
|
||||
def select_and_delete_tool(self):
|
||||
"""Select a tool to delete."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
self.message_box("Delete Tool", "No tools found.")
|
||||
return
|
||||
|
||||
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
|
||||
for name in tools:
|
||||
listbox.append(name, name)
|
||||
|
||||
grid = snack.GridForm(self.screen, "Select Tool to Delete", 1, 2)
|
||||
grid.add(listbox, 0, 0)
|
||||
buttons = snack.ButtonBar(self.screen, [("Delete", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
selected = listbox.current()
|
||||
if self.yes_no("Confirm", f"Delete tool '{selected}'?"):
|
||||
if delete_tool(selected):
|
||||
self.message_box("Deleted", f"Tool '{selected}' deleted.")
|
||||
|
||||
def show_tools_list(self):
|
||||
"""Show list of all tools."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
self.message_box("Tools", "No tools found.\n\nCreate one from the main menu.")
|
||||
return
|
||||
|
||||
text = ""
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
text += f"{name}\n"
|
||||
text += f" {tool.description or 'No description'}\n"
|
||||
if tool.arguments:
|
||||
args = ", ".join(a.flag for a in tool.arguments)
|
||||
text += f" Args: {args}\n"
|
||||
if tool.steps:
|
||||
steps = []
|
||||
for s in tool.steps:
|
||||
if isinstance(s, PromptStep):
|
||||
steps.append(f"P:{s.provider}")
|
||||
else:
|
||||
steps.append("C")
|
||||
text += f" Steps: {' -> '.join(steps)}\n"
|
||||
text += "\n"
|
||||
|
||||
snack.ButtonChoiceWindow(self.screen, "Available Tools", text.strip(), ["OK"])
|
||||
|
||||
def select_and_test_tool(self):
|
||||
"""Select a tool to test."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
self.message_box("Test Tool", "No tools found.")
|
||||
return
|
||||
|
||||
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
|
||||
for name in tools:
|
||||
listbox.append(name, name)
|
||||
|
||||
grid = snack.GridForm(self.screen, "Select Tool to Test", 1, 2)
|
||||
grid.add(listbox, 0, 0)
|
||||
buttons = snack.ButtonBar(self.screen, [("Test", "ok"), ("Cancel", "cancel")])
|
||||
grid.add(buttons, 0, 1)
|
||||
|
||||
result = grid.runOnce()
|
||||
|
||||
if buttons.buttonPressed(result) == "ok":
|
||||
selected = listbox.current()
|
||||
tool = load_tool(selected)
|
||||
if tool:
|
||||
test_input = self.input_box("Test Input", "Enter test input:", "Hello world")
|
||||
if test_input:
|
||||
from .runner import run_tool
|
||||
output, code = run_tool(
|
||||
tool=tool,
|
||||
input_text=test_input,
|
||||
custom_args={},
|
||||
provider_override="mock",
|
||||
dry_run=False,
|
||||
show_prompt=False,
|
||||
verbose=False
|
||||
)
|
||||
result_text = f"Exit code: {code}\n\nOutput:\n{output[:500]}"
|
||||
self.message_box("Test Result", result_text)
|
||||
|
||||
def manage_providers(self):
|
||||
"""Manage providers menu."""
|
||||
while True:
|
||||
providers = load_providers()
|
||||
|
||||
listbox = snack.Listbox(height=min(len(providers) + 2, 10), width=50, returnExit=1, scroll=1)
|
||||
for p in providers:
|
||||
listbox.append(f"{p.name}: {p.command}", p.name)
|
||||
listbox.append("[ + Add Provider ]", "__add__")
|
||||
listbox.append("[ <- Back ]", "__back__")
|
||||
|
||||
grid = snack.GridForm(self.screen, "Manage Providers", 1, 1)
|
||||
grid.add(listbox, 0, 0)
|
||||
|
||||
grid.runOnce()
|
||||
selected = listbox.current()
|
||||
|
||||
if selected == "__back__":
|
||||
break
|
||||
elif selected == "__add__":
|
||||
provider = self.add_provider_dialog()
|
||||
if provider:
|
||||
add_provider(provider)
|
||||
self.message_box("Success", f"Provider '{provider.name}' added.")
|
||||
else:
|
||||
# Edit or delete
|
||||
provider = get_provider(selected)
|
||||
if provider:
|
||||
action = self.provider_action_menu(provider)
|
||||
if action == "edit":
|
||||
updated = self.add_provider_dialog() # TODO: pass existing
|
||||
if updated:
|
||||
add_provider(updated)
|
||||
elif action == "delete":
|
||||
if self.yes_no("Confirm", f"Delete provider '{selected}'?"):
|
||||
delete_provider(selected)
|
||||
|
||||
def provider_action_menu(self, provider: Provider) -> Optional[str]:
|
||||
"""Show action menu for a provider."""
|
||||
listbox = snack.Listbox(height=3, width=20, returnExit=1)
|
||||
listbox.append("Edit", "edit")
|
||||
listbox.append("Delete", "delete")
|
||||
listbox.append("Cancel", "cancel")
|
||||
|
||||
grid = snack.GridForm(self.screen, f"Provider: {provider.name}", 1, 1)
|
||||
grid.add(listbox, 0, 0)
|
||||
|
||||
grid.runOnce()
|
||||
return listbox.current() if listbox.current() != "cancel" else None
|
||||
|
||||
|
||||
def run_ui():
|
||||
"""Entry point for the snack UI."""
|
||||
ui = SmartToolsUI()
|
||||
ui.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_ui()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
@startuml
|
||||
start
|
||||
:Set a = 5;
|
||||
:Set b = 6;
|
||||
:Set c = 7;
|
||||
:Calculate semi-perimeter s = (a + b + c) / 2;
|
||||
:Calculate area = sqrt(s*(s-a)*(s-b)*(s-c));
|
||||
:Print "The area of the triangle is [area]";
|
||||
stop
|
||||
@enduml
|
||||
@@ -1,17 +0,0 @@
|
||||
# Python Program to find the area of triangle
|
||||
|
||||
a = 5
|
||||
b = 6
|
||||
c = 7
|
||||
|
||||
# Uncomment below to take inputs from the user
|
||||
# a = float(input('Enter first side: '))
|
||||
# b = float(input('Enter second side: '))
|
||||
# c = float(input('Enter third side: '))
|
||||
|
||||
# calculate the semi-perimeter
|
||||
s = (a + b + c) / 2
|
||||
|
||||
# calculate the area
|
||||
area = (s*(s-a)*(s-b)*(s-c)) ** 0.5
|
||||
print('The area of the triangle is %0.2f' %area)
|
||||
Reference in New Issue
Block a user