41 Commits
Author SHA1 Message Date
robandClaude Opus 4.5 b67d44ac8b Clean up OpenCode ProviderModelNotFoundError messages
- Detect ProviderModelNotFoundError and show clean message
- Extract provider and model IDs from error
- Suggest: connect provider in opencode, use --provider flag, or edit in UI
- Truncate other stderr to 200 chars to avoid code dumps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 01:25:15 -04:00
robandClaude Opus 4.5 9d783af2f9 Improve provider error messages with install suggestion
- Add "smarttools providers install" suggestion when command not found
- Add suggestion when provider exits with error containing "not found"
- Detect empty output and warn about unavailable model
- Show stderr hint when provider returns nothing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 01:11:00 -04:00
robandClaude Opus 4.5 c1499e76d2 Add 'Try It Now' quick demo section to README
60-second demo using Docker container:
- Pull container, install OpenCode, run eli5 on README
- Uses free Big Pickle model by default, no sign-up required
- Demonstrates the tool while explaining itself

Keep native install as 'Quick Start (Native Install)' for regular use.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 00:55:43 -04:00
robandClaude Opus 4.5 c8a0433ecc Update OpenCode provider info with free models and 75+ providers
- Mention 4 free models: Big Pickle, GLM-4.7, Grok Code Fast 1, MiniMax M2.1
- Note that 75+ providers are available through OpenCode
- Change setup to "opens browser to connect more providers"
- Put opencode-pickle first in variants so test uses a free provider

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 00:09:14 -04:00
robandClaude Opus 4.5 e2816bf6f6 Make source ~/.bashrc instruction more prominent after provider install
Always show the IMPORTANT message about refreshing PATH, with clear
step numbering that makes source ~/.bashrc step 1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 23:47:11 -04:00
robandClaude Opus 4.5 d830a483d9 Update Docker provider install instructions for pre-built container
Add separate instructions for:
- Pre-built container: docker run with display/volume flags
- Build from source: docker-compose run setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 23:40:44 -04:00
robandClaude Opus 4.5 38a446640f Add pre-built container pull instructions to README
Users can now pull the image directly from the Gitea registry:
  docker pull gitea.brrd.tech/rob/smarttools:latest

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 23:21:12 -04:00
robandClaude Opus 4.5 543ded0a5f Add interactive provider installer and fix documentation
- Add `smarttools providers install` interactive guide for installing AI CLIs
- Fix OpenCode install URL (remove .sh suffix)
- Auto-refresh PATH after provider installation
- Fix incorrect npm package names in docs:
  - @anthropic-ai/claude-code (not claude-cli)
  - @openai/codex (not codex-cli)
  - @google/gemini-cli (not @anthropic-ai/gemini-cli)
- Update all docs to reflect clone-first install workflow (not PyPI)
- Add Docker provider installation section to README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 17:33:23 -04:00
rob b09008c215 fix: correct Gemini install command (npm @google/gemini-cli) 2025-12-29 15:50:42 -04:00
rob 2cbaaeb9d5 fix: correct Codex install command (npm, not pip) 2025-12-29 15:40:34 -04:00
rob 452cebadda fix: add xdg-utils for browser opening in Docker 2025-12-29 15:19:44 -04:00
robandClaude Opus 4.5 6106b7837a feat: add browser support for provider OAuth in Docker
Docker changes:
- Install Node.js and npm (for Claude CLI)
- Install Firefox ESR for browser-based OAuth
- Install X11 libraries for display forwarding
- Add 'setup' service with display access

Usage:
  xhost +local:docker
  docker-compose run --rm setup
  smarttools providers install

The 'setup' service has DISPLAY and X11 socket access, so browser-based
OAuth flows (Claude, Codex, Gemini, OpenCode) can work inside Docker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 13:15:13 -04:00
robandClaude Opus 4.5 9d3b65118d fix: correct provider setup descriptions for browser-based auth
All major providers (Claude, Codex, Gemini, OpenCode) use browser-based
OAuth authentication, not manual API keys:
- Run the CLI command
- Browser opens for sign-in
- Auth tokens saved automatically

Also added post_install_note for Ollama to show how to add the provider
after installing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 13:09:19 -04:00
robandClaude Opus 4.5 b4c7491784 feat: add interactive provider installation guide
New command: smarttools providers install

Features:
- Lists all provider groups (Claude, Codex, Gemini, OpenCode, Ollama)
- Shows cost, requirements, and included models for each
- Marks already-installed providers
- Offers to run installation command automatically
- Shows post-install setup instructions

Provider groups:
- Anthropic Claude (paid): claude, claude-haiku, claude-opus, claude-sonnet
- OpenAI Codex (paid): codex
- Google Gemini (free tier): gemini, gemini-flash
- OpenCode (free tier): opencode-deepseek, opencode-pickle, etc.
- Ollama (free, local): custom models

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 13:04:17 -04:00
robandClaude Opus 4.5 6abca22019 feat: add providers CLI command for managing AI providers
New commands:
- smarttools providers list - List all providers with status
- smarttools providers check - Check which providers are available
- smarttools providers add <name> <command> - Add/update provider
- smarttools providers remove <name> - Remove provider
- smarttools providers test <name> - Test a provider

Features:
- Shows [+] for available, [-] for missing providers
- Mock provider always shows as available (built-in)
- Helpful installation hints when no providers found
- Can add custom providers (Ollama, local scripts, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 12:53:43 -04:00
robandClaude Opus 4.5 f65d250973 docs: Add Docker section to README
Friends can now clone and test without installing anything locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-24 01:21:56 -04:00
robandClaude Opus 4.5 42be82df6b Add standalone docker-compose.yml
Self-contained Docker setup for SmartTools with no external dependencies.
Includes cli, test, and shell services.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-24 01:01:35 -04:00
robandClaude Opus 4.5 65279bd8c8 Add Docker support and documentation updates
- Add Dockerfile for containerized builds
- Add .dockerignore for cleaner builds
- Add CLAUDE.md for development guidance
- Update README and docs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-23 23:40:13 -04:00
rob 7e9bd0d533 made an adjustment to the adjust code instructions 2025-12-07 05:34:24 -04:00
robandClaude 35835f8254 fix: Add clearer example showing variable assignment pattern
The AI prompt now explicitly shows:
1. Assign Available Variables to standard Python variables first
2. Then use those variables in the code
3. Multi-line example with file writing pattern

This prevents the AI from trying to use {variable} directly in
expressions without proper assignment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 05:15:12 -04:00
robandClaude d09a0088ef fix: Show available variables in triple-quote format in AI prompt
The AI follows the pattern better when the available variables are
shown in the exact format they should be used:
  """{input}""", """{response}""", etc.

This helps the AI generate code that properly uses triple quotes
for variable substitution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 04:58:37 -04:00
robandClaude bcda4150a7 docs: Improve AI auto-adjust prompt with variable substitution guidance
Updated the default prompt template to explain how to use variables:
- Variables must be wrapped in curly braces: {variable}
- Use triple quotes since content may contain quotes/newlines
- Added example: my_var = """{response}"""

This helps the AI generate code that works correctly with the
variable substitution system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 03:57:41 -04:00
robandClaude 0c966a8adb fix: Add variable substitution to code steps
Code steps were executing with literal {variable} placeholders instead
of substituted values. Now substitute_variables() is called on the code
before execution, matching the behavior of prompt steps.

This fixes issues like {outputfile} being treated as a Python set literal
or written as a literal filename.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 02:59:35 -04:00
robandClaude 9c6b682c73 fix: Improve AI auto-adjust prompt to prevent function wrapping
The AI was generating function definitions instead of inline code.
Updated the default prompt template to explicitly:
- Request inline executable code, NOT function definitions
- Clarify that variables are already available
- Emphasize no wrapping in functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 21:39:48 -04:00
robandClaude 541d49b7f9 feat: Add tool categories for organization
- Add category field to Tool dataclass (Text, Developer, Data, Other)
- Display tools grouped by category in main UI with section headers
- Add category dropdown selector in tool builder dialog
- Update example tools with appropriate categories
- Document category feature in README

Categories help organize tools in the UI for easier navigation.
Tools without a category default to "Other".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 04:33:58 -04:00
robandClaude cff3b674ac docs: Add undo/redo and $EDITOR to UI navigation docs
- Added Alt+U/Alt+R for undo/redo in key bindings table
- Added tips about undo/redo (50 states) and $EDITOR button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 03:35:55 -04:00
robandClaude f84e06aa05 fix: Use Alt+U/Alt+R for undo/redo, fix state tracking
- Changed undo/redo keys to Alt+U and Alt+R (meta keys work in urwid)
- Fixed undo logic: now saves state BEFORE edit happens, not after
- Added duplicate state prevention
- Simplified the tracking logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 03:33:13 -04:00
robandClaude 67a83e5379 fix: Change undo/redo keys to Ctrl+U/Ctrl+R
Ctrl+Z is the Unix suspend signal (SIGTSTP) which suspends the process
before urwid can intercept it. Changed to:
- Ctrl+U for undo
- Ctrl+R for redo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 02:48:51 -04:00
robandClaude 6a4e153a62 feat: Add undo/redo for code editor
- Add UndoableEdit class with undo/redo support:
  - Ctrl+Z to undo (up to 50 states)
  - Ctrl+Y or Ctrl+Shift+Z to redo
  - Automatically tracks edit history and cursor position
  - Tab passes through for focus cycling

- Remove syntax highlighting attempt (incompatible with urwid Edit widget's
  text layout engine which requires raw text strings, not markup)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 02:44:03 -04:00
robandClaude f227831feb Add $EDITOR button to open code in external editor
When editing a code step, users can now click the "$EDITOR" button
to open the current code in their preferred external editor:

- Uses $EDITOR environment variable (falls back to $VISUAL, then nano)
- Creates a temp .py file with current code
- Suspends urwid UI while editor runs
- Imports edited code back when editor closes
- Shows success/error status in the dialog

This allows using vim, nano, VS Code, or any editor for complex
code editing while still using the SmartTools UI for workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 02:04:42 -04:00
robandClaude 765a53df1c Add DOS scrollbar to AI prompt editor
Wrap the AI prompt ListBox in DOSScrollBar for consistent
scrolling experience in the auto-adjust panel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:54:38 -04:00
robandClaude aef5c0f99f Add 3D-style buttons with box-drawing characters
Create DOS/BIOS-style 3D button effects using Unicode characters:

Button3D (multi-line):
  ┌──────────┐
  │  Label   │▄
  └──────────┘█

Button3DCompact (single-line):
  ▐ Label ▌▄

Changes:
- Add Button3D class for large standalone buttons
- Add Button3DCompact class for inline/dialog buttons
- Update palette with shadow colors (shadow_edge, button_highlight, button_shadow)
- Replace all ClickableButton/urwid.Button with Button3DCompact throughout:
  - Main menu action buttons (Create, Edit, Delete, Test, Providers, EXIT)
  - Dialog OK/Cancel buttons
  - Load buttons in code/prompt dialogs
  - Provider dropdown buttons
  - Auto-adjust button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:46:20 -04:00
robandClaude b8c85df398 docs: Add AI-Assisted Code Generation section to README
Document the new AI assist panel in the code step dialog:
- Provider selection dropdown
- Editable prompt template with {code} placeholder
- Output/feedback area showing status and errors
- Auto-adjust button to generate/modify code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:40:52 -04:00
robandClaude bdaf9e48de Redesign AI auto-adjust UI with boxed section
- Move auto-adjust feature to a dedicated LineBox panel
- Add full editable prompt template with {code} placeholder
- Add scrollable output/feedback area showing AI responses
- Display success/error messages with provider info
- Side-by-side layout: Code editor on left, AI assist on right
- Increase dialog size to accommodate new layout (90x30)
- Default prompt shows available variables and clear instructions

The AI assist panel now shows:
- Provider selector with dropdown
- Editable prompt (user can customize the full AI instruction)
- Output area with status messages and error details
- Centered Auto-adjust button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:27:22 -04:00
robandClaude c4b5492dc7 Cleanup: remove dead code and improve documentation
- Remove 6 unused methods from ui_urwid.py:
  - select_edit_tool, select_delete_tool, select_test_tool (replaced by main menu)
  - show_tools_list (replaced by info panel)
  - _edit_step_dialog, _edit_argument_dialog (replaced by direct dialog calls)
- Remove unused variable assignment in _edit_argument_at
- Remove ToolInput legacy alias from tool.py (never used)
- Enhance docstrings for DOSScrollBar and TabCyclePile classes
- Net reduction of ~120 lines of dead code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:01:01 -04:00
robandClaude 039df4a6a5 Add DOS-style scrollbars and improve dialog navigation
- Add DOSScrollBar class with ▲/▼ arrow buttons for code/prompt editors
- Expand scrollbar click sensitivity (last 2 columns, zone-based scrolling)
- Fix urwid 3.x compatibility: use explicit ('pack', widget) tuples in Piles
- Add TabPassEdit class for proper Tab cycling in multiline editors
- Add TabCyclePile for focus cycling with Tab key in dialogs
- Add auto-adjust AI feature to automatically set output_var from code
- Update README with new UI navigation documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 00:47:25 -04:00
robandClaude 4c1d3aa4fd Add Bitcoin donation address
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-04 13:13:06 -04:00
robandClaude 02b5a496c4 Update all URLs to point to Gitea
- Changed all github.com references to gitea.brrd.tech/rob/smarttools
- Updated pyproject.toml URLs
- Updated documentation install commands
- Updated curl examples for raw file access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-04 12:47:14 -04:00
robandClaude 4c71dbded2 Add comprehensive documentation and 28 example tools
- Updated README with full feature overview and examples
- Added docs/INSTALL.md with detailed setup instructions
- Added docs/PROVIDERS.md with 12 profiled providers
- Added docs/EXAMPLES.md with all 28 tool configurations
- Added examples/install.py for one-command tool installation
- Updated pyproject.toml with better metadata
- Added urwid TUI with provider descriptions in dropdown
- Profiled all providers for speed/accuracy/cost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-04 12:42:04 -04:00
robandClaude 28dac465e6 Implement core SmartTools functionality
- providers.py: Provider abstraction with mock support
- tool.py: Tool loading, saving, and wrapper script generation
- runner.py: Tool execution engine with prompt building
- ui.py: Dialog-based UI for tool management
- cli.py: CLI entry point with list/create/edit/delete/test commands

~430 lines of Python as designed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-11-28 04:45:39 -04:00
robandClaude ba18c0feaa Initial commit: SmartTools design and README
A lightweight personal tool builder for AI-powered CLI commands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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