diff --git a/README.md b/README.md index 3da5175..1c1f492 100644 --- a/README.md +++ b/README.md @@ -554,7 +554,16 @@ smarttools providers test claude ## Links +### User Documentation - [Installation Guide](docs/INSTALL.md) - [Provider Setup](docs/PROVIDERS.md) - [Example Tools](docs/EXAMPLES.md) +- [User Wiki](wiki/Home.md) + +### Developer Documentation +- [Project Overview](docs/PROJECT.md) - Start here to understand the codebase - [Design Document](docs/DESIGN.md) +- [Registry API Design](docs/REGISTRY.md) +- [Web UI Design](docs/WEB_UI.md) +- [Deployment Guide](docs/DEPLOYMENT.md) +- [Architecture Diagrams](docs/diagrams/) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..7e2f0e8 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,264 @@ +# SmartTools Deployment Guide + +This guide covers deploying the SmartTools Registry web application. + +## Quick Start (Development) + +```bash +# Clone and install +git clone https://gitea.brrd.tech/rob/SmartTools.git +cd SmartTools +pip install -e ".[dev]" + +# Initialize database +cd src/smarttools/web +python -c "from app import create_app; create_app()" + +# Run development server +flask run --port 5050 --host 0.0.0.0 +``` + +Access at `http://localhost:5050` + +## Production Deployment + +### Prerequisites + +- Python 3.8+ +- pip/pipx +- nginx (recommended for reverse proxy) +- systemd (for process management) + +### 1. Install Application + +```bash +# Create application directory +sudo mkdir -p /opt/smarttools +sudo chown $USER:$USER /opt/smarttools +cd /opt/smarttools + +# Clone repository +git clone https://gitea.brrd.tech/rob/SmartTools.git . + +# Create virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -e ".[prod]" +pip install gunicorn +``` + +### 2. Configure Environment + +Create `/opt/smarttools/.env`: + +```bash +# Required +FLASK_ENV=production +SECRET_KEY=your-secret-key-here # Generate with: python -c "import secrets; print(secrets.token_hex(32))" + +# Optional +SENTRY_DSN=https://xxx@sentry.io/xxx # Error monitoring +SITE_URL=https://registry.smarttools.dev # For sitemap/SEO +``` + +### 3. Initialize Database + +```bash +cd /opt/smarttools/src/smarttools/web +python -c "from app import create_app; create_app()" +``` + +Database will be created at `data/smarttools.db`. + +### 4. systemd Service + +Create `/etc/systemd/system/smarttools-web.service`: + +```ini +[Unit] +Description=SmartTools Registry Web Application +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/smarttools +Environment="PATH=/opt/smarttools/venv/bin" +EnvironmentFile=/opt/smarttools/.env +ExecStart=/opt/smarttools/venv/bin/gunicorn \ + --workers 4 \ + --bind 127.0.0.1:5050 \ + --access-logfile /var/log/smarttools/access.log \ + --error-logfile /var/log/smarttools/error.log \ + 'smarttools.web.app:create_app()' +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +# Create log directory +sudo mkdir -p /var/log/smarttools +sudo chown www-data:www-data /var/log/smarttools + +# Enable service +sudo systemctl daemon-reload +sudo systemctl enable smarttools-web +sudo systemctl start smarttools-web + +# Check status +sudo systemctl status smarttools-web +``` + +### 5. nginx Configuration + +Create `/etc/nginx/sites-available/smarttools`: + +```nginx +server { + listen 80; + server_name registry.smarttools.dev; + + # Redirect HTTP to HTTPS + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name registry.smarttools.dev; + + # SSL certificates (use certbot for Let's Encrypt) + ssl_certificate /etc/letsencrypt/live/registry.smarttools.dev/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/registry.smarttools.dev/privkey.pem; + + # SSL settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + + # Static files (optional - Flask can serve these) + location /static/ { + alias /opt/smarttools/src/smarttools/web/static/; + expires 1d; + add_header Cache-Control "public, immutable"; + } + + # Proxy to gunicorn + location / { + proxy_pass http://127.0.0.1:5050; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Enable site: + +```bash +sudo ln -s /etc/nginx/sites-available/smarttools /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +### 6. SSL with Let's Encrypt + +```bash +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d registry.smarttools.dev +``` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `FLASK_ENV` | Yes | `production` or `development` | +| `SECRET_KEY` | Yes | Secret key for sessions (32+ chars) | +| `SENTRY_DSN` | No | Sentry error monitoring DSN | +| `SITE_URL` | No | Public URL for sitemap generation | +| `DATABASE_PATH` | No | Override database path (default: `data/smarttools.db`) | + +## Database Backup + +```bash +# Backup +cp /opt/smarttools/data/smarttools.db /backup/smarttools-$(date +%Y%m%d).db + +# Or use SQLite backup command for consistency +sqlite3 /opt/smarttools/data/smarttools.db ".backup '/backup/smarttools.db'" +``` + +## Monitoring + +### Logs + +```bash +# Application logs +tail -f /var/log/smarttools/error.log +tail -f /var/log/smarttools/access.log + +# systemd logs +journalctl -u smarttools-web -f +``` + +### Sentry Integration + +The application includes Sentry integration for error monitoring. Set `SENTRY_DSN` environment variable to enable. + +### Health Check + +```bash +curl http://localhost:5050/api/v1/tools +``` + +## Troubleshooting + +### Service won't start + +```bash +# Check logs +journalctl -u smarttools-web -n 50 + +# Verify Python path +/opt/smarttools/venv/bin/python -c "import smarttools" + +# Test gunicorn manually +cd /opt/smarttools +source venv/bin/activate +gunicorn --bind 127.0.0.1:5050 'smarttools.web.app:create_app()' +``` + +### Database errors + +```bash +# Check database exists and is readable +ls -la /opt/smarttools/data/smarttools.db + +# Verify permissions +chown www-data:www-data /opt/smarttools/data/ +chown www-data:www-data /opt/smarttools/data/smarttools.db +``` + +### Static files not loading + +```bash +# Rebuild Tailwind CSS +cd /opt/smarttools +npm install +npm run css:build + +# Or use development CSS +# (static/css/output.css should exist) +``` + +## Architecture Diagram + +See `docs/diagrams/deployment-architecture.svg` for visual representation. diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 0000000..0bad2a4 --- /dev/null +++ b/docs/PROJECT.md @@ -0,0 +1,180 @@ +# SmartTools Project Overview + +This document provides a comprehensive overview of the SmartTools project structure, components, and how everything fits together. + +## What is SmartTools? + +SmartTools is a personal tool builder for AI-powered CLI commands. It consists of three main components: + +1. **CLI Tool** (`smarttools`) - Create, manage, and run AI-powered command-line tools +2. **Registry API** - REST API for publishing and discovering tools +3. **Web UI** - Browser interface for the registry with docs, search, and user dashboard + +## Quick Reference + +| Component | Location | Purpose | +|-----------|----------|---------| +| CLI | `src/smarttools/cli.py` | Main entry point for `smarttools` command | +| Registry API | `src/smarttools/registry/` | REST API for tool registry | +| Web UI | `src/smarttools/web/` | Flask web application | +| Tool Storage | `~/.smarttools/` | User's installed tools | +| Database | `data/smarttools.db` | SQLite with FTS5 search | + +## Architecture Diagrams + +See `docs/diagrams/` for visual representations: + +- **system-architecture.svg** - How components interact +- **deployment-architecture.svg** - Production deployment setup +- **source-organization.svg** - Code structure + +## Source Code Structure + +``` +src/smarttools/ +├── cli.py # Entry point, subcommand routing +├── tool.py # Tool dataclasses, YAML loading +├── runner.py # Step execution engine +├── providers.py # AI provider abstraction +├── config.py # Global configuration +├── manifest.py # Tool manifest handling +│ +├── registry/ # Registry API Server +│ ├── app.py # Flask API application +│ ├── db.py # Database operations, FTS5 search +│ ├── rate_limit.py # API rate limiting +│ ├── sync.py # Git sync for tool submissions +│ ├── categorize.py # Auto-categorization +│ └── similarity.py # Similar tool recommendations +│ +├── web/ # Web UI Application +│ ├── app.py # Flask web app, Sentry integration +│ ├── routes.py # Page routes and handlers +│ ├── auth.py # Password hashing, login/register +│ ├── sessions.py # Session management +│ ├── docs_content.py # Documentation text content +│ ├── seo.py # Sitemap, robots.txt +│ ├── filters.py # Jinja template filters +│ ├── templates/ # Jinja HTML templates +│ └── static/ # CSS, JS, images +│ +├── registry_client.py # CLI client for registry API +├── resolver.py # Dependency resolution +│ +├── ui.py # TUI dispatcher +├── ui_urwid.py # urwid TUI implementation +└── ui_snack.py # newt/snack TUI fallback +``` + +## Database Schema + +SQLite database at `data/smarttools.db`: + +| Table | Purpose | +|-------|---------| +| `publishers` | User accounts (username, email, password_hash) | +| `tools` | Published tools (name, owner, description, config) | +| `api_tokens` | Authentication tokens for API access | +| `download_stats` | Tool download tracking | +| `pageviews` | Analytics for tool detail pages | +| `tools_fts` | Full-text search index (FTS5) | + +## API Endpoints + +Base URL: `/api/v1/` + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/tools` | GET | List/search tools | +| `/tools//` | GET | Get tool details | +| `/tools//` | POST | Publish tool (auth required) | +| `/tools///download` | POST | Record download | +| `/me` | GET | Current user info | +| `/tokens` | GET/POST | Manage API tokens | + +## Web Routes + +| Route | Purpose | +|-------|---------| +| `/` | Homepage with featured tools | +| `/tools` | Tool browser with search | +| `/tools//` | Tool detail page | +| `/docs/*` | Documentation pages | +| `/login`, `/register` | Authentication | +| `/dashboard/*` | User dashboard, token management | + +## Key Files to Know + +### Configuration +- `~/.smarttools/providers.yaml` - AI provider configurations +- `~/.smarttools/config.yaml` - Global settings +- `~/.smarttools//config.yaml` - Individual tool configs + +### Development +- `pyproject.toml` - Package configuration +- `pytest.ini` - Test configuration +- `tailwind.config.js` - Tailwind CSS configuration + +### Documentation +- `docs/REGISTRY.md` - Registry design document (comprehensive) +- `docs/WEB_UI.md` - Web UI design document (comprehensive) +- `wiki/Home.md` - User-facing wiki documentation +- `CLAUDE.md` - Development guidance for AI assistants + +## Running the Project + +### Development + +```bash +# Install with dev dependencies +pip install -e ".[dev]" + +# Run CLI +python -m smarttools.cli + +# Run web server (development) +cd src/smarttools/web +flask run --port 5050 +``` + +### Production + +```bash +# Using gunicorn +gunicorn -w 4 -b 0.0.0.0:5050 'smarttools.web.app:create_app()' + +# Environment variables needed: +# FLASK_ENV=production +# SECRET_KEY= +# SENTRY_DSN= +# SITE_URL=https://your-domain.com +``` + +### systemd Service + +See `docs/DEPLOYMENT.md` for systemd service configuration. + +## Testing + +```bash +# Run all tests +pytest + +# Run specific test +pytest tests/test_name.py::test_function + +# Run with coverage +pytest --cov=smarttools +``` + +## Design Documents + +For detailed design decisions and specifications: + +- **docs/REGISTRY.md** - Complete registry API design (Phases 1-8) +- **docs/WEB_UI.md** - Complete web UI design (Phase 7) +- **wiki/Home.md** - User documentation for CLI usage + +## Archive + +Old development discussions and diagrams are preserved in `archive/discussions/` for historical reference. diff --git a/src/smarttools/cli.py b/src/smarttools/cli.py index 66f6d88..54401c6 100644 --- a/src/smarttools/cli.py +++ b/src/smarttools/cli.py @@ -1,1465 +1,11 @@ -"""CLI entry point for SmartTools.""" +"""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 -from .config import load_config, save_config, set_registry_token -from .manifest import ( - load_manifest, save_manifest, create_manifest, find_manifest, - Manifest, Dependency, MANIFEST_FILENAME -) -from .resolver import ( - resolve_tool, find_tool, install_from_registry, uninstall_tool, - list_installed_tools, ToolNotFoundError, ToolSpec -) - - -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 | -|------|---------|-------------| +This module is a thin wrapper for backwards compatibility. +The actual implementation is in the cli/ package. """ - 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 - - -# ------------------------------------------------------------------------- -# Registry Commands -# ------------------------------------------------------------------------- - -def cmd_registry(args): - """Handle registry subcommands.""" - from .registry_client import ( - RegistryClient, RegistryError, RateLimitError, - get_client, search, install_tool as registry_install - ) - - if args.registry_cmd == "search": - try: - client = get_client() - results = client.search_tools( - query=args.query, - category=args.category, - per_page=args.limit or 20 - ) - - if not results.data: - print(f"No tools found matching '{args.query}'") - return 0 - - print(f"Found {results.total} tools:\n") - for tool in results.data: - owner = tool.get("owner", "") - name = tool.get("name", "") - version = tool.get("version", "") - desc = tool.get("description", "") - downloads = tool.get("downloads", 0) - - print(f" {owner}/{name} v{version}") - print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}") - print(f" Downloads: {downloads}") - print() - - if results.total_pages > 1: - print(f"Showing page {results.page}/{results.total_pages}") - - except RegistryError as e: - if e.code == "CONNECTION_ERROR": - print("Could not connect to the registry.", file=sys.stderr) - print("Check your internet connection or try again later.", file=sys.stderr) - elif e.code == "RATE_LIMITED": - print(f"Rate limited. Please wait and try again.", file=sys.stderr) - else: - print(f"Error: {e.message}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error searching registry: {e}", file=sys.stderr) - print("If the problem persists, check: smarttools config show", file=sys.stderr) - return 1 - - return 0 - - elif args.registry_cmd == "install": - tool_spec = args.tool - version = args.version - - print(f"Installing {tool_spec}...") - - try: - resolved = install_from_registry(tool_spec, version) - print(f"Installed: {resolved.full_name}@{resolved.version}") - print(f"Location: {resolved.path}") - - # Show wrapper info - from .tool import BIN_DIR - wrapper_name = resolved.tool.name - if resolved.owner: - # Check for collision - short_wrapper = BIN_DIR / resolved.tool.name - if short_wrapper.exists(): - wrapper_name = f"{resolved.owner}-{resolved.tool.name}" - - print(f"\nRun with: {wrapper_name}") - - except RegistryError as e: - if e.code == "TOOL_NOT_FOUND": - print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) - print(f"Try: smarttools registry search {tool_spec.split('/')[-1]}", file=sys.stderr) - elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": - print(f"Error: {e.message}", file=sys.stderr) - if e.details and "available_versions" in e.details: - versions = e.details["available_versions"] - print(f"Available versions: {', '.join(versions[:5])}", file=sys.stderr) - if e.details.get("latest_stable"): - print(f"Latest stable: {e.details['latest_stable']}", file=sys.stderr) - elif e.code == "CONNECTION_ERROR": - print("Could not connect to the registry.", file=sys.stderr) - print("Check your internet connection or try again later.", file=sys.stderr) - else: - print(f"Error: {e.message}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error installing tool: {e}", file=sys.stderr) - return 1 - - return 0 - - elif args.registry_cmd == "uninstall": - tool_spec = args.tool - - print(f"Uninstalling {tool_spec}...") - - if uninstall_tool(tool_spec): - print(f"Uninstalled: {tool_spec}") - else: - print(f"Tool '{tool_spec}' not found", file=sys.stderr) - return 1 - - return 0 - - elif args.registry_cmd == "info": - tool_spec = args.tool - - try: - # Parse the tool spec - parsed = ToolSpec.parse(tool_spec) - owner = parsed.owner or "official" - - client = get_client() - tool_info = client.get_tool(owner, parsed.name) - - print(f"{tool_info.owner}/{tool_info.name} v{tool_info.version}") - print("=" * 50) - print(f"Description: {tool_info.description}") - print(f"Category: {tool_info.category}") - print(f"Tags: {', '.join(tool_info.tags)}") - print(f"Downloads: {tool_info.downloads}") - print(f"Published: {tool_info.published_at}") - - if tool_info.deprecated: - print() - print(f"DEPRECATED: {tool_info.deprecated_message}") - if tool_info.replacement: - print(f"Use instead: {tool_info.replacement}") - - # Show versions - versions = client.get_tool_versions(owner, parsed.name) - if versions: - print(f"\nVersions: {', '.join(versions[:5])}") - if len(versions) > 5: - print(f" ...and {len(versions) - 5} more") - - print(f"\nInstall: smarttools registry install {tool_info.owner}/{tool_info.name}") - - except RegistryError as e: - if e.code == "TOOL_NOT_FOUND": - print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) - print(f"Try: smarttools registry search {parsed.name}", file=sys.stderr) - elif e.code == "CONNECTION_ERROR": - print("Could not connect to the registry.", file=sys.stderr) - print("Check your internet connection or try again later.", file=sys.stderr) - else: - print(f"Error: {e.message}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error fetching tool info: {e}", file=sys.stderr) - return 1 - - return 0 - - elif args.registry_cmd == "update": - print("Updating registry index...") - - try: - client = get_client() - index = client.get_index(force_refresh=True) - - tool_count = index.get("tool_count", len(index.get("tools", []))) - generated = index.get("generated_at", "unknown") - - print(f"Index updated: {tool_count} tools") - print(f"Generated: {generated}") - - except RegistryError as e: - if e.code == "CONNECTION_ERROR": - print("Could not connect to the registry.", file=sys.stderr) - print("Check your internet connection or try again later.", file=sys.stderr) - elif e.code == "RATE_LIMITED": - print("Rate limited. Please wait a moment and try again.", file=sys.stderr) - else: - print(f"Error: {e.message}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error updating index: {e}", file=sys.stderr) - return 1 - - return 0 - - elif args.registry_cmd == "publish": - # Read tool from current directory or specified path - tool_path = Path(args.path) if args.path else Path.cwd() - - if tool_path.is_dir(): - config_path = tool_path / "config.yaml" - else: - config_path = tool_path - tool_path = config_path.parent - - if not config_path.exists(): - print(f"Error: config.yaml not found in {tool_path}", file=sys.stderr) - return 1 - - # Read config - import yaml - config_yaml = config_path.read_text() - - # Read README if exists - readme_path = tool_path / "README.md" - readme = readme_path.read_text() if readme_path.exists() else "" - - # Validate - try: - data = yaml.safe_load(config_yaml) - name = data.get("name", "") - version = data.get("version", "") - if not name or not version: - print("Error: config.yaml must have 'name' and 'version' fields", file=sys.stderr) - return 1 - except yaml.YAMLError as e: - print(f"Error: Invalid YAML in config.yaml: {e}", file=sys.stderr) - return 1 - - if args.dry_run: - print("Dry run - validating only") - print() - print(f"Would publish:") - print(f" Name: {name}") - print(f" Version: {version}") - print(f" Config: {len(config_yaml)} bytes") - print(f" README: {len(readme)} bytes") - return 0 - - # Check for token - config = load_config() - if not config.registry.token: - print("No registry token configured.") - print() - print("1. Register at: https://gitea.brrd.tech/registry/register") - print("2. Generate a token from your dashboard") - print("3. Enter your token below") - print() - - try: - token = input("Registry token: ").strip() - if not token: - print("Cancelled.") - return 1 - set_registry_token(token) - print("Token saved.") - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - return 1 - - print(f"Publishing {name}@{version}...") - - try: - client = get_client() - result = client.publish_tool(config_yaml, readme) - - pr_url = result.get("pr_url", "") - status = result.get("status", "") - - if status == "published" or result.get("version"): - print(f"Published: {result.get('owner', '')}/{result.get('name', '')}@{result.get('version', version)}") - elif pr_url: - print(f"PR created: {pr_url}") - print("Your tool is pending review.") - else: - print("Published successfully!") - - # Show suggestions if provided (from Phase 6 smart features) - suggestions = result.get("suggestions", {}) - if suggestions: - print() - - # Category suggestion - cat_suggestion = suggestions.get("category") - if cat_suggestion and cat_suggestion.get("suggested"): - confidence = cat_suggestion.get("confidence", 0) - print(f"Suggested category: {cat_suggestion['suggested']} ({confidence:.0%} confidence)") - - # Similar tools warning - similar = suggestions.get("similar_tools", []) - if similar: - print("Similar existing tools:") - for tool in similar[:3]: - similarity = tool.get("similarity", 0) - print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)") - - except RegistryError as e: - if e.code == "UNAUTHORIZED": - print("Authentication failed.", file=sys.stderr) - print("Your token may have expired. Generate a new one from the registry.", file=sys.stderr) - elif e.code == "INVALID_CONFIG": - print(f"Invalid tool config: {e.message}", file=sys.stderr) - print("Check your config.yaml for errors.", file=sys.stderr) - elif e.code == "VERSION_EXISTS": - print(f"Version already exists: {e.message}", file=sys.stderr) - print("Bump the version in config.yaml and try again.", file=sys.stderr) - elif e.code == "CONNECTION_ERROR": - print("Could not connect to the registry.", file=sys.stderr) - print("Check your internet connection or try again later.", file=sys.stderr) - elif e.code == "RATE_LIMITED": - print("Rate limited. Please wait a moment and try again.", file=sys.stderr) - else: - print(f"Error: {e.message}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error publishing: {e}", file=sys.stderr) - return 1 - - return 0 - - elif args.registry_cmd == "my-tools": - try: - client = get_client() - tools = client.get_my_tools() - - if not tools: - print("You haven't published any tools yet.") - print("Publish your first tool with: smarttools registry publish") - return 0 - - print(f"Your published tools ({len(tools)}):\n") - for tool in tools: - status = "[DEPRECATED]" if tool.deprecated else "" - print(f" {tool.owner}/{tool.name} v{tool.version} {status}") - print(f" Downloads: {tool.downloads}") - print() - - except RegistryError as e: - if e.code == "UNAUTHORIZED": - print("Not logged in. Set your registry token first:", file=sys.stderr) - print(" smarttools config set-token ", file=sys.stderr) - print() - print("Don't have a token? Register at the registry website.", file=sys.stderr) - elif e.code == "CONNECTION_ERROR": - print("Could not connect to the registry.", file=sys.stderr) - print("Check your internet connection or try again later.", file=sys.stderr) - elif e.code == "RATE_LIMITED": - print("Rate limited. Please wait a moment and try again.", file=sys.stderr) - else: - print(f"Error: {e.message}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - - return 0 - - elif args.registry_cmd == "browse": - # Launch TUI browser - try: - from .ui_registry import run_registry_browser - return run_registry_browser() - except ImportError: - print("TUI browser requires urwid. Install with:", file=sys.stderr) - print(" pip install 'smarttools[tui]'", file=sys.stderr) - print() - print("Or search from command line:", file=sys.stderr) - print(" smarttools registry search ", file=sys.stderr) - return 1 - - else: - # Default: show registry help - print("Registry commands:") - print(" search Search for tools") - print(" install Install a tool") - print(" uninstall Uninstall a tool") - print(" info Show tool information") - print(" update Update local index cache") - print(" publish [path] Publish a tool") - print(" my-tools List your published tools") - print(" browse Browse tools (TUI)") - return 0 - - -# ------------------------------------------------------------------------- -# Project Commands -# ------------------------------------------------------------------------- - -def cmd_deps(args): - """Show project dependencies from smarttools.yaml.""" - manifest = load_manifest() - - if manifest is None: - print("No smarttools.yaml found in current project.") - print("Create one with: smarttools init") - return 1 - - print(f"Project: {manifest.name} v{manifest.version}") - print() - - if not manifest.dependencies: - print("No dependencies defined.") - print("Add one with: smarttools add ") - return 0 - - print(f"Dependencies ({len(manifest.dependencies)}):") - print() - - for dep in manifest.dependencies: - # Check if installed - installed = find_tool(dep.name) - status = "[installed]" if installed else "[not installed]" - - print(f" {dep.name}") - print(f" Version: {dep.version}") - print(f" Status: {status}") - print() - - if manifest.overrides: - print("Overrides:") - for name, override in manifest.overrides.items(): - if override.provider: - print(f" {name}: provider={override.provider}") - - return 0 - - -def cmd_install_deps(args): - """Install dependencies from smarttools.yaml.""" - from .registry_client import get_client, RegistryError - - manifest = load_manifest() - - if manifest is None: - print("No smarttools.yaml found in current project.") - print("Create one with: smarttools init") - return 1 - - if not manifest.dependencies: - print("No dependencies to install.") - return 0 - - print(f"Installing dependencies for {manifest.name}...") - print() - - failed = [] - installed = [] - - for i, dep in enumerate(manifest.dependencies, 1): - print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}") - - # Check if already installed - existing = find_tool(dep.name) - if existing: - print(f" Already installed: {existing.full_name}") - installed.append(dep.name) - continue - - try: - print(f" Downloading...") - resolved = install_from_registry(dep.name, dep.version) - print(f" Installed: {resolved.full_name}@{resolved.version}") - installed.append(dep.name) - except RegistryError as e: - if e.code == "TOOL_NOT_FOUND": - print(f" Not found in registry") - elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": - print(f" Version {dep.version} not available") - elif e.code == "CONNECTION_ERROR": - print(f" Connection failed (check network)") - else: - print(f" Failed: {e.message}") - failed.append(dep.name) - except Exception as e: - print(f" Failed: {e}") - failed.append(dep.name) - - print() - print(f"Installed {len(installed)} tools") - - if failed: - print(f"Failed: {', '.join(failed)}") - return 1 - - return 0 - - -def cmd_add(args): - """Add a tool to project dependencies.""" - tool_spec = args.tool - version = args.version or "*" - - # Find or create manifest - manifest_path = find_manifest() - if manifest_path: - manifest = load_manifest(manifest_path) - else: - # Create in current directory - manifest = create_manifest(name=Path.cwd().name) - manifest_path = Path.cwd() / MANIFEST_FILENAME - - # Parse tool spec - parsed = ToolSpec.parse(tool_spec) - full_name = parsed.full_name - - # Add dependency - manifest.add_dependency(full_name, version) - - # Save - save_manifest(manifest, manifest_path) - print(f"Added {full_name}@{version} to {manifest_path.name}") - - # Install if requested - if not args.no_install: - from .registry_client import RegistryError - print(f"Installing {full_name}...") - try: - resolved = install_from_registry(tool_spec, version if version != "*" else None) - print(f"Installed: {resolved.full_name}@{resolved.version}") - except RegistryError as e: - if e.code == "TOOL_NOT_FOUND": - print(f"Tool not found in registry.", file=sys.stderr) - print(f"It's been added to your dependencies - you can install it manually later.", file=sys.stderr) - elif e.code == "CONNECTION_ERROR": - print(f"Could not connect to registry.", file=sys.stderr) - print("Run 'smarttools install' to try again later.", file=sys.stderr) - else: - print(f"Install failed: {e.message}", file=sys.stderr) - print("Run 'smarttools install' to try again.", file=sys.stderr) - except Exception as e: - print(f"Install failed: {e}", file=sys.stderr) - print("Run 'smarttools install' to try again.", file=sys.stderr) - - return 0 - - -def cmd_init(args): - """Initialize a new smarttools.yaml.""" - manifest_path = Path.cwd() / MANIFEST_FILENAME - - if manifest_path.exists() and not args.force: - print(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.") - return 1 - - # Get project name - default_name = Path.cwd().name - if args.name: - name = args.name - else: - try: - name = input(f"Project name [{default_name}]: ").strip() or default_name - except (EOFError, KeyboardInterrupt): - print() - name = default_name - - # Get version - if args.version: - version = args.version - else: - try: - version = input("Version [1.0.0]: ").strip() or "1.0.0" - except (EOFError, KeyboardInterrupt): - print() - version = "1.0.0" - - # Create manifest - manifest = create_manifest(name=name, version=version) - save_manifest(manifest, manifest_path) - - print(f"Created {MANIFEST_FILENAME}") - print() - print("Add dependencies with: smarttools add ") - print("Install them with: smarttools install") - - return 0 - - -def cmd_config(args): - """Manage SmartTools configuration.""" - if args.config_cmd == "show": - config = load_config() - print("SmartTools Configuration:") - print(f" Registry URL: {config.registry.url}") - print(f" Token: {'***' if config.registry.token else '(not set)'}") - print(f" Client ID: {config.client_id}") - print(f" Auto-fetch: {config.auto_fetch_from_registry}") - if config.default_provider: - print(f" Default provider: {config.default_provider}") - return 0 - - elif args.config_cmd == "set-token": - token = args.token - set_registry_token(token) - print("Registry token saved.") - return 0 - - elif args.config_cmd == "set": - config = load_config() - key = args.key - value = args.value - - if key == "auto_fetch": - config.auto_fetch_from_registry = value.lower() in ("true", "1", "yes") - elif key == "default_provider": - config.default_provider = value if value else None - elif key == "registry_url": - config.registry.url = value - else: - print(f"Unknown config key: {key}", file=sys.stderr) - print("Available keys: auto_fetch, default_provider, registry_url") - return 1 - - save_config(config) - print(f"Set {key} = {value}") - return 0 - - else: - print("Config commands:") - print(" show Show current configuration") - print(" set-token Set registry authentication token") - print(" set Set a configuration value") - 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))) - - # ------------------------------------------------------------------------- - # Registry Commands - # ------------------------------------------------------------------------- - p_registry = subparsers.add_parser("registry", help="Registry commands (search, install, publish)") - registry_sub = p_registry.add_subparsers(dest="registry_cmd", help="Registry commands") - - # registry search - p_reg_search = registry_sub.add_parser("search", help="Search for tools") - p_reg_search.add_argument("query", help="Search query") - p_reg_search.add_argument("-c", "--category", help="Filter by category") - p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)") - p_reg_search.set_defaults(func=cmd_registry) - - # registry install - p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry") - p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)") - p_reg_install.add_argument("-v", "--version", help="Version constraint") - p_reg_install.set_defaults(func=cmd_registry) - - # registry uninstall - p_reg_uninstall = registry_sub.add_parser("uninstall", help="Uninstall a tool") - p_reg_uninstall.add_argument("tool", help="Tool to uninstall (owner/name)") - p_reg_uninstall.set_defaults(func=cmd_registry) - - # registry info - p_reg_info = registry_sub.add_parser("info", help="Show tool information") - p_reg_info.add_argument("tool", help="Tool name (owner/name)") - p_reg_info.set_defaults(func=cmd_registry) - - # registry update - p_reg_update = registry_sub.add_parser("update", help="Update local index cache") - p_reg_update.set_defaults(func=cmd_registry) - - # registry publish - p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry") - p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)") - p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing") - p_reg_publish.set_defaults(func=cmd_registry) - - # registry my-tools - p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools") - p_reg_mytools.set_defaults(func=cmd_registry) - - # registry browse - p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)") - p_reg_browse.set_defaults(func=cmd_registry) - - # Default for registry with no subcommand - p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args))) - - # ------------------------------------------------------------------------- - # Project Commands - # ------------------------------------------------------------------------- - - # 'deps' command - p_deps = subparsers.add_parser("deps", help="Show project dependencies") - p_deps.set_defaults(func=cmd_deps) - - # 'install' command (for dependencies) - p_install = subparsers.add_parser("install", help="Install dependencies from smarttools.yaml") - p_install.set_defaults(func=cmd_install_deps) - - # 'add' command - p_add = subparsers.add_parser("add", help="Add a tool to project dependencies") - p_add.add_argument("tool", help="Tool to add (owner/name)") - p_add.add_argument("-v", "--version", help="Version constraint (default: *)") - p_add.add_argument("--no-install", action="store_true", help="Don't install after adding") - p_add.set_defaults(func=cmd_add) - - # 'init' command - p_init = subparsers.add_parser("init", help="Initialize smarttools.yaml") - p_init.add_argument("-n", "--name", help="Project name") - p_init.add_argument("-v", "--version", help="Project version") - p_init.add_argument("-f", "--force", action="store_true", help="Overwrite existing") - p_init.set_defaults(func=cmd_init) - - # ------------------------------------------------------------------------- - # Config Commands - # ------------------------------------------------------------------------- - p_config = subparsers.add_parser("config", help="Manage configuration") - config_sub = p_config.add_subparsers(dest="config_cmd", help="Config commands") - - # config show - p_cfg_show = config_sub.add_parser("show", help="Show current configuration") - p_cfg_show.set_defaults(func=cmd_config) - - # config set-token - p_cfg_token = config_sub.add_parser("set-token", help="Set registry authentication token") - p_cfg_token.add_argument("token", help="Registry token") - p_cfg_token.set_defaults(func=cmd_config) - - # config set - p_cfg_set = config_sub.add_parser("set", help="Set a configuration value") - p_cfg_set.add_argument("key", help="Config key") - p_cfg_set.add_argument("value", help="Config value") - p_cfg_set.set_defaults(func=cmd_config) - - # Default for config with no subcommand - p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args))) - - args = parser.parse_args() - - # If no command, launch UI - if args.command is None: - return cmd_ui(args) - - return args.func(args) +from .cli import main if __name__ == "__main__": + import sys sys.exit(main()) diff --git a/src/smarttools/cli/__init__.py b/src/smarttools/cli/__init__.py new file mode 100644 index 0000000..deea620 --- /dev/null +++ b/src/smarttools/cli/__init__.py @@ -0,0 +1,233 @@ +"""CLI entry point for SmartTools.""" + +import argparse +import sys + +from .. import __version__ + +from .tool_commands import ( + cmd_list, cmd_create, cmd_edit, cmd_delete, cmd_test, cmd_run, + cmd_ui, cmd_refresh, cmd_docs +) +from .provider_commands import cmd_providers +from .registry_commands import cmd_registry +from .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init +from .config_commands import cmd_config + + +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))) + + # ------------------------------------------------------------------------- + # Registry Commands + # ------------------------------------------------------------------------- + p_registry = subparsers.add_parser("registry", help="Registry commands (search, install, publish)") + registry_sub = p_registry.add_subparsers(dest="registry_cmd", help="Registry commands") + + # registry search + p_reg_search = registry_sub.add_parser("search", help="Search for tools") + p_reg_search.add_argument("query", help="Search query") + p_reg_search.add_argument("-c", "--category", help="Filter by category") + p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)") + p_reg_search.set_defaults(func=cmd_registry) + + # registry install + p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry") + p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)") + p_reg_install.add_argument("-v", "--version", help="Version constraint") + p_reg_install.set_defaults(func=cmd_registry) + + # registry uninstall + p_reg_uninstall = registry_sub.add_parser("uninstall", help="Uninstall a tool") + p_reg_uninstall.add_argument("tool", help="Tool to uninstall (owner/name)") + p_reg_uninstall.set_defaults(func=cmd_registry) + + # registry info + p_reg_info = registry_sub.add_parser("info", help="Show tool information") + p_reg_info.add_argument("tool", help="Tool name (owner/name)") + p_reg_info.set_defaults(func=cmd_registry) + + # registry update + p_reg_update = registry_sub.add_parser("update", help="Update local index cache") + p_reg_update.set_defaults(func=cmd_registry) + + # registry publish + p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry") + p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)") + p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing") + p_reg_publish.set_defaults(func=cmd_registry) + + # registry my-tools + p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools") + p_reg_mytools.set_defaults(func=cmd_registry) + + # registry browse + p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)") + p_reg_browse.set_defaults(func=cmd_registry) + + # Default for registry with no subcommand + p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args))) + + # ------------------------------------------------------------------------- + # Project Commands + # ------------------------------------------------------------------------- + + # 'deps' command + p_deps = subparsers.add_parser("deps", help="Show project dependencies") + p_deps.set_defaults(func=cmd_deps) + + # 'install' command (for dependencies) + p_install = subparsers.add_parser("install", help="Install dependencies from smarttools.yaml") + p_install.set_defaults(func=cmd_install_deps) + + # 'add' command + p_add = subparsers.add_parser("add", help="Add a tool to project dependencies") + p_add.add_argument("tool", help="Tool to add (owner/name)") + p_add.add_argument("-v", "--version", help="Version constraint (default: *)") + p_add.add_argument("--no-install", action="store_true", help="Don't install after adding") + p_add.set_defaults(func=cmd_add) + + # 'init' command + p_init = subparsers.add_parser("init", help="Initialize smarttools.yaml") + p_init.add_argument("-n", "--name", help="Project name") + p_init.add_argument("-v", "--version", help="Project version") + p_init.add_argument("-f", "--force", action="store_true", help="Overwrite existing") + p_init.set_defaults(func=cmd_init) + + # ------------------------------------------------------------------------- + # Config Commands + # ------------------------------------------------------------------------- + p_config = subparsers.add_parser("config", help="Manage configuration") + config_sub = p_config.add_subparsers(dest="config_cmd", help="Config commands") + + # config show + p_cfg_show = config_sub.add_parser("show", help="Show current configuration") + p_cfg_show.set_defaults(func=cmd_config) + + # config set-token + p_cfg_token = config_sub.add_parser("set-token", help="Set registry authentication token") + p_cfg_token.add_argument("token", help="Registry token") + p_cfg_token.set_defaults(func=cmd_config) + + # config set + p_cfg_set = config_sub.add_parser("set", help="Set a configuration value") + p_cfg_set.add_argument("key", help="Config key") + p_cfg_set.add_argument("value", help="Config value") + p_cfg_set.set_defaults(func=cmd_config) + + # Default for config with no subcommand + p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(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()) diff --git a/src/smarttools/cli/__main__.py b/src/smarttools/cli/__main__.py new file mode 100644 index 0000000..01e1368 --- /dev/null +++ b/src/smarttools/cli/__main__.py @@ -0,0 +1,7 @@ +"""Allow running the CLI as a module.""" + +import sys +from . import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/smarttools/cli/config_commands.py b/src/smarttools/cli/config_commands.py new file mode 100644 index 0000000..b0f46bd --- /dev/null +++ b/src/smarttools/cli/config_commands.py @@ -0,0 +1,62 @@ +"""Configuration commands.""" + +from ..config import load_config, save_config, set_registry_token + + +def cmd_config(args): + """Manage SmartTools configuration.""" + if args.config_cmd == "show": + return _cmd_config_show(args) + elif args.config_cmd == "set-token": + return _cmd_config_set_token(args) + elif args.config_cmd == "set": + return _cmd_config_set(args) + else: + print("Config commands:") + print(" show Show current configuration") + print(" set-token Set registry authentication token") + print(" set Set a configuration value") + return 0 + + +def _cmd_config_show(args): + """Show current configuration.""" + config = load_config() + print("SmartTools Configuration:") + print(f" Registry URL: {config.registry.url}") + print(f" Token: {'***' if config.registry.token else '(not set)'}") + print(f" Client ID: {config.client_id}") + print(f" Auto-fetch: {config.auto_fetch_from_registry}") + if config.default_provider: + print(f" Default provider: {config.default_provider}") + return 0 + + +def _cmd_config_set_token(args): + """Set registry authentication token.""" + token = args.token + set_registry_token(token) + print("Registry token saved.") + return 0 + + +def _cmd_config_set(args): + """Set a configuration value.""" + config = load_config() + key = args.key + value = args.value + + if key == "auto_fetch": + config.auto_fetch_from_registry = value.lower() in ("true", "1", "yes") + elif key == "default_provider": + config.default_provider = value if value else None + elif key == "registry_url": + config.registry.url = value + else: + print(f"Unknown config key: {key}") + print("Available keys: auto_fetch, default_provider, registry_url") + return 1 + + save_config(config) + print(f"Set {key} = {value}") + return 0 diff --git a/src/smarttools/cli/project_commands.py b/src/smarttools/cli/project_commands.py new file mode 100644 index 0000000..2d78a03 --- /dev/null +++ b/src/smarttools/cli/project_commands.py @@ -0,0 +1,202 @@ +"""Project management commands.""" + +import sys +from pathlib import Path + +from ..manifest import ( + load_manifest, save_manifest, create_manifest, find_manifest, + MANIFEST_FILENAME +) +from ..resolver import ( + find_tool, install_from_registry, ToolSpec +) + + +def cmd_deps(args): + """Show project dependencies from smarttools.yaml.""" + manifest = load_manifest() + + if manifest is None: + print("No smarttools.yaml found in current project.") + print("Create one with: smarttools init") + return 1 + + print(f"Project: {manifest.name} v{manifest.version}") + print() + + if not manifest.dependencies: + print("No dependencies defined.") + print("Add one with: smarttools add ") + return 0 + + print(f"Dependencies ({len(manifest.dependencies)}):") + print() + + for dep in manifest.dependencies: + # Check if installed + installed = find_tool(dep.name) + status = "[installed]" if installed else "[not installed]" + + print(f" {dep.name}") + print(f" Version: {dep.version}") + print(f" Status: {status}") + print() + + if manifest.overrides: + print("Overrides:") + for name, override in manifest.overrides.items(): + if override.provider: + print(f" {name}: provider={override.provider}") + + return 0 + + +def cmd_install_deps(args): + """Install dependencies from smarttools.yaml.""" + from ..registry_client import RegistryError + + manifest = load_manifest() + + if manifest is None: + print("No smarttools.yaml found in current project.") + print("Create one with: smarttools init") + return 1 + + if not manifest.dependencies: + print("No dependencies to install.") + return 0 + + print(f"Installing dependencies for {manifest.name}...") + print() + + failed = [] + installed = [] + + for i, dep in enumerate(manifest.dependencies, 1): + print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}") + + # Check if already installed + existing = find_tool(dep.name) + if existing: + print(f" Already installed: {existing.full_name}") + installed.append(dep.name) + continue + + try: + print(f" Downloading...") + resolved = install_from_registry(dep.name, dep.version) + print(f" Installed: {resolved.full_name}@{resolved.version}") + installed.append(dep.name) + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f" Not found in registry") + elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": + print(f" Version {dep.version} not available") + elif e.code == "CONNECTION_ERROR": + print(f" Connection failed (check network)") + else: + print(f" Failed: {e.message}") + failed.append(dep.name) + except Exception as e: + print(f" Failed: {e}") + failed.append(dep.name) + + print() + print(f"Installed {len(installed)} tools") + + if failed: + print(f"Failed: {', '.join(failed)}") + return 1 + + return 0 + + +def cmd_add(args): + """Add a tool to project dependencies.""" + from ..registry_client import RegistryError + + tool_spec = args.tool + version = args.version or "*" + + # Find or create manifest + manifest_path = find_manifest() + if manifest_path: + manifest = load_manifest(manifest_path) + else: + # Create in current directory + manifest = create_manifest(name=Path.cwd().name) + manifest_path = Path.cwd() / MANIFEST_FILENAME + + # Parse tool spec + parsed = ToolSpec.parse(tool_spec) + full_name = parsed.full_name + + # Add dependency + manifest.add_dependency(full_name, version) + + # Save + save_manifest(manifest, manifest_path) + print(f"Added {full_name}@{version} to {manifest_path.name}") + + # Install if requested + if not args.no_install: + print(f"Installing {full_name}...") + try: + resolved = install_from_registry(tool_spec, version if version != "*" else None) + print(f"Installed: {resolved.full_name}@{resolved.version}") + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f"Tool not found in registry.", file=sys.stderr) + print(f"It's been added to your dependencies - you can install it manually later.", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print(f"Could not connect to registry.", file=sys.stderr) + print("Run 'smarttools install' to try again later.", file=sys.stderr) + else: + print(f"Install failed: {e.message}", file=sys.stderr) + print("Run 'smarttools install' to try again.", file=sys.stderr) + except Exception as e: + print(f"Install failed: {e}", file=sys.stderr) + print("Run 'smarttools install' to try again.", file=sys.stderr) + + return 0 + + +def cmd_init(args): + """Initialize a new smarttools.yaml.""" + manifest_path = Path.cwd() / MANIFEST_FILENAME + + if manifest_path.exists() and not args.force: + print(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.") + return 1 + + # Get project name + default_name = Path.cwd().name + if args.name: + name = args.name + else: + try: + name = input(f"Project name [{default_name}]: ").strip() or default_name + except (EOFError, KeyboardInterrupt): + print() + name = default_name + + # Get version + if args.version: + version = args.version + else: + try: + version = input("Version [1.0.0]: ").strip() or "1.0.0" + except (EOFError, KeyboardInterrupt): + print() + version = "1.0.0" + + # Create manifest + manifest = create_manifest(name=name, version=version) + save_manifest(manifest, manifest_path) + + print(f"Created {MANIFEST_FILENAME}") + print() + print("Add dependencies with: smarttools add ") + print("Install them with: smarttools install") + + return 0 diff --git a/src/smarttools/cli/provider_commands.py b/src/smarttools/cli/provider_commands.py new file mode 100644 index 0000000..370a0d4 --- /dev/null +++ b/src/smarttools/cli/provider_commands.py @@ -0,0 +1,307 @@ +"""Provider management commands.""" + +import shutil +import subprocess +from pathlib import Path + +from ..providers import load_providers, add_provider, delete_provider, Provider, call_provider + + +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.""" + if args.providers_cmd == "install": + return _cmd_providers_install(args) + elif args.providers_cmd == "list": + return _cmd_providers_list(args) + elif args.providers_cmd == "add": + return _cmd_providers_add(args) + elif args.providers_cmd == "remove": + return _cmd_providers_remove(args) + elif args.providers_cmd == "test": + return _cmd_providers_test(args) + elif args.providers_cmd == "check": + return _cmd_providers_check(args) + return 0 + + +def _cmd_providers_install(args): + """Interactive guide to install AI providers.""" + 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 + + +def _cmd_providers_list(args): + """List all providers and their status.""" + 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 + + +def _cmd_providers_add(args): + """Add or update a provider.""" + 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 + + +def _cmd_providers_remove(args): + """Remove a provider.""" + name = args.name + if delete_provider(name): + print(f"Provider '{name}' removed.") + return 0 + else: + print(f"Provider '{name}' not found.") + return 1 + + +def _cmd_providers_test(args): + """Test a provider.""" + 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 + + +def _cmd_providers_check(args): + """Check which providers are available.""" + 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 diff --git a/src/smarttools/cli/registry_commands.py b/src/smarttools/cli/registry_commands.py new file mode 100644 index 0000000..2a68fa9 --- /dev/null +++ b/src/smarttools/cli/registry_commands.py @@ -0,0 +1,425 @@ +"""Registry commands.""" + +import sys +from pathlib import Path + +import yaml + +from ..config import load_config, set_registry_token +from ..resolver import install_from_registry, uninstall_tool, ToolSpec + + +def cmd_registry(args): + """Handle registry subcommands.""" + from ..registry_client import RegistryClient, RegistryError, RateLimitError, get_client + + if args.registry_cmd == "search": + return _cmd_registry_search(args) + elif args.registry_cmd == "install": + return _cmd_registry_install(args) + elif args.registry_cmd == "uninstall": + return _cmd_registry_uninstall(args) + elif args.registry_cmd == "info": + return _cmd_registry_info(args) + elif args.registry_cmd == "update": + return _cmd_registry_update(args) + elif args.registry_cmd == "publish": + return _cmd_registry_publish(args) + elif args.registry_cmd == "my-tools": + return _cmd_registry_my_tools(args) + elif args.registry_cmd == "browse": + return _cmd_registry_browse(args) + else: + # Default: show registry help + print("Registry commands:") + print(" search Search for tools") + print(" install Install a tool") + print(" uninstall Uninstall a tool") + print(" info Show tool information") + print(" update Update local index cache") + print(" publish [path] Publish a tool") + print(" my-tools List your published tools") + print(" browse Browse tools (TUI)") + return 0 + + +def _cmd_registry_search(args): + """Search for tools in the registry.""" + from ..registry_client import RegistryError, get_client + + try: + client = get_client() + results = client.search_tools( + query=args.query, + category=args.category, + per_page=args.limit or 20 + ) + + if not results.data: + print(f"No tools found matching '{args.query}'") + return 0 + + print(f"Found {results.total} tools:\n") + for tool in results.data: + owner = tool.get("owner", "") + name = tool.get("name", "") + version = tool.get("version", "") + desc = tool.get("description", "") + downloads = tool.get("downloads", 0) + + print(f" {owner}/{name} v{version}") + print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}") + print(f" Downloads: {downloads}") + print() + + if results.total_pages > 1: + print(f"Showing page {results.page}/{results.total_pages}") + + except RegistryError as e: + if e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print(f"Rate limited. Please wait and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error searching registry: {e}", file=sys.stderr) + print("If the problem persists, check: smarttools config show", file=sys.stderr) + return 1 + + return 0 + + +def _cmd_registry_install(args): + """Install a tool from the registry.""" + from ..registry_client import RegistryError + from ..tool import BIN_DIR + + tool_spec = args.tool + version = args.version + + print(f"Installing {tool_spec}...") + + try: + resolved = install_from_registry(tool_spec, version) + print(f"Installed: {resolved.full_name}@{resolved.version}") + print(f"Location: {resolved.path}") + + # Show wrapper info + wrapper_name = resolved.tool.name + if resolved.owner: + # Check for collision + short_wrapper = BIN_DIR / resolved.tool.name + if short_wrapper.exists(): + wrapper_name = f"{resolved.owner}-{resolved.tool.name}" + + print(f"\nRun with: {wrapper_name}") + + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) + print(f"Try: smarttools registry search {tool_spec.split('/')[-1]}", file=sys.stderr) + elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": + print(f"Error: {e.message}", file=sys.stderr) + if e.details and "available_versions" in e.details: + versions = e.details["available_versions"] + print(f"Available versions: {', '.join(versions[:5])}", file=sys.stderr) + if e.details.get("latest_stable"): + print(f"Latest stable: {e.details['latest_stable']}", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error installing tool: {e}", file=sys.stderr) + return 1 + + return 0 + + +def _cmd_registry_uninstall(args): + """Uninstall a tool.""" + tool_spec = args.tool + + print(f"Uninstalling {tool_spec}...") + + if uninstall_tool(tool_spec): + print(f"Uninstalled: {tool_spec}") + else: + print(f"Tool '{tool_spec}' not found", file=sys.stderr) + return 1 + + return 0 + + +def _cmd_registry_info(args): + """Show tool information.""" + from ..registry_client import RegistryError, get_client + + tool_spec = args.tool + + try: + # Parse the tool spec + parsed = ToolSpec.parse(tool_spec) + owner = parsed.owner or "official" + + client = get_client() + tool_info = client.get_tool(owner, parsed.name) + + print(f"{tool_info.owner}/{tool_info.name} v{tool_info.version}") + print("=" * 50) + print(f"Description: {tool_info.description}") + print(f"Category: {tool_info.category}") + print(f"Tags: {', '.join(tool_info.tags)}") + print(f"Downloads: {tool_info.downloads}") + print(f"Published: {tool_info.published_at}") + + if tool_info.deprecated: + print() + print(f"DEPRECATED: {tool_info.deprecated_message}") + if tool_info.replacement: + print(f"Use instead: {tool_info.replacement}") + + # Show versions + versions = client.get_tool_versions(owner, parsed.name) + if versions: + print(f"\nVersions: {', '.join(versions[:5])}") + if len(versions) > 5: + print(f" ...and {len(versions) - 5} more") + + print(f"\nInstall: smarttools registry install {tool_info.owner}/{tool_info.name}") + + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) + print(f"Try: smarttools registry search {parsed.name}", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error fetching tool info: {e}", file=sys.stderr) + return 1 + + return 0 + + +def _cmd_registry_update(args): + """Update local index cache.""" + from ..registry_client import RegistryError, get_client + + print("Updating registry index...") + + try: + client = get_client() + index = client.get_index(force_refresh=True) + + tool_count = index.get("tool_count", len(index.get("tools", []))) + generated = index.get("generated_at", "unknown") + + print(f"Index updated: {tool_count} tools") + print(f"Generated: {generated}") + + except RegistryError as e: + if e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print("Rate limited. Please wait a moment and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error updating index: {e}", file=sys.stderr) + return 1 + + return 0 + + +def _cmd_registry_publish(args): + """Publish a tool to the registry.""" + from ..registry_client import RegistryError, get_client + + # Read tool from current directory or specified path + tool_path = Path(args.path) if args.path else Path.cwd() + + if tool_path.is_dir(): + config_path = tool_path / "config.yaml" + else: + config_path = tool_path + tool_path = config_path.parent + + if not config_path.exists(): + print(f"Error: config.yaml not found in {tool_path}", file=sys.stderr) + return 1 + + # Read config + config_yaml = config_path.read_text() + + # Read README if exists + readme_path = tool_path / "README.md" + readme = readme_path.read_text() if readme_path.exists() else "" + + # Validate + try: + data = yaml.safe_load(config_yaml) + name = data.get("name", "") + version = data.get("version", "") + if not name or not version: + print("Error: config.yaml must have 'name' and 'version' fields", file=sys.stderr) + return 1 + except yaml.YAMLError as e: + print(f"Error: Invalid YAML in config.yaml: {e}", file=sys.stderr) + return 1 + + if args.dry_run: + print("Dry run - validating only") + print() + print(f"Would publish:") + print(f" Name: {name}") + print(f" Version: {version}") + print(f" Config: {len(config_yaml)} bytes") + print(f" README: {len(readme)} bytes") + return 0 + + # Check for token + config = load_config() + if not config.registry.token: + print("No registry token configured.") + print() + print("1. Register at: https://gitea.brrd.tech/registry/register") + print("2. Generate a token from your dashboard") + print("3. Enter your token below") + print() + + try: + token = input("Registry token: ").strip() + if not token: + print("Cancelled.") + return 1 + set_registry_token(token) + print("Token saved.") + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + return 1 + + print(f"Publishing {name}@{version}...") + + try: + client = get_client() + result = client.publish_tool(config_yaml, readme) + + pr_url = result.get("pr_url", "") + status = result.get("status", "") + + if status == "published" or result.get("version"): + print(f"Published: {result.get('owner', '')}/{result.get('name', '')}@{result.get('version', version)}") + elif pr_url: + print(f"PR created: {pr_url}") + print("Your tool is pending review.") + else: + print("Published successfully!") + + # Show suggestions if provided (from Phase 6 smart features) + suggestions = result.get("suggestions", {}) + if suggestions: + print() + + # Category suggestion + cat_suggestion = suggestions.get("category") + if cat_suggestion and cat_suggestion.get("suggested"): + confidence = cat_suggestion.get("confidence", 0) + print(f"Suggested category: {cat_suggestion['suggested']} ({confidence:.0%} confidence)") + + # Similar tools warning + similar = suggestions.get("similar_tools", []) + if similar: + print("Similar existing tools:") + for tool in similar[:3]: + similarity = tool.get("similarity", 0) + print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)") + + except RegistryError as e: + if e.code == "UNAUTHORIZED": + print("Authentication failed.", file=sys.stderr) + print("Your token may have expired. Generate a new one from the registry.", file=sys.stderr) + elif e.code == "INVALID_CONFIG": + print(f"Invalid tool config: {e.message}", file=sys.stderr) + print("Check your config.yaml for errors.", file=sys.stderr) + elif e.code == "VERSION_EXISTS": + print(f"Version already exists: {e.message}", file=sys.stderr) + print("Bump the version in config.yaml and try again.", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print("Rate limited. Please wait a moment and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error publishing: {e}", file=sys.stderr) + return 1 + + return 0 + + +def _cmd_registry_my_tools(args): + """List your published tools.""" + from ..registry_client import RegistryError, get_client + + try: + client = get_client() + tools = client.get_my_tools() + + if not tools: + print("You haven't published any tools yet.") + print("Publish your first tool with: smarttools registry publish") + return 0 + + print(f"Your published tools ({len(tools)}):\n") + for tool in tools: + status = "[DEPRECATED]" if tool.deprecated else "" + print(f" {tool.owner}/{tool.name} v{tool.version} {status}") + print(f" Downloads: {tool.downloads}") + print() + + except RegistryError as e: + if e.code == "UNAUTHORIZED": + print("Not logged in. Set your registry token first:", file=sys.stderr) + print(" smarttools config set-token ", file=sys.stderr) + print() + print("Don't have a token? Register at the registry website.", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print("Rate limited. Please wait a moment and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + return 0 + + +def _cmd_registry_browse(args): + """Browse tools (TUI).""" + try: + from ..ui_registry import run_registry_browser + return run_registry_browser() + except ImportError: + print("TUI browser requires urwid. Install with:", file=sys.stderr) + print(" pip install 'smarttools[tui]'", file=sys.stderr) + print() + print("Or search from command line:", file=sys.stderr) + print(" smarttools registry search ", file=sys.stderr) + return 1 diff --git a/src/smarttools/cli/tool_commands.py b/src/smarttools/cli/tool_commands.py new file mode 100644 index 0000000..0a0a717 --- /dev/null +++ b/src/smarttools/cli/tool_commands.py @@ -0,0 +1,333 @@ +"""Tool management commands.""" + +import sys +from pathlib import Path + +from ..tool import ( + list_tools, load_tool, save_tool, delete_tool, get_tools_dir, + Tool, ToolArgument, PromptStep, CodeStep +) +from ..ui import run_ui + + +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 + + 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: + 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 ..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 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 + + 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 diff --git a/src/smarttools/providers.py b/src/smarttools/providers.py index fadabaf..f6b332d 100644 --- a/src/smarttools/providers.py +++ b/src/smarttools/providers.py @@ -1,6 +1,7 @@ """Provider abstraction for AI CLI tools.""" import os +import shlex import subprocess import shutil from dataclasses import dataclass, field @@ -166,8 +167,14 @@ def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> Provid # Parse command (expand environment variables) cmd = os.path.expandvars(provider.command) - # Check if base command exists - base_cmd = cmd.split()[0] + # Check if base command exists (use shlex for proper quote handling) + try: + cmd_parts = shlex.split(cmd) + base_cmd = cmd_parts[0] if cmd_parts else cmd.split()[0] + except ValueError: + # shlex failed (unbalanced quotes, etc.) - fall back to simple split + 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): diff --git a/src/smarttools/registry/app.py b/src/smarttools/registry/app.py index 21ea853..3660a75 100644 --- a/src/smarttools/registry/app.py +++ b/src/smarttools/registry/app.py @@ -1076,6 +1076,28 @@ def create_app() -> Flask: except Exception: pass + # Run automated scrutiny + scrutiny_report = None + try: + from .scrutiny import scrutinize_tool + scrutiny_report = scrutinize_tool(config_text, description or "", readme) + except Exception: + pass + + # Check scrutiny decision + if scrutiny_report: + suggestions["scrutiny"] = scrutiny_report + if scrutiny_report.get("decision") == "reject": + # Find the failing check for error message + fail_findings = [f for f in scrutiny_report.get("findings", []) if f.get("result") == "fail"] + fail_msg = fail_findings[0]["message"] if fail_findings else "Tool failed automated review" + return error_response( + "SCRUTINY_FAILED", + f"Tool rejected: {fail_msg}", + 400, + details={"scrutiny": scrutiny_report}, + ) + if dry_run: return jsonify({ "data": { @@ -1088,12 +1110,24 @@ def create_app() -> Flask: }) tags_json = json.dumps(tags) + + # Determine status based on scrutiny + if scrutiny_report and scrutiny_report.get("decision") == "approve": + scrutiny_status = "approved" + elif scrutiny_report and scrutiny_report.get("decision") == "review": + scrutiny_status = "pending_review" + else: + scrutiny_status = "pending" + + scrutiny_json = json.dumps(scrutiny_report) if scrutiny_report else None + g.db.execute( """ INSERT INTO tools ( owner, name, version, description, category, tags, config_yaml, readme, - publisher_id, deprecated, deprecated_message, replacement, downloads, published_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + publisher_id, deprecated, deprecated_message, replacement, downloads, + scrutiny_status, scrutiny_report, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ owner, @@ -1109,6 +1143,8 @@ def create_app() -> Flask: data.get("deprecated_message"), data.get("replacement"), 0, + scrutiny_status, + scrutiny_json, datetime.utcnow().isoformat(), ], ) @@ -1120,7 +1156,7 @@ def create_app() -> Flask: "name": name, "version": version, "pr_url": "", - "status": "pending_review", + "status": scrutiny_status, "suggestions": suggestions, } }) @@ -1154,6 +1190,54 @@ def create_app() -> Flask: }) return jsonify({"data": data}) + @app.route("/api/v1/tools///deprecate", methods=["POST"]) + @require_token + def deprecate_tool(owner: str, name: str) -> Response: + """Mark a tool as deprecated.""" + if g.current_publisher["slug"] != owner: + return error_response("FORBIDDEN", "You can only deprecate your own tools", 403) + + data = request.get_json() or {} + message = (data.get("deprecated_message") or data.get("message") or "").strip() + replacement = (data.get("replacement") or "").strip() or None + + if message and len(message) > 500: + return error_response("VALIDATION_ERROR", "Message too long (max 500)", 400) + + # Update all versions of the tool + result = g.db.execute( + """ + UPDATE tools SET deprecated = 1, deprecated_message = ?, replacement = ? + WHERE owner = ? AND name = ? + """, + [message or None, replacement, owner, name], + ) + if result.rowcount == 0: + return error_response("TOOL_NOT_FOUND", f"Tool {owner}/{name} not found", 404) + g.db.commit() + + return jsonify({"data": {"status": "deprecated", "owner": owner, "name": name}}) + + @app.route("/api/v1/tools///undeprecate", methods=["POST"]) + @require_token + def undeprecate_tool(owner: str, name: str) -> Response: + """Remove deprecation status from a tool.""" + if g.current_publisher["slug"] != owner: + return error_response("FORBIDDEN", "You can only undeprecate your own tools", 403) + + result = g.db.execute( + """ + UPDATE tools SET deprecated = 0, deprecated_message = NULL, replacement = NULL + WHERE owner = ? AND name = ? + """, + [owner, name], + ) + if result.rowcount == 0: + return error_response("TOOL_NOT_FOUND", f"Tool {owner}/{name} not found", 404) + g.db.commit() + + return jsonify({"data": {"status": "active", "owner": owner, "name": name}}) + @app.route("/api/v1/me/settings", methods=["PUT"]) @require_token def update_settings() -> Response: diff --git a/src/smarttools/registry/db.py b/src/smarttools/registry/db.py index abcbcb6..1a1ace5 100644 --- a/src/smarttools/registry/db.py +++ b/src/smarttools/registry/db.py @@ -49,6 +49,8 @@ CREATE TABLE IF NOT EXISTS tools ( deprecated_message TEXT, replacement TEXT, downloads INTEGER DEFAULT 0, + scrutiny_status TEXT DEFAULT 'pending', + scrutiny_report TEXT, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner, name, version) ); diff --git a/src/smarttools/registry/scrutiny.py b/src/smarttools/registry/scrutiny.py new file mode 100644 index 0000000..d1fbdf7 --- /dev/null +++ b/src/smarttools/registry/scrutiny.py @@ -0,0 +1,588 @@ +"""Tool scrutiny system for automated review of published tools. + +This module analyzes tools on publish to verify: +1. Honesty - Does the tool do what its description claims? +2. Transparency - Is behavior visible or hidden/obfuscated? +3. Scope - Does the code stay within expected bounds? +4. Efficiency - Are AI calls necessary or wasteful? +""" + +from __future__ import annotations + +import base64 +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple + +import yaml + + +class CheckResult(Enum): + PASS = "pass" + WARNING = "warning" + FAIL = "fail" + + +@dataclass +class Finding: + """A single finding from scrutiny analysis.""" + check: str + result: CheckResult + message: str + suggestion: Optional[str] = None + location: Optional[str] = None # e.g., "step 2", "code block" + + +@dataclass +class ScrutinyReport: + """Complete scrutiny report for a tool.""" + tool_name: str + findings: List[Finding] = field(default_factory=list) + optimizations: List[Any] = field(default_factory=list) # OptimizationSuggestion list + + @property + def passed(self) -> bool: + """Tool passes if no FAIL results.""" + return not any(f.result == CheckResult.FAIL for f in self.findings) + + @property + def has_warnings(self) -> bool: + return any(f.result == CheckResult.WARNING for f in self.findings) + + @property + def decision(self) -> str: + """Auto-approve, flag for review, or reject.""" + if any(f.result == CheckResult.FAIL for f in self.findings): + return "reject" + if any(f.result == CheckResult.WARNING for f in self.findings): + return "review" + return "approve" + + def to_dict(self) -> Dict[str, Any]: + result = { + "tool_name": self.tool_name, + "decision": self.decision, + "passed": self.passed, + "has_warnings": self.has_warnings, + "findings": [ + { + "check": f.check, + "result": f.result.value, + "message": f.message, + "suggestion": f.suggestion, + "location": f.location, + } + for f in self.findings + ], + } + + # Add optimization suggestions if any + if self.optimizations: + result["optimizations"] = [ + { + "operation": opt.operation, + "current_prompt": opt.current_prompt, + "optimized_code": opt.optimized_code, + "tradeoffs": opt.tradeoffs, + "location": opt.location, + "action": "optional", + } + for opt in self.optimizations + ] + + return result + + +# Keywords that indicate specific behaviors +BEHAVIOR_KEYWORDS = { + "summarize": ["summary", "summarize", "condense", "brief", "shorten", "tldr"], + "translate": ["translate", "translation", "language", "convert to"], + "explain": ["explain", "clarify", "describe", "what is", "how does"], + "fix": ["fix", "correct", "repair", "grammar", "spelling"], + "generate": ["generate", "create", "write", "produce", "make"], + "extract": ["extract", "pull", "find", "identify", "parse"], + "analyze": ["analyze", "review", "examine", "check", "audit"], + "convert": ["convert", "transform", "format", "change to"], +} + +# Patterns that suggest obfuscation or suspicious behavior +SUSPICIOUS_PATTERNS = [ + (r'base64\.(b64decode|decodebytes)', "Base64 decoding detected"), + (r'exec\s*\(', "Dynamic code execution with exec()"), + (r'eval\s*\(', "Dynamic code execution with eval()"), + (r'__import__\s*\(', "Dynamic import detected"), + (r'subprocess\.(run|call|Popen)', "Subprocess execution detected"), + (r'os\.(system|popen|exec)', "OS command execution detected"), + (r'requests\.(get|post|put|delete)', "Network request detected"), + (r'urllib', "URL library usage detected"), + (r'socket\.', "Raw socket usage detected"), + (r'\\x[0-9a-fA-F]{2}', "Hex-encoded strings detected"), +] + +# File/system access patterns +SCOPE_PATTERNS = [ + (r'open\s*\([^)]*["\']/', "Absolute path file access"), + (r'open\s*\([^)]*~', "Home directory file access"), + (r'\.ssh', "SSH directory access"), + (r'\.aws', "AWS credentials access"), + (r'\.env', "Environment file access"), + (r'/etc/', "System config access"), + (r'os\.environ', "Environment variable access"), + (r'keyring|password|credential', "Credential-related access"), +] + +# Operations that could be done with code (with tradeoffs) +# Format: (prompt_pattern, operation_name, code_template, code_benefit, ai_benefit) +CODE_OPTIMIZATIONS = [ + ( + r'count (the |how many )?words', + "Word counting", + 'word_count = len(input.split())', + "Faster, no API cost, deterministic", + "Handles edge cases (hyphenated words, contractions, numbers as words)", + ), + ( + r'count (the |how many )?(lines|line)', + "Line counting", + 'line_count = len(input.strip().split("\\n"))', + "Instant, no API cost", + "Could interpret 'meaningful lines' vs blank lines", + ), + ( + r'count (the |how many )?(characters?|chars?)', + "Character counting", + 'char_count = len(input)', + "Instant, no API cost", + "Could exclude whitespace or count 'visible' characters", + ), + ( + r'convert.*(to|into) json', + "JSON conversion", + 'import json\nresult = json.dumps(data, indent=2)', + "Reliable, no API cost, handles valid input perfectly", + "Can interpret messy/partial data and fix formatting issues", + ), + ( + r'parse.*(json|the json)', + "JSON parsing", + 'import json\nresult = json.loads(input)', + "Fast, no API cost, strict validation", + "Can fix malformed JSON, handle comments, trailing commas", + ), + ( + r'convert.*(to|into) csv', + "CSV conversion", + 'import csv\nimport io\n# ... csv.writer conversion', + "Reliable for structured data", + "Can infer structure from unstructured text", + ), + ( + r'(extract|find|get) (all )?(emails?|email addresses)', + "Email extraction", + 'import re\nemails = re.findall(r"[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}", input)', + "Fast, consistent pattern matching", + "Handles obfuscated emails (user [at] domain), context-aware", + ), + ( + r'(extract|find|get) (all )?(urls?|links?)', + "URL extraction", + 'import re\nurls = re.findall(r"https?://[^\\s<>\"]+", input)', + "Fast, consistent pattern matching", + "Handles partial URLs, context-aware link detection", + ), + ( + r'(uppercase|to upper|make upper)', + "Uppercase conversion", + 'result = input.upper()', + "Instant, no API cost", + "Language-aware (Turkish İ/i, Greek σ/ς edge cases)", + ), + ( + r'(lowercase|to lower|make lower)', + "Lowercase conversion", + 'result = input.lower()', + "Instant, no API cost", + "Language-aware case handling", + ), + ( + r'(capitalize|title case)', + "Title case conversion", + 'result = input.title()', + "Instant, no API cost", + "Knows style rules (articles, prepositions)", + ), + ( + r'(reverse|reversed?) (the |this )?(text|string|order)', + "Text reversal", + 'result = input[::-1]', + "Instant, no API cost", + "Could reverse by word, sentence, or paragraph intelligently", + ), + ( + r'(sort|sorted?) (the |these |this )?(lines?|items?|list)', + "Sorting", + 'result = "\\n".join(sorted(input.strip().split("\\n")))', + "Fast, deterministic alphabetical sort", + "Can sort by meaning ('sort by importance', 'by date mentioned')", + ), + ( + r'remove (duplicate|duplicated?) (lines?|items?)', + "Deduplication", + 'seen = set()\nresult = "\\n".join(x for x in input.split("\\n") if not (x in seen or seen.add(x)))', + "Fast, exact matching", + "Can detect semantic duplicates ('USA' vs 'United States')", + ), + ( + r'(remove|strip|trim) (whitespace|spaces|blank)', + "Whitespace removal", + 'result = " ".join(input.split())', + "Instant, predictable", + "Could preserve meaningful spacing, format-aware", + ), + ( + r'split (by|on|into)', + "Text splitting", + '# result = input.split(delimiter)', + "Fast, exact delimiter matching", + "Can split by meaning ('split into paragraphs about each topic')", + ), +] + + +def analyze_tool(config_yaml: str, description: str) -> ScrutinyReport: + """Run full scrutiny analysis on a tool. + + Args: + config_yaml: The tool's YAML configuration + description: The tool's description + + Returns: + ScrutinyReport with all findings + """ + try: + config = yaml.safe_load(config_yaml) or {} + except yaml.YAMLError: + return ScrutinyReport( + tool_name="unknown", + findings=[Finding( + check="parse", + result=CheckResult.FAIL, + message="Invalid YAML configuration", + )] + ) + + tool_name = config.get("name", "unknown") + report = ScrutinyReport(tool_name=tool_name) + + # Run all checks + report.findings.extend(check_honesty(config, description)) + report.findings.extend(check_transparency(config)) + report.findings.extend(check_scope(config)) + + # Efficiency check returns findings and optimizations + efficiency_findings, optimizations = check_efficiency(config) + report.findings.extend(efficiency_findings) + report.optimizations = optimizations + + # If no findings, add a pass + if not report.findings: + report.findings.append(Finding( + check="overall", + result=CheckResult.PASS, + message="All checks passed", + )) + + return report + + +def check_honesty(config: Dict[str, Any], description: str) -> List[Finding]: + """Check if tool behavior matches its description. + + Analyzes prompts and compares against description claims. + """ + findings = [] + steps = config.get("steps", []) + description_lower = description.lower() + + # Extract all prompt text + all_prompts = "" + for step in steps: + if step.get("type") == "prompt": + all_prompts += " " + (step.get("prompt") or "").lower() + + # Detect what the description claims + claimed_behaviors = set() + for behavior, keywords in BEHAVIOR_KEYWORDS.items(): + if any(kw in description_lower for kw in keywords): + claimed_behaviors.add(behavior) + + # Detect what prompts actually do + actual_behaviors = set() + for behavior, keywords in BEHAVIOR_KEYWORDS.items(): + if any(kw in all_prompts for kw in keywords): + actual_behaviors.add(behavior) + + # Check for mismatches + claimed_not_done = claimed_behaviors - actual_behaviors + done_not_claimed = actual_behaviors - claimed_behaviors + + if claimed_not_done: + findings.append(Finding( + check="honesty", + result=CheckResult.WARNING, + message=f"Description claims '{', '.join(claimed_not_done)}' but prompts don't reflect this", + suggestion="Update description to accurately reflect tool behavior", + )) + + if done_not_claimed and claimed_behaviors: + # Only warn if tool claims to do something specific but also does other things + findings.append(Finding( + check="honesty", + result=CheckResult.WARNING, + message=f"Prompts include '{', '.join(done_not_claimed)}' not mentioned in description", + suggestion="Update description to include all tool capabilities", + )) + + # Check for empty or minimal prompts + for i, step in enumerate(steps): + if step.get("type") == "prompt": + prompt = step.get("prompt", "").strip() + if len(prompt) < 10: + findings.append(Finding( + check="honesty", + result=CheckResult.WARNING, + message="Prompt is unusually short", + location=f"step {i + 1}", + )) + + if not findings: + findings.append(Finding( + check="honesty", + result=CheckResult.PASS, + message="Description matches observed behavior", + )) + + return findings + + +def check_transparency(config: Dict[str, Any]) -> List[Finding]: + """Check for obfuscated or hidden behavior. + + Looks for encoded strings, dynamic execution, etc. + """ + findings = [] + steps = config.get("steps", []) + + for i, step in enumerate(steps): + if step.get("type") == "code": + code = step.get("code", "") + location = f"step {i + 1}" + + # Check for suspicious patterns + for pattern, description in SUSPICIOUS_PATTERNS: + if re.search(pattern, code, re.IGNORECASE): + # Determine severity + if "exec" in pattern or "eval" in pattern: + result = CheckResult.FAIL + msg = f"{description} - potential code injection risk" + elif "socket" in pattern or "subprocess" in pattern: + result = CheckResult.WARNING + msg = f"{description} - flagged for manual review" + else: + result = CheckResult.WARNING + msg = description + + findings.append(Finding( + check="transparency", + result=result, + message=msg, + location=location, + )) + + # Check for obfuscated variable names + obfuscated = re.findall(r'\b([a-z])\1{2,}\b|\b[a-z]{1,2}\d+\b', code) + if len(obfuscated) > 3: + findings.append(Finding( + check="transparency", + result=CheckResult.WARNING, + message="Multiple obfuscated variable names detected", + location=location, + suggestion="Use descriptive variable names", + )) + + # Check for very long single lines (possible obfuscation) + for line_num, line in enumerate(code.split('\n'), 1): + if len(line) > 500: + findings.append(Finding( + check="transparency", + result=CheckResult.WARNING, + message=f"Unusually long code line ({len(line)} chars)", + location=f"{location}, line {line_num}", + )) + + if not findings: + findings.append(Finding( + check="transparency", + result=CheckResult.PASS, + message="No obfuscation detected", + )) + + return findings + + +def check_scope(config: Dict[str, Any]) -> List[Finding]: + """Check if code accesses unexpected resources. + + Flags file system, network, or credential access. + """ + findings = [] + steps = config.get("steps", []) + tool_name = config.get("name", "").lower() + description = config.get("description", "").lower() + + for i, step in enumerate(steps): + if step.get("type") == "code": + code = step.get("code", "") + location = f"step {i + 1}" + + for pattern, description_text in SCOPE_PATTERNS: + if re.search(pattern, code, re.IGNORECASE): + # Check if this access seems expected based on tool name/description + is_expected = False + if "ssh" in pattern and ("ssh" in tool_name or "ssh" in description): + is_expected = True + if "env" in pattern and ("env" in tool_name or "environment" in description): + is_expected = True + if "file" in description_text.lower() and ("file" in tool_name or "file" in description): + is_expected = True + + if not is_expected: + # Credential access is always flagged + if "credential" in pattern or ".ssh" in pattern or ".aws" in pattern: + result = CheckResult.FAIL + suggestion = "Remove credential access or justify in description" + else: + result = CheckResult.WARNING + suggestion = "Document this access in tool description" + + findings.append(Finding( + check="scope", + result=result, + message=description_text, + location=location, + suggestion=suggestion, + )) + + if not findings: + findings.append(Finding( + check="scope", + result=CheckResult.PASS, + message="No unexpected resource access", + )) + + return findings + + +@dataclass +class OptimizationSuggestion: + """A code optimization suggestion for an AI prompt.""" + operation: str + current_prompt: str + optimized_code: str + tradeoffs: Dict[str, str] + location: str + + +def check_efficiency(config: Dict[str, Any]) -> List[Finding]: + """Check for AI calls that could optionally be pure code. + + Generates optimization suggestions with tradeoffs - these are + informational, not penalties. Authors can choose to keep AI + for valid reasons. + """ + findings = [] + steps = config.get("steps", []) + optimizations = [] + + for i, step in enumerate(steps): + if step.get("type") == "prompt": + prompt = step.get("prompt", "") + prompt_lower = prompt.lower() + location = f"step {i + 1}" + + # Check against known optimizable patterns + for pattern, operation, code, code_benefit, ai_benefit in CODE_OPTIMIZATIONS: + if re.search(pattern, prompt_lower): + optimizations.append(OptimizationSuggestion( + operation=operation, + current_prompt=prompt.strip()[:200], # Truncate for display + optimized_code=code, + tradeoffs={ + "code": code_benefit, + "ai": ai_benefit, + }, + location=location, + )) + break # One suggestion per step + + # Add optimization suggestions as findings (not warnings - optional) + for opt in optimizations: + findings.append(Finding( + check="efficiency", + result=CheckResult.PASS, # Not a warning - it's a suggestion + message=f"Optional optimization: {opt.operation} could use code", + location=opt.location, + suggestion=f"Code: {opt.optimized_code}", + )) + + # Check for all-AI tools (informational, not a warning) + code_steps = [s for s in steps if s.get("type") == "code"] + prompt_steps = [s for s in steps if s.get("type") == "prompt"] + + if prompt_steps and not code_steps and len(prompt_steps) > 3: + findings.append(Finding( + check="efficiency", + result=CheckResult.PASS, # Informational + message=f"Tool has {len(prompt_steps)} AI steps - consider if any could be code", + suggestion="Mixing AI and code steps can reduce API costs", + )) + + if not findings: + findings.append(Finding( + check="efficiency", + result=CheckResult.PASS, + message="AI usage appears appropriate", + )) + + return findings, optimizations + + +def get_optimization_details(optimizations: List[OptimizationSuggestion]) -> List[Dict[str, Any]]: + """Convert optimization suggestions to serializable format.""" + return [ + { + "operation": opt.operation, + "current_prompt": opt.current_prompt, + "optimized_code": opt.optimized_code, + "tradeoffs": opt.tradeoffs, + "location": opt.location, + "action": "optional", # Author chooses + } + for opt in optimizations + ] + + +def scrutinize_tool(config_yaml: str, description: str, readme: Optional[str] = None) -> Dict[str, Any]: + """Main entry point for tool scrutiny. + + Args: + config_yaml: The tool's YAML configuration + description: The tool's description + readme: Optional README content (for additional context) + + Returns: + Dictionary with scrutiny results + """ + report = analyze_tool(config_yaml, description) + return report.to_dict() diff --git a/src/smarttools/resolver.py b/src/smarttools/resolver.py index 9394c99..077460f 100644 --- a/src/smarttools/resolver.py +++ b/src/smarttools/resolver.py @@ -7,6 +7,7 @@ Implements the tool resolution order: 4. Error if not found """ +import logging import re import sys from dataclasses import dataclass @@ -19,6 +20,8 @@ from .tool import Tool, TOOLS_DIR, get_bin_dir, BIN_DIR from .config import is_auto_fetch_enabled, load_config from .manifest import load_manifest +logger = logging.getLogger(__name__) + # Local project tools directories (support both legacy and documented paths) LOCAL_TOOLS_DIRS = [Path(".smarttools"), Path("smarttools")] @@ -362,8 +365,10 @@ class ToolResolver: except ImportError: # Registry client not available + logger.debug("Registry client not available") return None except Exception as e: + logger.warning(f"Registry fetch failed: {e}") if self.verbose: print(f"Registry fetch failed: {e}", file=sys.stderr) return None @@ -465,6 +470,7 @@ exec {python_path} -m smarttools.runner {owner}/{name} "$@" return Tool.from_dict(data) except Exception as e: + logger.warning(f"Error loading tool from {config_path}: {e}") if self.verbose: print(f"Error loading tool from {config_path}: {e}", file=sys.stderr) return None diff --git a/src/smarttools/runner.py b/src/smarttools/runner.py index b513884..6e3ee75 100644 --- a/src/smarttools/runner.py +++ b/src/smarttools/runner.py @@ -15,16 +15,35 @@ def substitute_variables(template: str, variables: dict) -> str: """ Substitute {variable} placeholders in a template. + Supports escaping: use {{ for literal { and }} for literal } + Args: template: String with {var} placeholders variables: Dict of variable name -> value Returns: String with placeholders replaced + + Examples: + >>> substitute_variables("Hello {name}", {"name": "World"}) + 'Hello World' + >>> substitute_variables("Use {{braces}}", {"braces": "nope"}) + 'Use {braces}' """ - result = template + # Use unique placeholders for escaped braces + ESCAPE_OPEN = "\x00\x01OPEN\x01\x00" + ESCAPE_CLOSE = "\x00\x01CLOSE\x01\x00" + + # First, replace escaped braces with placeholders + result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE) + + # Now do variable substitution for name, value in variables.items(): result = result.replace(f"{{{name}}}", str(value) if value else "") + + # Finally, restore escaped braces as single braces + result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}") + return result diff --git a/src/smarttools/ui_urwid.py b/src/smarttools/ui_urwid.py index 2a414b5..e4e0e5d 100644 --- a/src/smarttools/ui_urwid.py +++ b/src/smarttools/ui_urwid.py @@ -1,2313 +1,23 @@ -"""BIOS-style TUI for SmartTools using urwid (with mouse support).""" +"""BIOS-style TUI for SmartTools using urwid. -import urwid -from typing import Optional, List, Callable +This module is a thin wrapper for backwards compatibility. +The actual implementation is in the ui_urwid/ package. +""" -from .tool import ( - Tool, ToolArgument, PromptStep, CodeStep, Step, - list_tools, load_tool, save_tool, delete_tool, tool_exists, validate_tool_name +from .ui_urwid import run_ui, SmartToolsUI +from .ui_urwid.palette import PALETTE +from .ui_urwid.widgets import ( + SelectableText, Button3D, Button3DCompact, ClickableButton, + SelectableToolItem, ToolListBox, TabCyclePile, TabPassEdit, + UndoableEdit, DOSScrollBar, ToolBuilderLayout, Dialog ) -from .providers import Provider, load_providers, add_provider, delete_provider, get_provider - -# Color palette - BIOS style with 3D button effects -PALETTE = [ - ('body', 'white', 'dark blue'), - ('header', 'white', 'dark red', 'bold'), - ('footer', 'black', 'light gray'), - # Button colors - raised 3D effect - ('button', 'black', 'light gray'), - ('button_focus', 'white', 'dark red', 'bold'), - ('button_highlight', 'white', 'light gray'), # Top/left edge (light) - ('button_shadow', 'dark gray', 'light gray'), # Bottom/right edge (dark) - ('button_pressed', 'black', 'dark gray'), # Pressed state - # Edit fields - ('edit', 'black', 'light gray'), - ('edit_focus', 'black', 'yellow'), - # List items - ('listbox', 'black', 'light gray'), - ('listbox_focus', 'white', 'dark red'), - # Dialog - ('dialog', 'black', 'light gray'), - ('dialog_border', 'white', 'dark blue'), - # Text styles - ('label', 'yellow', 'dark blue', 'bold'), - ('error', 'white', 'dark red', 'bold'), - ('success', 'light green', 'dark blue', 'bold'), - # 3D shadow elements - ('shadow', 'black', 'black'), - ('shadow_edge', 'dark gray', 'dark blue'), +__all__ = [ + 'run_ui', 'SmartToolsUI', 'PALETTE', + 'SelectableText', 'Button3D', 'Button3DCompact', 'ClickableButton', + 'SelectableToolItem', 'ToolListBox', 'TabCyclePile', 'TabPassEdit', + 'UndoableEdit', 'DOSScrollBar', 'ToolBuilderLayout', 'Dialog' ] - -class SelectableText(urwid.WidgetWrap): - """A selectable text widget for list items.""" - - def __init__(self, text, value=None, on_select=None): - self.value = value - self.on_select = on_select - self.text_widget = urwid.Text(text) - display = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') - super().__init__(display) - - def selectable(self): - return True - - def keypress(self, size, key): - if key == 'enter' and self.on_select: - self.on_select(self.value) - return None - return key - - def mouse_event(self, size, event, button, col, row, focus): - if event == 'mouse press' and button == 1 and self.on_select: - self.on_select(self.value) - return True - return False - - -class Button3D(urwid.WidgetWrap): - """A 3D-style button using box-drawing characters for depth. - - Creates a raised button effect like DOS/BIOS interfaces: - ┌──────────┐ - │ Label │▄ - └──────────┘█ - - When focused, colors change to show selection. - """ - - signals = ['click'] - - def __init__(self, label, on_press=None, user_data=None): - self.label = label - self.on_press = on_press - self.user_data = user_data - self._pressed = False - - # Build the 3D button structure - self._build_widget() - super().__init__(self._widget) - - def _build_widget(self): - """Build the 3D button widget structure.""" - label = self.label - width = len(label) + 4 # Padding inside button - - # Button face with border - # Top edge: ┌────┐ - top = '┌' + '─' * (width - 2) + '┐' - # Middle: │ Label │ with shadow - middle_text = '│ ' + label + ' │' - # Bottom edge: └────┘ with shadow - bottom = '└' + '─' * (width - 2) + '┘' - - # Shadow characters (right and bottom) - shadow_right = '▄' - shadow_bottom = '█' - - # Create the rows - top_row = urwid.Text(top + ' ') # Space for shadow alignment - middle_row = urwid.Columns([ - ('pack', urwid.Text(middle_text)), - ('pack', urwid.Text(('shadow_edge', shadow_right))), - ]) - bottom_row = urwid.Columns([ - ('pack', urwid.Text(bottom)), - ('pack', urwid.Text(('shadow_edge', shadow_right))), - ]) - shadow_row = urwid.Text(('shadow_edge', ' ' + shadow_bottom * (width - 1))) - - # Stack them - pile = urwid.Pile([ - top_row, - middle_row, - bottom_row, - shadow_row, - ]) - - self._widget = urwid.AttrMap(pile, 'button', 'button_focus') - - def selectable(self): - return True - - def keypress(self, size, key): - if key == 'enter': - self._activate() - return None - return key - - def mouse_event(self, size, event, button, col, row, focus): - if button == 1: - if event == 'mouse press': - self._pressed = True - return True - elif event == 'mouse release' and self._pressed: - self._pressed = False - self._activate() - return True - return False - - def _activate(self): - """Trigger the button callback.""" - if self.on_press: - self.on_press(self.user_data) - self._emit('click') - - -class Button3DCompact(urwid.WidgetWrap): - """A compact 3D button that fits on a single line with shadow effect. - - Creates a subtle 3D effect: [ Label ]▌ - - Better for inline use where vertical space is limited. - """ - - signals = ['click'] - - def __init__(self, label, on_press=None, user_data=None): - self.label = label - self.on_press = on_press - self.user_data = user_data - - # Build compact button: ▐ Label ▌ with shadow - # Using block characters for edges - button_text = urwid.Text([ - ('button_highlight', '▐'), - ('button', f' {label} '), - ('button_shadow', '▌'), - ('shadow_edge', '▄'), - ]) - - self._widget = urwid.AttrMap(button_text, None, { - 'button': 'button_focus', - 'button_highlight': 'button_focus', - 'button_shadow': 'button_focus', - }) - super().__init__(self._widget) - - def selectable(self): - return True - - def keypress(self, size, key): - if key == 'enter': - self._activate() - return None - return key - - def mouse_event(self, size, event, button, col, row, focus): - if button == 1 and event == 'mouse release': - self._activate() - return True - return False - - def _activate(self): - if self.on_press: - self.on_press(self.user_data) - self._emit('click') - - -class ClickableButton(urwid.WidgetWrap): - """A button that responds to mouse clicks (legacy wrapper).""" - - def __init__(self, label, on_press=None, user_data=None): - self.on_press = on_press - self.user_data = user_data - button = urwid.Button(label) - if on_press: - urwid.connect_signal(button, 'click', self._handle_click) - display = urwid.AttrMap(button, 'button', 'button_focus') - super().__init__(display) - - def _handle_click(self, button): - if self.on_press: - self.on_press(self.user_data) - - -class SelectableToolItem(urwid.WidgetWrap): - """A selectable tool item that maintains selection state.""" - - def __init__(self, name, on_select=None): - self.name = name - self.on_select = on_select - self._selected = False - self.text_widget = urwid.Text(f" {name} ") - self.attr_map = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') - super().__init__(self.attr_map) - - def selectable(self): - return True - - def set_selected(self, selected): - """Set whether this item is the selected tool.""" - self._selected = selected - if self._selected: - self.attr_map.set_attr_map({None: 'listbox_focus'}) - else: - self.attr_map.set_attr_map({None: 'listbox'}) - - def keypress(self, size, key): - if key == 'enter' and self.on_select: - self.on_select(self.name) - return None - return key - - def mouse_event(self, size, event, button, col, row, focus): - if event == 'mouse press' and button == 1: - # Single click just selects/focuses - don't call on_select - # on_select is only called on Enter key (to edit) - return True - return False - - -class ToolListBox(urwid.ListBox): - """A ListBox that keeps arrow keys internal and passes Tab out.""" - - def __init__(self, body, on_focus_change=None): - super().__init__(body) - self.on_focus_change = on_focus_change - self._last_focus = None - - def keypress(self, size, key): - if key in ('up', 'down'): - # Handle arrow keys internally - navigate within list - result = super().keypress(size, key) - # Check if focus changed - self._check_focus_change() - return result - elif key == 'tab': - # Pass tab out to parent for focus cycling - return key - elif key == 'shift tab': - return key - else: - return super().keypress(size, key) - - def _check_focus_change(self): - """Check if focus changed and notify callback.""" - try: - current = self.focus - if current is not self._last_focus: - self._last_focus = current - if self.on_focus_change and isinstance(current, SelectableToolItem): - self.on_focus_change(current.name) - except (IndexError, TypeError): - pass - - def render(self, size, focus=False): - # Check focus on render too (for initial display) - if focus: - self._check_focus_change() - return super().render(size, focus) - - -class TabCyclePile(urwid.Pile): - """A Pile that uses Tab/Shift-Tab to cycle between specific positions. - - Args: - widget_list: List of widgets (same as urwid.Pile) - tab_positions: List of indices in the pile that Tab should cycle between. - Default is [0] (only first position). - """ - - def __init__(self, widget_list, tab_positions=None): - super().__init__(widget_list) - # Positions in the pile that Tab should cycle between - self.tab_positions = tab_positions or [0] - self._current_tab_idx = 0 - - def keypress(self, size, key): - if key == 'tab': - # Move to next tab position - self._current_tab_idx = (self._current_tab_idx + 1) % len(self.tab_positions) - self.focus_position = self.tab_positions[self._current_tab_idx] - return None - elif key == 'shift tab': - # Move to previous tab position - self._current_tab_idx = (self._current_tab_idx - 1) % len(self.tab_positions) - self.focus_position = self.tab_positions[self._current_tab_idx] - return None - else: - return super().keypress(size, key) - - -class TabPassEdit(urwid.Edit): - """A multiline Edit that passes Tab through for focus cycling instead of inserting tabs.""" - - def keypress(self, size, key): - if key in ('tab', 'shift tab'): - # Pass Tab through to parent for focus cycling - return key - return super().keypress(size, key) - - -class UndoableEdit(urwid.Edit): - """A multiline Edit with undo/redo support. - - Features: - - Undo with Alt+U (up to 50 states) - - Redo with Alt+R - - Tab passes through for focus cycling - """ - - MAX_UNDO = 50 # Maximum undo history size - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._undo_stack = [] # List of (text, cursor_pos) tuples - self._redo_stack = [] - - def keypress(self, size, key): - if key in ('tab', 'shift tab'): - return key - - # Handle undo (Alt+U or meta u) - if key in ('meta u', 'alt u'): - self._undo() - return None - - # Handle redo (Alt+R or meta r) - if key in ('meta r', 'alt r'): - self._redo() - return None - - # Save current state BEFORE the edit for undo - old_text = self.edit_text - old_pos = self.edit_pos - - # Let the parent handle the keypress - result = super().keypress(size, key) - - # If text changed, save the old state to undo stack - if self.edit_text != old_text: - self._save_undo_state(old_text, old_pos) - self._redo_stack.clear() # Clear redo on new edit - - return result - - def _save_undo_state(self, text, pos): - """Save state to undo stack.""" - # Don't save duplicate states - if self._undo_stack and self._undo_stack[-1][0] == text: - return - if len(self._undo_stack) >= self.MAX_UNDO: - self._undo_stack.pop(0) - self._undo_stack.append((text, pos)) - - def _undo(self): - """Restore previous state from undo stack.""" - if not self._undo_stack: - return - - # Save current state to redo stack - self._redo_stack.append((self.edit_text, self.edit_pos)) - - # Restore previous state - text, pos = self._undo_stack.pop() - self.set_edit_text(text) - self.set_edit_pos(min(pos, len(text))) - - def _redo(self): - """Restore state from redo stack.""" - if not self._redo_stack: - return - - # Save current state to undo stack - self._undo_stack.append((self.edit_text, self.edit_pos)) - - # Restore redo state - text, pos = self._redo_stack.pop() - self.set_edit_text(text) - self.set_edit_pos(min(pos, len(text))) - - -class DOSScrollBar(urwid.WidgetWrap): - """A DOS-style scrollbar with arrow buttons at top and bottom. - - Renders a scrollbar on the right side of the wrapped widget with: - - ▲ arrow at top (click to scroll up) - - ░ track with █ thumb showing scroll position - - ▼ arrow at bottom (click to scroll down) - - Click zones (expanded to last 2 columns for easier clicking): - - Top 25%: scroll up 3 lines - - Bottom 25%: scroll down 3 lines - - Middle: page up/down based on which half clicked - """ - - def __init__(self, widget): - self._wrapped = widget - # Create a columns layout: content on left, scrollbar on right - super().__init__(widget) - - def render(self, size, focus=False): - maxcol, maxrow = size - - # Render the wrapped widget with one less column for scrollbar - content_size = (maxcol - 1, maxrow) - content_canvas = self._wrapped.render(content_size, focus) - - # Build the scrollbar column - scrollbar_chars = [] - - # Up arrow at top - scrollbar_chars.append('▲') - - # Calculate thumb position - if maxrow > 2: - track_height = maxrow - 2 # Minus the two arrow buttons - - # Get scroll position info from wrapped widget - try: - if hasattr(self._wrapped, 'rows_max'): - rows_max = self._wrapped.rows_max(content_size) - scroll_pos = self._wrapped.get_scrollpos(content_size) - else: - rows_max = maxrow - scroll_pos = 0 - - if rows_max > maxrow: - # Calculate thumb position within track - thumb_pos = int((scroll_pos / (rows_max - maxrow)) * (track_height - 1)) - thumb_pos = max(0, min(thumb_pos, track_height - 1)) - else: - thumb_pos = 0 - except (AttributeError, TypeError, ZeroDivisionError): - thumb_pos = 0 - - # Build track with thumb - for i in range(track_height): - if i == thumb_pos: - scrollbar_chars.append('█') # Thumb - else: - scrollbar_chars.append('░') # Track - - # Down arrow at bottom - scrollbar_chars.append('▼') - - # Create scrollbar canvas - scrollbar_text = '\n'.join(scrollbar_chars[:maxrow]) - scrollbar_canvas = urwid.Text(scrollbar_text).render((1,)) - - # Combine canvases - combined = urwid.CanvasJoin([ - (content_canvas, None, focus, content_size[0]), - (scrollbar_canvas, None, False, 1), - ]) - - return combined - - def keypress(self, size, key): - maxcol, maxrow = size - content_size = (maxcol - 1, maxrow) - return self._wrapped.keypress(content_size, key) - - def mouse_event(self, size, event, button, col, row, focus): - maxcol, maxrow = size - content_size = (maxcol - 1, maxrow) - - # Expand clickable area - last 2 columns count as scrollbar - if col >= maxcol - 2: - if button == 1 and event == 'mouse press': - # Top 25% of scrollbar = scroll up - if row < maxrow // 4: - for _ in range(3): - self._wrapped.keypress(content_size, 'up') - self._invalidate() - return True - # Bottom 25% of scrollbar = scroll down - elif row >= maxrow - (maxrow // 4): - for _ in range(3): - self._wrapped.keypress(content_size, 'down') - self._invalidate() - return True - # Middle = page up/down based on which half - elif row < maxrow // 2: - for _ in range(maxrow // 2): - self._wrapped.keypress(content_size, 'up') - self._invalidate() - return True - else: - for _ in range(maxrow // 2): - self._wrapped.keypress(content_size, 'down') - self._invalidate() - return True - - # Handle mouse wheel on scrollbar - if button == 4: # Scroll up - for _ in range(3): - self._wrapped.keypress(content_size, 'up') - self._invalidate() - return True - elif button == 5: # Scroll down - for _ in range(3): - self._wrapped.keypress(content_size, 'down') - self._invalidate() - return True - - return True # Consume other scrollbar clicks - - # Pass to wrapped widget - return self._wrapped.mouse_event(content_size, event, button, col, row, focus) - - def selectable(self): - return self._wrapped.selectable() - - def sizing(self): - return frozenset([urwid.Sizing.BOX]) - - -class ToolBuilderLayout(urwid.WidgetWrap): - """Custom layout for tool builder that handles Tab cycling across all sections.""" - - def __init__(self, left_box, args_box, steps_box, args_section, steps_section, bottom_buttons, on_cancel=None): - self._current_section = 0 - self.on_cancel = on_cancel - - # Store references to LineBoxes for title highlighting - self.left_box = left_box - self.args_box = args_box - self.steps_box = steps_box - - # Build visual layout: left column and right column side by side - right_pile = urwid.Pile([ - ('weight', 1, args_section), - ('pack', urwid.Divider()), - ('weight', 1, steps_section), - ]) - - columns = urwid.Columns([ - ('weight', 1, left_box), - ('weight', 1, right_pile), - ], dividechars=1) - - main_pile = urwid.Pile([ - ('weight', 1, columns), - ('pack', urwid.Divider()), - ('pack', bottom_buttons), - ]) - - super().__init__(main_pile) - - # Set initial highlight - self._update_section_titles() - - def keypress(self, size, key): - if key == 'tab': - self._current_section = (self._current_section + 1) % 4 - self._focus_section(self._current_section) - self._update_section_titles() - return None - elif key == 'shift tab': - self._current_section = (self._current_section - 1) % 4 - self._focus_section(self._current_section) - self._update_section_titles() - return None - elif key == 'esc': - # Go back to main menu instead of exiting - if self.on_cancel: - self.on_cancel(None) - return None - else: - return super().keypress(size, key) - - def mouse_event(self, size, event, button, col, row, focus): - # Let the parent handle the mouse event first - result = super().mouse_event(size, event, button, col, row, focus) - - # After mouse click, detect which section has focus and update titles - if event == 'mouse press': - self._detect_current_section() - self._update_section_titles() - - return result - - def _detect_current_section(self): - """Detect which section currently has focus based on widget hierarchy.""" - main_pile = self._w - - # Check if bottom buttons have focus (position 2) - if main_pile.focus_position == 2: - self._current_section = 3 - return - - # Focus is on columns (position 0) - columns = main_pile.contents[0][0] - - if columns.focus_position == 0: - # Left box (Tool Info) - self._current_section = 0 - else: - # Right pile - right_pile = columns.contents[1][0] - if right_pile.focus_position == 0: - # Args section - self._current_section = 1 - else: - # Steps section - self._current_section = 2 - - def _update_section_titles(self): - """Update section titles to highlight the current one with markers.""" - # Section 0 = Tool Info, Section 1 = Arguments, Section 2 = Steps, Section 3 = buttons - if self._current_section == 0: - self.left_box.set_title('[ Tool Info ]') - self.args_box.set_title('Arguments') - self.steps_box.set_title('Execution Steps') - elif self._current_section == 1: - self.left_box.set_title('Tool Info') - self.args_box.set_title('[ Arguments ]') - self.steps_box.set_title('Execution Steps') - elif self._current_section == 2: - self.left_box.set_title('Tool Info') - self.args_box.set_title('Arguments') - self.steps_box.set_title('[ Execution Steps ]') - else: - # Buttons focused - no section highlighted - self.left_box.set_title('Tool Info') - self.args_box.set_title('Arguments') - self.steps_box.set_title('Execution Steps') - - def _focus_section(self, section_idx): - """Set focus to the specified section.""" - # Get the main pile - main_pile = self._w - - if section_idx == 0: - # Tool Info (left box) - focus columns, then left - main_pile.focus_position = 0 # columns - columns = main_pile.contents[0][0] - columns.focus_position = 0 # left box - elif section_idx == 1: - # Arguments section - focus columns, then right, then args - main_pile.focus_position = 0 # columns - columns = main_pile.contents[0][0] - columns.focus_position = 1 # right pile - right_pile = columns.contents[1][0] - right_pile.focus_position = 0 # args section - elif section_idx == 2: - # Steps section - focus columns, then right, then steps - main_pile.focus_position = 0 # columns - columns = main_pile.contents[0][0] - columns.focus_position = 1 # right pile - right_pile = columns.contents[1][0] - right_pile.focus_position = 2 # steps section (after divider) - elif section_idx == 3: - # Save/Cancel buttons - main_pile.focus_position = 2 # bottom buttons (after divider) - - -class Dialog(urwid.WidgetWrap): - """A dialog box overlay with 3D-style buttons.""" - - def __init__(self, title, body, buttons, width=60, height=None): - # Title - title_widget = urwid.Text(('header', f' {title} '), align='center') - - # Buttons row - use 3D compact buttons for dialog actions - button_widgets = [] - for label, callback in buttons: - btn = Button3DCompact(label, callback) - button_widgets.append(btn) - buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2) - buttons_centered = urwid.Padding(buttons_row, align='center', width='pack') - - # Check if body is a box widget - # ListBox is always a box widget. For Piles with weighted items, - # check if it ONLY supports BOX sizing (not FLOW). - is_box_widget = isinstance(body, (urwid.ListBox, urwid.Scrollable, urwid.ScrollBar)) - if not is_box_widget: - try: - sizing = body.sizing() - # Box widget if it ONLY supports BOX sizing - is_box_widget = sizing == frozenset({urwid.Sizing.BOX}) - except (AttributeError, TypeError): - pass - - if is_box_widget: - # Box widget - use directly with weight - pile = urwid.Pile([ - ('pack', title_widget), - ('pack', urwid.Divider('─')), - ('weight', 1, body), - ('pack', urwid.Divider('─')), - ('pack', buttons_centered), - ]) - else: - # Flow widget - wrap in Filler - body_padded = urwid.Padding(body, left=1, right=1) - body_filled = urwid.Filler(body_padded, valign='top') - pile = urwid.Pile([ - ('pack', title_widget), - ('pack', urwid.Divider('─')), - body_filled, - ('pack', urwid.Divider('─')), - ('pack', buttons_centered), - ]) - - # Box it - box = urwid.LineBox(pile, title='', title_align='center') - box = urwid.AttrMap(box, 'dialog') - - super().__init__(box) - - -class SmartToolsUI: - """Urwid-based UI for SmartTools with mouse support.""" - - def __init__(self): - self.loop = None - self.main_widget = None - self.overlay_stack = [] - - def run(self): - """Run the UI.""" - self.show_main_menu() - self.loop = urwid.MainLoop( - self.main_widget, - palette=PALETTE, - unhandled_input=self.handle_input, - handle_mouse=True # Enable mouse support! - ) - self.loop.run() - - def handle_input(self, key): - """Handle global key input.""" - if key in ('q', 'Q', 'esc'): - if self.overlay_stack: - self.close_overlay() - else: - raise urwid.ExitMainLoop() - - def refresh(self): - """Refresh the display.""" - if self.loop: - self.loop.draw_screen() - - def set_main(self, widget): - """Set the main widget.""" - self.main_widget = urwid.AttrMap(widget, 'body') - if self.loop: - self.loop.widget = self.main_widget - - def show_overlay(self, dialog, width=60, height=20): - """Show a dialog overlay.""" - overlay = urwid.Overlay( - dialog, - self.main_widget, - align='center', width=width, - valign='middle', height=height, - ) - self.overlay_stack.append(self.main_widget) - self.main_widget = overlay - if self.loop: - self.loop.widget = self.main_widget - - def close_overlay(self): - """Close the current overlay.""" - if self.overlay_stack: - self.main_widget = self.overlay_stack.pop() - if self.loop: - self.loop.widget = self.main_widget - - def message_box(self, title: str, message: str, callback=None): - """Show a message box.""" - def on_ok(_): - self.close_overlay() - if callback: - callback() - - body = urwid.Text(message) - dialog = Dialog(title, body, [("OK", on_ok)], width=50) - self.show_overlay(dialog, width=52, height=min(10 + message.count('\n'), 20)) - - def yes_no(self, title: str, message: str, on_yes=None, on_no=None): - """Show a yes/no dialog.""" - def handle_yes(_): - self.close_overlay() - if on_yes: - on_yes() - - def handle_no(_): - self.close_overlay() - if on_no: - on_no() - - body = urwid.Text(message) - dialog = Dialog(title, body, [("Yes", handle_yes), ("No", handle_no)], width=50) - self.show_overlay(dialog, width=52, height=10) - - def input_dialog(self, title: str, prompt: str, initial: str, callback: Callable[[str], None]): - """Show an input dialog.""" - edit = urwid.Edit(('label', f"{prompt}: "), initial) - edit = urwid.AttrMap(edit, 'edit', 'edit_focus') - - def on_ok(_): - value = edit.base_widget.edit_text - self.close_overlay() - callback(value) - - def on_cancel(_): - self.close_overlay() - - body = urwid.Filler(edit, valign='top') - dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)], width=50) - self.show_overlay(dialog, width=52, height=8) - - # ==================== Main Menu ==================== - - def show_main_menu(self): - """Show the main menu with tool list and info panel.""" - self._selected_tool_name = None - self._refresh_main_menu() - - def _refresh_main_menu(self): - """Refresh the main menu display.""" - from collections import defaultdict - from .tool import DEFAULT_CATEGORIES - - tools = list_tools() - self._tools_list = tools - - # Group tools by category - tools_by_category = defaultdict(list) - for name in tools: - tool = load_tool(name) - category = tool.category if tool else "Other" - tools_by_category[category].append(name) - - # Build tool list with category headers - tool_items = [] - - # Show categories in defined order, then any custom ones - all_categories = list(DEFAULT_CATEGORIES) - for cat in tools_by_category: - if cat not in all_categories: - all_categories.append(cat) - - for category in all_categories: - if category in tools_by_category and tools_by_category[category]: - # Category header (non-selectable) - header = urwid.AttrMap( - urwid.Text(f"─── {category} ───"), - 'label' - ) - tool_items.append(header) - - # Tools in this category - for name in sorted(tools_by_category[category]): - item = SelectableToolItem(name, on_select=self._on_tool_select) - tool_items.append(item) - - if not tools: - tool_items.append(urwid.Text(('label', " (no tools - click Create to add one) "))) - - self._tool_walker = urwid.SimpleFocusListWalker(tool_items) - tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus) - tool_box = urwid.LineBox(tool_listbox, title='Tools') - - # Action buttons - Tab navigates here from tool list (3D style) - create_btn = Button3DCompact("Create", lambda _: self._create_tool_before_selected()) - edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool()) - delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool()) - test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool()) - providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers()) - - buttons_row = urwid.Columns([ - ('pack', create_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ('pack', urwid.Text(" ")), - ('pack', delete_btn), - ('pack', urwid.Text(" ")), - ('pack', test_btn), - ('pack', urwid.Text(" ")), - ('pack', providers_btn), - ]) - buttons_padded = urwid.Padding(buttons_row, align='left', left=1) - - # Info panel - shows details of selected tool (not focusable) - self._info_name = urwid.Text("") - self._info_desc = urwid.Text("") - self._info_args = urwid.Text("") - self._info_steps = urwid.Text("") - self._info_output = urwid.Text("") - - info_content = urwid.Pile([ - self._info_name, - self._info_desc, - urwid.Divider(), - self._info_args, - urwid.Divider(), - self._info_steps, - urwid.Divider(), - self._info_output, - ]) - info_filler = urwid.Filler(info_content, valign='top') - info_box = urwid.LineBox(info_filler, title='Tool Info') - - # Exit button at bottom (3D style) - exit_btn = Button3DCompact("EXIT", lambda _: self.exit_app()) - exit_centered = urwid.Padding(exit_btn, align='center', width=12) - - # Use a custom Pile that handles Tab to cycle between tool list and buttons - self._main_pile = TabCyclePile([ - ('weight', 1, tool_box), - ('pack', buttons_padded), - ('pack', urwid.Divider('─')), - ('weight', 2, info_box), - ('pack', urwid.Divider()), - ('pack', exit_centered), - ], tab_positions=[0, 1, 5]) # Tool list, buttons row, exit button - - # Header - header = urwid.Text(('header', ' SmartTools Manager '), align='center') - - # Footer - footer = urwid.Text(('footer', ' Arrow:Navigate list | Tab:Jump to buttons | Enter/Click:Select | Q:Quit '), align='center') - - frame = urwid.Frame(self._main_pile, header=header, footer=footer) - self.set_main(frame) - - # Update info for first tool if any - if tools: - self._on_tool_focus(tools[0]) - - def _create_tool_before_selected(self): - """Create a new tool (will appear in list based on name sorting).""" - self.create_tool() - - def _on_tool_focus(self, name): - """Called when a tool is focused/highlighted.""" - self._selected_tool_name = name - - # Update selection state on all tool items - if hasattr(self, '_tool_walker'): - for item in self._tool_walker: - if isinstance(item, SelectableToolItem): - item.set_selected(item.name == name) - - tool = load_tool(name) - - if tool: - self._info_name.set_text(('label', f"Name: {tool.name}")) - self._info_desc.set_text(f"Description: {tool.description or '(none)'}") - - if tool.arguments: - args_text = "Arguments:\n" - for arg in tool.arguments: - default = f" = {arg.default}" if arg.default else "" - args_text += f" {arg.flag} -> {{{arg.variable}}}{default}\n" - else: - args_text = "Arguments: (none)" - self._info_args.set_text(args_text.rstrip()) - - if tool.steps: - steps_text = "Execution Steps:\n" - for i, step in enumerate(tool.steps): - if isinstance(step, PromptStep): - steps_text += f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}\n" - else: - steps_text += f" {i+1}. CODE -> {{{step.output_var}}}\n" - else: - steps_text = "Execution Steps: (none)" - self._info_steps.set_text(steps_text.rstrip()) - - self._info_output.set_text(f"Output: {tool.output}") - else: - self._info_name.set_text("") - self._info_desc.set_text("") - self._info_args.set_text("") - self._info_steps.set_text("") - self._info_output.set_text("") - - def _on_tool_select(self, name): - """Called when a tool is selected (Enter/double-click).""" - # Edit the tool on select - tool = load_tool(name) - if tool: - self.tool_builder(tool) - - def _edit_selected_tool(self): - """Edit the currently selected tool.""" - if self._selected_tool_name: - tool = load_tool(self._selected_tool_name) - if tool: - self.tool_builder(tool) - else: - self.message_box("Edit", "No tool selected.") - - def _delete_selected_tool(self): - """Delete the currently selected tool.""" - if self._selected_tool_name: - name = self._selected_tool_name - def do_delete(): - delete_tool(name) - self._selected_tool_name = None - self.message_box("Deleted", f"Tool '{name}' deleted.", self._refresh_main_menu) - self.yes_no("Confirm", f"Delete tool '{name}'?", on_yes=do_delete) - else: - self.message_box("Delete", "No tool selected.") - - def _test_selected_tool(self): - """Test the currently selected tool.""" - if self._selected_tool_name: - tool = load_tool(self._selected_tool_name) - if tool: - self._test_tool(tool) - else: - self.message_box("Test", "No tool selected.") - - def exit_app(self): - """Exit the application.""" - raise urwid.ExitMainLoop() - - - # ==================== Tool Builder ==================== - - def create_tool(self): - """Create a new tool.""" - self.tool_builder(None) - - def tool_builder(self, existing: Optional[Tool]): - """Main tool builder interface.""" - is_edit = existing is not None - - # Initialize tool - if existing: - tool = Tool( - name=existing.name, - description=existing.description, - arguments=list(existing.arguments), - steps=list(existing.steps), - output=existing.output - ) - else: - tool = Tool(name="", description="", arguments=[], steps=[], output="{input}") - - # Store references for callbacks - self._current_tool = tool - self._is_edit = is_edit - self._selected_arg_idx = None - self._selected_step_idx = None - - self._show_tool_builder() - - def _save_tool_fields(self): - """Save current edit field values to the tool object.""" - if not hasattr(self, '_name_edit') or not hasattr(self, '_current_tool'): - return - - tool = self._current_tool - - # Save name (only if it's an edit widget, not a text label) - if not self._is_edit and hasattr(self._name_edit, 'base_widget'): - name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit - if hasattr(name_edit, 'edit_text'): - tool.name = name_edit.edit_text.strip() - - # Save description - if hasattr(self, '_desc_edit') and hasattr(self._desc_edit, 'base_widget'): - tool.description = self._desc_edit.base_widget.edit_text.strip() - - # Save output - if hasattr(self, '_output_edit') and hasattr(self._output_edit, 'base_widget'): - tool.output = self._output_edit.base_widget.edit_text.strip() - - def _show_tool_builder(self): - """Render the tool builder screen.""" - from .tool import DEFAULT_CATEGORIES - - tool = self._current_tool - - # Create edit widgets - if self._is_edit: - name_widget = urwid.Text(('label', f"Name: {tool.name}")) - else: - name_widget = urwid.AttrMap(urwid.Edit(('label', "Name: "), tool.name), 'edit', 'edit_focus') - self._name_edit = name_widget - - self._desc_edit = urwid.AttrMap(urwid.Edit(('label', "Desc: "), tool.description), 'edit', 'edit_focus') - self._output_edit = urwid.AttrMap(urwid.Edit(('label', "Output: "), tool.output), 'edit', 'edit_focus') - - # Category selector - self._selected_category = [tool.category or "Other"] - category_btn_text = urwid.Text(self._selected_category[0]) - category_btn = urwid.AttrMap( - urwid.Padding(category_btn_text, left=1, right=1), - 'edit', 'edit_focus' - ) - - def show_category_dropdown(_): - """Show category selection popup.""" - def select_category(cat): - def callback(_): - self._selected_category[0] = cat - category_btn_text.set_text(cat) - tool.category = cat - self.close_overlay() - return callback - - items = [] - for cat in DEFAULT_CATEGORIES: - btn = urwid.Button(cat, on_press=select_category(cat)) - items.append(urwid.AttrMap(btn, 'button', 'button_focus')) - - listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) - popup = Dialog("Select Category", listbox, []) - self.show_overlay(popup, width=30, height=len(DEFAULT_CATEGORIES) + 4) - - category_select_btn = Button3DCompact("▼", on_press=show_category_dropdown) - - category_row = urwid.Columns([ - ('pack', urwid.Text(('label', "Category: "))), - ('weight', 1, category_btn), - ('pack', urwid.Text(" ")), - ('pack', category_select_btn), - ]) - - # Left column - fields - left_pile = urwid.Pile([ - ('pack', name_widget), - ('pack', urwid.Divider()), - ('pack', self._desc_edit), - ('pack', urwid.Divider()), - ('pack', category_row), - ('pack', urwid.Divider()), - ('pack', self._output_edit), - ]) - left_box = urwid.LineBox(urwid.Filler(left_pile, valign='top'), title='Tool Info') - - # Arguments list - arg_items = [] - for i, arg in enumerate(tool.arguments): - text = f"{arg.flag} -> {{{arg.variable}}}" - item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_arg_activate(idx)) - item.name = i # Store index - arg_items.append(item) - if not arg_items: - arg_items.append(urwid.Text(('label', " (none) "))) - - self._arg_walker = urwid.SimpleFocusListWalker(arg_items) - args_listbox = ToolListBox(self._arg_walker, on_focus_change=self._on_arg_focus) - args_box = urwid.LineBox(args_listbox, title='Arguments') - - # Argument buttons - arg_add_btn = ClickableButton("Add", lambda _: self._add_argument_dialog()) - arg_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_arg()) - arg_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_arg()) - arg_buttons = urwid.Columns([ - ('pack', arg_add_btn), - ('pack', urwid.Text(" ")), - ('pack', arg_edit_btn), - ('pack', urwid.Text(" ")), - ('pack', arg_del_btn), - ]) - arg_buttons_padded = urwid.Padding(arg_buttons, align='left', left=1) - - # Args section (list + buttons) - args_section = urwid.Pile([ - ('weight', 1, args_box), - ('pack', arg_buttons_padded), - ]) - - # Steps list - step_items = [] - for i, step in enumerate(tool.steps): - if isinstance(step, PromptStep): - text = f"P:{step.provider} -> {{{step.output_var}}}" - else: - text = f"C: -> {{{step.output_var}}}" - item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_step_activate(idx)) - item.name = i # Store index - step_items.append(item) - if not step_items: - step_items.append(urwid.Text(('label', " (none) "))) - - self._step_walker = urwid.SimpleFocusListWalker(step_items) - steps_listbox = ToolListBox(self._step_walker, on_focus_change=self._on_step_focus) - steps_box = urwid.LineBox(steps_listbox, title='Execution Steps') - - # Step buttons - step_add_btn = ClickableButton("Add", lambda _: self._add_step_choice()) - step_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_step()) - step_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_step()) - step_buttons = urwid.Columns([ - ('pack', step_add_btn), - ('pack', urwid.Text(" ")), - ('pack', step_edit_btn), - ('pack', urwid.Text(" ")), - ('pack', step_del_btn), - ]) - step_buttons_padded = urwid.Padding(step_buttons, align='left', left=1) - - # Steps section (list + buttons) - steps_section = urwid.Pile([ - ('weight', 1, steps_box), - ('pack', step_buttons_padded), - ]) - - # Save/Cancel buttons - save_btn = ClickableButton("Save", self._on_save_tool) - cancel_btn = ClickableButton("Cancel", self._on_cancel_tool) - bottom_buttons = urwid.Columns([ - ('pack', save_btn), - ('pack', urwid.Text(" ")), - ('pack', cancel_btn), - ], dividechars=1) - bottom_buttons_centered = urwid.Padding(bottom_buttons, align='center', width='pack') - - # Use ToolBuilderLayout for proper Tab cycling - # Pass LineBoxes for title highlighting and on_cancel for Escape key - body = ToolBuilderLayout( - left_box, args_box, steps_box, - args_section, steps_section, bottom_buttons_centered, - on_cancel=self._on_cancel_tool - ) - - # Frame - title = f"Edit Tool: {tool.name}" if self._is_edit and tool.name else "New Tool" - header = urwid.Text(('header', f' {title} '), align='center') - footer = urwid.Text(('footer', ' Arrow:Navigate | Tab:Next section | Enter/Click:Select | Esc:Cancel '), align='center') - - frame = urwid.Frame(body, header=header, footer=footer) - self.set_main(frame) - - # Set initial selection - if tool.arguments: - self._selected_arg_idx = 0 - self._on_arg_focus(0) - if tool.steps: - self._selected_step_idx = 0 - self._on_step_focus(0) - - def _on_arg_focus(self, idx): - """Called when an argument is focused.""" - if isinstance(idx, int): - self._selected_arg_idx = idx - # Update selection display - if hasattr(self, '_arg_walker'): - for i, item in enumerate(self._arg_walker): - if isinstance(item, SelectableToolItem): - item.set_selected(i == idx) - - def _on_arg_activate(self, idx): - """Called when an argument is activated (Enter/click).""" - self._selected_arg_idx = idx - self._edit_argument_at(idx) - - def _on_step_focus(self, idx): - """Called when a step is focused.""" - if isinstance(idx, int): - self._selected_step_idx = idx - # Update selection display - if hasattr(self, '_step_walker'): - for i, item in enumerate(self._step_walker): - if isinstance(item, SelectableToolItem): - item.set_selected(i == idx) - - def _on_step_activate(self, idx): - """Called when a step is activated (Enter/click).""" - self._selected_step_idx = idx - self._edit_step_at(idx) - - def _edit_selected_arg(self): - """Edit the currently selected argument.""" - if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): - self._edit_argument_at(self._selected_arg_idx) - else: - self.message_box("Edit", "No argument selected.") - - def _delete_selected_arg(self): - """Delete the currently selected argument.""" - if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): - idx = self._selected_arg_idx - arg = self._current_tool.arguments[idx] - def do_delete(): - self._save_tool_fields() - self._current_tool.arguments.pop(idx) - self._selected_arg_idx = None - self._show_tool_builder() - self.yes_no("Delete", f"Delete argument {arg.flag}?", on_yes=do_delete) - else: - self.message_box("Delete", "No argument selected.") - - def _edit_selected_step(self): - """Edit the currently selected step.""" - if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): - self._edit_step_at(self._selected_step_idx) - else: - self.message_box("Edit", "No step selected.") - - def _delete_selected_step(self): - """Delete the currently selected step.""" - if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): - idx = self._selected_step_idx - def do_delete(): - self._save_tool_fields() - self._current_tool.steps.pop(idx) - self._selected_step_idx = None - self._show_tool_builder() - self.yes_no("Delete", f"Delete step {idx + 1}?", on_yes=do_delete) - else: - self.message_box("Delete", "No step selected.") - - def _edit_argument_at(self, idx): - """Edit argument at index.""" - self._do_edit_argument(idx) - - def _edit_step_at(self, idx): - """Edit step at index - opens the appropriate dialog based on step type.""" - # Save current field values before showing dialog - self._save_tool_fields() - - step = self._current_tool.steps[idx] - if isinstance(step, PromptStep): - self._add_prompt_dialog(step, idx) - else: - self._add_code_dialog(step, idx) - - def _add_argument_dialog(self): - """Show add argument dialog.""" - # Save current field values before showing dialog - self._save_tool_fields() - - flag_edit = urwid.Edit(('label', "Flag: "), "--") - var_edit = urwid.Edit(('label', "Variable: "), "") - default_edit = urwid.Edit(('label', "Default: "), "") - - def on_ok(_): - flag = flag_edit.edit_text.strip() - var = var_edit.edit_text.strip() - default = default_edit.edit_text.strip() or None - - if not flag: - return - if not var: - var = flag.lstrip("-").replace("-", "_") - - self._current_tool.arguments.append(ToolArgument( - flag=flag, variable=var, default=default, description="" - )) - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - body = urwid.Pile([ - urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(var_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(default_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Add Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=50, height=14) - - def _do_edit_argument(self, idx): - """Edit an existing argument.""" - # Save current field values before showing dialog - self._save_tool_fields() - - arg = self._current_tool.arguments[idx] - - flag_edit = urwid.Edit(('label', "Flag: "), arg.flag) - var_edit = urwid.Edit(('label', "Variable: "), arg.variable) - default_edit = urwid.Edit(('label', "Default: "), arg.default or "") - - def on_ok(_): - arg.flag = flag_edit.edit_text.strip() - arg.variable = var_edit.edit_text.strip() - arg.default = default_edit.edit_text.strip() or None - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - body = urwid.Pile([ - urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(var_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(default_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Edit Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=50, height=14) - - def _add_step_choice(self): - """Choose step type to add.""" - # Save current field values before showing dialog - self._save_tool_fields() - - def on_prompt(_): - self.close_overlay() - # Defer dialog opening to avoid overlay rendering issues - if self.loop: - self.loop.set_alarm_in(0, lambda loop, data: self._add_prompt_dialog()) - else: - self._add_prompt_dialog() - - def on_code(_): - self.close_overlay() - # Defer dialog opening to avoid overlay rendering issues - if self.loop: - self.loop.set_alarm_in(0, lambda loop, data: self._add_code_dialog()) - else: - self._add_code_dialog() - - def on_cancel(_): - self.close_overlay() - - body = urwid.Text("Choose step type:") - dialog = Dialog("Add Step", body, [("Prompt", on_prompt), ("Code", on_code), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=45, height=9) - - def _get_available_vars(self, up_to=-1): - """Get available variables.""" - tool = self._current_tool - variables = ["input"] - for arg in tool.arguments: - variables.append(arg.variable) - if up_to == -1: - up_to = len(tool.steps) - for i, step in enumerate(tool.steps): - if i >= up_to: - break - variables.append(step.output_var) - return variables - - def _add_prompt_dialog(self, existing=None, idx=-1): - """Add/edit prompt step with provider dropdown and multiline prompt.""" - from .tool import get_tools_dir - - providers = load_providers() - provider_names = [p.name for p in providers] - if not provider_names: - provider_names = ["mock"] - current_provider = existing.provider if existing else provider_names[0] - - # Provider selector state - selected_provider = [current_provider] # Use list to allow mutation in closures - - # Provider dropdown button - provider_btn_text = urwid.Text(current_provider) - provider_btn = urwid.AttrMap( - urwid.Padding(provider_btn_text, left=1, right=1), - 'edit', 'edit_focus' - ) - - def show_provider_dropdown(_): - """Show provider selection popup with descriptions.""" - # Build provider lookup for descriptions - provider_lookup = {p.name: p.description for p in providers} - - # Description display (updates on focus change) - desc_text = urwid.Text("") - desc_box = urwid.AttrMap( - urwid.Padding(desc_text, left=1, right=1), - 'label' - ) - - def update_description(name): - """Update the description text for the focused provider.""" - desc = provider_lookup.get(name, "") - desc_text.set_text(('label', desc if desc else "No description")) - - def select_provider(name): - def callback(_): - selected_provider[0] = name - provider_btn_text.set_text(name) - self.close_overlay() - return callback - - # Create focusable buttons that update description on focus - class DescriptiveButton(urwid.Button): - def __init__(self, name, desc_callback): - super().__init__(name, on_press=select_provider(name)) - self._name = name - self._desc_callback = desc_callback - - def render(self, size, focus=False): - if focus: - self._desc_callback(self._name) - return super().render(size, focus) - - items = [] - for name in provider_names: - # Show short hint inline: "name | short_desc" - short_desc = provider_lookup.get(name, "") - # Extract just the key info (after the timing) - if "|" in short_desc: - short_desc = short_desc.split("|", 1)[1].strip()[:20] - else: - short_desc = short_desc[:20] - - label = f"{name:<18} {short_desc}" - btn = DescriptiveButton(name, update_description) - btn.set_label(label) - items.append(urwid.AttrMap(btn, 'button', 'button_focus')) - - # Set initial description - update_description(provider_names[0]) - - listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) - - # Combine listbox with description footer - body = urwid.Pile([ - ('weight', 1, listbox), - ('pack', urwid.Divider('─')), - ('pack', desc_box), - ]) - - popup = Dialog("Select Provider", body, []) - self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) - - provider_select_btn = Button3DCompact("▼", on_press=show_provider_dropdown) - - # File input for external prompt - default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt" - file_edit = urwid.Edit(('label', "File: "), default_file) - - # Multiline prompt editor - use TabPassEdit so Tab passes through for navigation - prompt_edit = TabPassEdit( - edit_text=existing.prompt if existing else "{input}", - multiline=True - ) - - output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "response") - - vars_available = self._get_available_vars(idx) - vars_text = urwid.Text(('label', f"Variables: {', '.join('{'+v+'}' for v in vars_available)}")) - - status_text = urwid.Text("") - - def do_load(): - """Actually load prompt from file.""" - filename = file_edit.edit_text.strip() - tool_dir = get_tools_dir() / self._current_tool.name - prompt_path = tool_dir / filename - - try: - prompt_edit.set_edit_text(prompt_path.read_text()) - status_text.set_text(('success', f"Loaded from {filename}")) - except Exception as e: - status_text.set_text(('error', f"Load error: {e}")) - - def on_load(_): - """Load prompt from file with confirmation.""" - filename = file_edit.edit_text.strip() - if not filename: - status_text.set_text(('error', "Enter a filename first")) - return - - tool_dir = get_tools_dir() / self._current_tool.name - prompt_path = tool_dir / filename - - if not prompt_path.exists(): - status_text.set_text(('error', f"File not found: {filename}")) - return - - # Show confirmation dialog - def on_yes(_): - self.close_overlay() - do_load() - - def on_no(_): - self.close_overlay() - - confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current prompt.") - confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) - self.show_overlay(confirm_dialog, width=50, height=8) - - def on_ok(_): - provider = selected_provider[0] - prompt = prompt_edit.edit_text.strip() - output_var = output_edit.edit_text.strip() or "response" - prompt_file = file_edit.edit_text.strip() or None - - # Auto-save to file if filename is set - if prompt_file: - tool_dir = get_tools_dir() / self._current_tool.name - tool_dir.mkdir(parents=True, exist_ok=True) - prompt_path = tool_dir / prompt_file - try: - prompt_path.write_text(prompt) - except Exception as e: - status_text.set_text(('error', f"Save error: {e}")) - return - - step = PromptStep(prompt=prompt, provider=provider, output_var=output_var, prompt_file=prompt_file) - - if existing and idx >= 0: - self._current_tool.steps[idx] = step - else: - self._current_tool.steps.append(step) - - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - def on_external_edit(_): - """Open prompt in external editor ($EDITOR).""" - import os - import subprocess - import tempfile - - current_prompt = prompt_edit.edit_text - - # Stop the urwid loop temporarily - if self.loop: - self.loop.stop() - - try: - # Create temp file with current prompt - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: - f.write(current_prompt) - temp_path = f.name - - # Get editor from environment - editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) - - # Run editor - subprocess.run([editor, temp_path], check=True) - - # Read back the edited prompt - with open(temp_path, 'r') as f: - new_prompt = f.read() - - # Update the prompt editor - prompt_edit.set_edit_text(new_prompt) - status_text.set_text(('success', f"Prompt updated from {editor}")) - - # Clean up temp file - os.unlink(temp_path) - - except subprocess.CalledProcessError: - status_text.set_text(('error', "Editor exited with error")) - except FileNotFoundError: - status_text.set_text(('error', f"Editor '{editor}' not found")) - except Exception as e: - status_text.set_text(('error', f"Edit error: {e}")) - finally: - # Restart the urwid loop - if self.loop: - self.loop.start() - - load_btn = Button3DCompact("Load", on_load) - edit_btn = Button3DCompact("$EDITOR", on_external_edit) - - # Prompt editor in a box - use ListBox for proper focus handling and scrolling - # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons - prompt_edit_styled = urwid.AttrMap(prompt_edit, 'edit', 'edit_focus') - prompt_walker = urwid.SimpleFocusListWalker([prompt_edit_styled]) - prompt_listbox = urwid.ListBox(prompt_walker) - prompt_scrollbar = DOSScrollBar(prompt_listbox) - prompt_box = urwid.LineBox(prompt_scrollbar, title="Prompt") - - # Use TabCyclePile so Tab cycles between sections - # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when - # the Pile contains weighted items (urwid 3.x requirement) - body = TabCyclePile([ - ('pack', vars_text), - ('pack', urwid.Divider()), - ('pack', urwid.Columns([ - ('pack', urwid.Text(('label', "Provider: "))), - ('weight', 1, provider_btn), - ('pack', urwid.Text(" ")), - ('pack', provider_select_btn), - ])), - ('pack', urwid.Divider()), - ('pack', urwid.Columns([ - ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), - ('pack', urwid.Text(" ")), - ('pack', load_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ])), - ('pack', status_text), - ('weight', 1, prompt_box), - ('pack', urwid.Divider()), - ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), - ], tab_positions=[2, 4, 6, 8]) - - title = "Edit Prompt Step" if existing else "Add Prompt Step" - dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=75, height=22) - - def _add_code_dialog(self, existing=None, idx=-1): - """Add/edit code step with multiline editor, file support, and AI auto-adjust.""" - from .tool import get_tools_dir - from .providers import load_providers, call_provider - - # File name input (default based on output_var) - default_output_var = existing.output_var if existing else "processed" - default_file = existing.code_file if existing and existing.code_file else f"{default_output_var}.py" - file_edit = urwid.Edit(('label', "File: "), default_file) - - # Multiline code editor with undo/redo (Alt+U / Alt+R) - default_code = existing.code if existing else f"{default_output_var} = input.upper()" - code_edit = UndoableEdit( - edit_text=default_code, - multiline=True - ) - - output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "processed") - - vars_available = self._get_available_vars(idx) - vars_text = urwid.Text(('label', f"Variables: {', '.join(vars_available)}")) - - status_text = urwid.Text("") - - # --- Auto-adjust AI feature --- - providers = load_providers() - provider_names = [p.name for p in providers] - if not provider_names: - provider_names = ["mock"] - selected_ai_provider = [provider_names[0]] - - ai_provider_btn_text = urwid.Text(provider_names[0]) - ai_provider_btn = urwid.AttrMap( - urwid.Padding(ai_provider_btn_text, left=1, right=1), - 'edit', 'edit_focus' - ) - - def show_ai_provider_dropdown(_): - provider_lookup = {p.name: p.description for p in providers} - desc_text = urwid.Text("") - desc_box = urwid.AttrMap(urwid.Padding(desc_text, left=1, right=1), 'label') - - def update_description(name): - desc = provider_lookup.get(name, "") - desc_text.set_text(('label', desc if desc else "No description")) - - def select_provider(name): - def callback(_): - selected_ai_provider[0] = name - ai_provider_btn_text.set_text(name) - self.close_overlay() - return callback - - class DescriptiveButton(urwid.Button): - def __init__(btn_self, name, desc_callback): - super().__init__(name, on_press=select_provider(name)) - btn_self._name = name - btn_self._desc_callback = desc_callback - - def render(btn_self, size, focus=False): - if focus: - btn_self._desc_callback(btn_self._name) - return super().render(size, focus) - - items = [] - for name in provider_names: - short_desc = provider_lookup.get(name, "") - if "|" in short_desc: - short_desc = short_desc.split("|", 1)[1].strip()[:20] - else: - short_desc = short_desc[:20] - label = f"{name:<18} {short_desc}" - btn = DescriptiveButton(name, update_description) - btn.set_label(label) - items.append(urwid.AttrMap(btn, 'button', 'button_focus')) - - update_description(provider_names[0]) - listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) - popup_body = urwid.Pile([ - ('weight', 1, listbox), - ('pack', urwid.Divider('─')), - ('pack', desc_box), - ]) - popup = Dialog("Select Provider", popup_body, []) - self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) - - ai_provider_select_btn = Button3DCompact("▼", on_press=show_ai_provider_dropdown) - - # Default prompt template for AI code generation/adjustment - # Show variables in triple-quote format so the AI follows the pattern - vars_formatted = ', '.join(f'\"\"\"{{{v}}}\"\"\"' for v in vars_available) - default_ai_prompt = f"""Write inline Python code (NOT a function definition) according to my instruction. - -The code runs directly with variable substitution. Assign any "Available Variables" used to a new standard variable first, then use that variable in the code. Use triple quotes and curly braces since the substituted content may contain quotes/newlines. - -Example: -my_var = \"\"\"{{variable}}\"\"\" - -INSTRUCTION: [Describe what you want] - -CURRENT CODE: -```python -{{code}} -``` - -AVAILABLE VARIABLES: {vars_formatted} - -IMPORTANT: Return ONLY executable inline code. Do NOT wrap in a function. -No explanations, no markdown fencing, just the code.""" - - # Multiline editable prompt for AI with DOS-style scrollbar - ai_prompt_edit = TabPassEdit(edit_text=default_ai_prompt, multiline=True) - ai_prompt_styled = urwid.AttrMap(ai_prompt_edit, 'edit', 'edit_focus') - ai_prompt_walker = urwid.SimpleFocusListWalker([ai_prompt_styled]) - ai_prompt_listbox = urwid.ListBox(ai_prompt_walker) - ai_prompt_scrollbar = DOSScrollBar(ai_prompt_listbox) - ai_prompt_box = urwid.LineBox(ai_prompt_scrollbar, title="Prompt") - - # Output/feedback area for AI responses - ai_output_text = urwid.Text("") - ai_output_walker = urwid.SimpleFocusListWalker([ai_output_text]) - ai_output_listbox = urwid.ListBox(ai_output_walker) - ai_output_box = urwid.LineBox(ai_output_listbox, title="Output & Feedback") - - def on_auto_adjust(_): - prompt_template = ai_prompt_edit.edit_text.strip() - if not prompt_template: - ai_output_text.set_text(('error', "Enter a prompt for the AI")) - return - - current_code = code_edit.edit_text.strip() - - # Replace {code} placeholder with actual code - prompt = prompt_template.replace("{code}", current_code) - - provider_name = selected_ai_provider[0] - ai_output_text.set_text(('label', f"Calling {provider_name}...\nPlease wait...")) - self.refresh() - - result = call_provider(provider_name, prompt) - - if result.success: - new_code = result.text.strip() - # Strip markdown code fences if present - if new_code.startswith("```python"): - new_code = new_code[9:] - if new_code.startswith("```"): - new_code = new_code[3:] - if new_code.endswith("```"): - new_code = new_code[:-3] - new_code = new_code.strip() - - code_edit.set_edit_text(new_code) - ai_output_text.set_text(('success', f"✓ Code updated successfully!\n\nProvider: {provider_name}\nResponse length: {len(result.text)} chars")) - else: - error_msg = result.error or "Unknown error" - ai_output_text.set_text(('error', f"✗ Error from {provider_name}:\n\n{error_msg}")) - - auto_adjust_btn = Button3DCompact("Auto-adjust", on_auto_adjust) - - # Build the AI assist box with provider selector, prompt editor, output area, and button - ai_provider_row = urwid.Columns([ - ('pack', urwid.Text(('label', "Provider: "))), - ('pack', ai_provider_btn), - ('pack', ai_provider_select_btn), - ]) - - ai_assist_content = urwid.Pile([ - ('pack', ai_provider_row), - ('pack', urwid.Divider()), - ('weight', 2, ai_prompt_box), - ('weight', 1, ai_output_box), - ('pack', urwid.Padding(auto_adjust_btn, align='center', width=16)), - ]) - ai_assist_box = urwid.LineBox(ai_assist_content, title="AI Assisted Auto-adjust") - # --- End Auto-adjust feature --- - - def do_load(): - """Actually load code from file.""" - filename = file_edit.edit_text.strip() - tool_dir = get_tools_dir() / self._current_tool.name - code_path = tool_dir / filename - - try: - code_edit.set_edit_text(code_path.read_text()) - status_text.set_text(('success', f"Loaded from {filename}")) - except Exception as e: - status_text.set_text(('error', f"Load error: {e}")) - - def on_load(_): - """Load code from file with confirmation.""" - filename = file_edit.edit_text.strip() - if not filename: - status_text.set_text(('error', "Enter a filename first")) - return - - tool_dir = get_tools_dir() / self._current_tool.name - code_path = tool_dir / filename - - if not code_path.exists(): - status_text.set_text(('error', f"File not found: {filename}")) - return - - def on_yes(_): - self.close_overlay() - do_load() - - def on_no(_): - self.close_overlay() - - confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current code.") - confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) - self.show_overlay(confirm_dialog, width=50, height=8) - - def on_ok(_): - import ast - - code = code_edit.edit_text.strip() - output_var = output_edit.edit_text.strip() or "processed" - code_file = file_edit.edit_text.strip() or None - - if code: - try: - ast.parse(code) - except SyntaxError as e: - line_info = f" (line {e.lineno})" if e.lineno else "" - status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}")) - return - - if code_file: - tool_dir = get_tools_dir() / self._current_tool.name - tool_dir.mkdir(parents=True, exist_ok=True) - code_path = tool_dir / code_file - try: - code_path.write_text(code) - except Exception as e: - status_text.set_text(('error', f"Save error: {e}")) - return - - step = CodeStep(code=code, output_var=output_var, code_file=code_file) - - if existing and idx >= 0: - self._current_tool.steps[idx] = step - else: - self._current_tool.steps.append(step) - - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - def on_external_edit(_): - """Open code in external editor ($EDITOR).""" - import os - import subprocess - import tempfile - - current_code = code_edit.edit_text - - # Stop the urwid loop temporarily - if self.loop: - self.loop.stop() - - try: - # Create temp file with current code - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: - f.write(current_code) - temp_path = f.name - - # Get editor from environment - editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) - - # Run editor - subprocess.run([editor, temp_path], check=True) - - # Read back the edited code - with open(temp_path, 'r') as f: - new_code = f.read() - - # Update the code editor - code_edit.set_edit_text(new_code) - status_text.set_text(('success', f"Code updated from {editor}")) - - # Clean up temp file - os.unlink(temp_path) - - except subprocess.CalledProcessError: - status_text.set_text(('error', "Editor exited with error")) - except FileNotFoundError: - status_text.set_text(('error', f"Editor '{editor}' not found")) - except Exception as e: - status_text.set_text(('error', f"Edit error: {e}")) - finally: - # Restart the urwid loop - if self.loop: - self.loop.start() - - load_btn = Button3DCompact("Load", on_load) - edit_btn = Button3DCompact("$EDITOR", on_external_edit) - - # Code editor in a box - use ListBox for proper focus handling and scrolling - # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons - code_edit_styled = urwid.AttrMap(code_edit, 'edit', 'edit_focus') - code_walker = urwid.SimpleFocusListWalker([code_edit_styled]) - code_listbox = urwid.ListBox(code_walker) - code_scrollbar = DOSScrollBar(code_listbox) - code_box = urwid.LineBox(code_scrollbar, title="Code") - - # Layout: Code editor on left, AI assist box on right - main_columns = urwid.Columns([ - ('weight', 1, code_box), - ('weight', 1, ai_assist_box), - ], dividechars=1) - - # Use TabCyclePile so Tab cycles between sections - # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when - # the Pile contains weighted items (urwid 3.x requirement) - body = TabCyclePile([ - ('pack', vars_text), - ('pack', urwid.Divider()), - ('pack', urwid.Columns([ - ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), - ('pack', urwid.Text(" ")), - ('pack', load_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ])), - ('pack', status_text), - ('weight', 1, main_columns), - ('pack', urwid.Divider()), - ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), - ], tab_positions=[2, 4, 6]) - - title = "Edit Code Step" if existing else "Add Code Step" - dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=90, height=30) - - def _on_save_tool(self, _): - """Save the tool.""" - tool = self._current_tool - - # Update from edits - widgets are wrapped in AttrMap, access base_widget - if not self._is_edit: - # Name edit is an AttrMap wrapping an Edit - name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit - if hasattr(name_edit, 'edit_text'): - tool.name = name_edit.edit_text.strip() - tool.description = self._desc_edit.base_widget.edit_text.strip() - tool.output = self._output_edit.base_widget.edit_text.strip() - - if not tool.name: - self.message_box("Error", "Tool name is required.") - return - - # Validate tool name - is_valid, error_msg = validate_tool_name(tool.name) - if not is_valid: - self.message_box("Error", error_msg) - return - - if not self._is_edit and tool_exists(tool.name): - def on_yes(): - save_tool(tool) - self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) - self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", on_yes=on_yes) - else: - save_tool(tool) - self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) - - def _on_cancel_tool(self, _): - """Cancel tool editing.""" - self.show_main_menu() - - def _test_tool(self, tool): - """Test a tool with mock input.""" - def on_input(text): - from .runner import run_tool - output, code = run_tool( - tool=tool, - input_text=text, - custom_args={}, - provider_override="mock", - dry_run=False, - show_prompt=False, - verbose=False - ) - result = f"Exit code: {code}\n\nOutput:\n{output[:300]}" - self.message_box("Test Result", result) - - self.input_dialog("Test Input", "Enter test input", "Hello world", on_input) - - - # ==================== Provider Management ==================== - - def manage_providers(self): - """Manage providers.""" - self._show_providers_menu() - - def _show_providers_menu(self): - """Show providers management menu.""" - providers = load_providers() - self._selected_provider_name = None - self._provider_walker = None - - def on_provider_focus(name): - """Called when a provider is focused.""" - self._selected_provider_name = name - # Update selection state on all items - if self._provider_walker: - for item in self._provider_walker: - if isinstance(item, SelectableToolItem): - item.set_selected(item.name == name) - - def on_provider_activate(name): - """Called when Enter is pressed on a provider.""" - self.close_overlay() - self._edit_provider_menu(name) - - def on_add(_): - self.close_overlay() - self._add_provider_dialog() - - def on_edit(_): - if self._selected_provider_name: - self.close_overlay() - self._edit_provider_menu(self._selected_provider_name) - else: - self.message_box("Edit", "No provider selected.") - - def on_cancel(_): - self.close_overlay() - - # Build provider list - items = [] - for p in providers: - item = SelectableToolItem(f"{p.name}: {p.command}", on_select=on_provider_activate) - item.name = p.name # Store the actual provider name - items.append(item) - - if not items: - items.append(urwid.Text(('label', " (no providers) "))) - - self._provider_walker = urwid.SimpleFocusListWalker(items) - listbox = ToolListBox(self._provider_walker, on_focus_change=on_provider_focus) - listbox_box = urwid.LineBox(listbox, title='Providers') - - # Buttons row - add_btn = ClickableButton("Add", on_add) - edit_btn = ClickableButton("Edit", on_edit) - cancel_btn = ClickableButton("Cancel", on_cancel) - buttons = urwid.Columns([ - ('pack', add_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ('pack', urwid.Text(" ")), - ('pack', cancel_btn), - ]) - buttons_centered = urwid.Padding(buttons, align='center', width='pack') - - # Layout - body = urwid.Pile([ - ('weight', 1, listbox_box), - ('pack', urwid.Divider()), - ('pack', buttons_centered), - ]) - - # Wrap in a frame with title - header = urwid.Text(('header', ' Manage Providers '), align='center') - frame = urwid.Frame(body, header=header) - frame = urwid.LineBox(frame) - frame = urwid.AttrMap(frame, 'dialog') - - height = min(len(providers) + 10, 18) - self.show_overlay(frame, width=55, height=height) - - # Set initial selection - if providers: - self._selected_provider_name = providers[0].name - on_provider_focus(providers[0].name) - - def _add_provider_dialog(self): - """Add a new provider.""" - name_edit = urwid.Edit(('label', "Name: "), "") - cmd_edit = urwid.Edit(('label', "Command: "), "") - desc_edit = urwid.Edit(('label', "Description: "), "") - - def on_ok(_): - name = name_edit.edit_text.strip() - cmd = cmd_edit.edit_text.strip() - desc = desc_edit.edit_text.strip() - - if name and cmd: - add_provider(Provider(name=name, command=cmd, description=desc)) - self.close_overlay() - self.message_box("Success", f"Provider '{name}' added.", self._show_providers_menu) - else: - self.message_box("Error", "Name and command are required.") - - def on_cancel(_): - self.close_overlay() - self._show_providers_menu() - - body = urwid.Pile([ - urwid.AttrMap(name_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Add Provider", body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=55, height=14) - - def _edit_provider_menu(self, name): - """Edit a provider.""" - provider = get_provider(name) - if not provider: - return - - name_edit = urwid.Edit(('label', "Name: "), provider.name) - cmd_edit = urwid.Edit(('label', "Command: "), provider.command) - desc_edit = urwid.Edit(('label', "Description: "), provider.description or "") - - def on_save(_): - new_name = name_edit.edit_text.strip() - cmd = cmd_edit.edit_text.strip() - desc = desc_edit.edit_text.strip() - - if new_name and cmd: - # Delete old provider if name changed - if new_name != name: - delete_provider(name) - # Save with new/same name - add_provider(Provider(name=new_name, command=cmd, description=desc)) - self.close_overlay() - self.message_box("Success", f"Provider '{new_name}' saved.", self._show_providers_menu) - else: - self.message_box("Error", "Name and command are required.") - - def on_delete(_): - self.close_overlay() - def do_delete(): - delete_provider(name) - self.message_box("Deleted", f"Provider '{name}' deleted.", self._show_providers_menu) - self.yes_no("Confirm", f"Delete provider '{name}'?", on_yes=do_delete, on_no=self._show_providers_menu) - - def on_cancel(_): - self.close_overlay() - self._show_providers_menu() - - body = urwid.Pile([ - urwid.AttrMap(name_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Edit Provider", body, [("Save", on_save), ("Delete", on_delete), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=55, height=16) - - -def run_ui(): - """Entry point for the urwid UI.""" - ui = SmartToolsUI() - ui.run() - - if __name__ == "__main__": run_ui() diff --git a/src/smarttools/ui_urwid/__init__.py b/src/smarttools/ui_urwid/__init__.py new file mode 100644 index 0000000..415fa5d --- /dev/null +++ b/src/smarttools/ui_urwid/__init__.py @@ -0,0 +1,1570 @@ +"""BIOS-style TUI for SmartTools using urwid (with mouse support).""" + +import urwid +from typing import Optional, Callable + +from ..tool import ( + Tool, ToolArgument, PromptStep, CodeStep, + list_tools, load_tool, save_tool, delete_tool, tool_exists, validate_tool_name, + get_tools_dir, DEFAULT_CATEGORIES +) +from ..providers import Provider, load_providers, add_provider, delete_provider, get_provider + +from .palette import PALETTE +from .widgets import ( + SelectableText, Button3D, Button3DCompact, ClickableButton, + SelectableToolItem, ToolListBox, TabCyclePile, TabPassEdit, + UndoableEdit, DOSScrollBar, ToolBuilderLayout, Dialog +) + + +class SmartToolsUI: + """Urwid-based UI for SmartTools with mouse support.""" + + def __init__(self): + self.loop = None + self.main_widget = None + self.overlay_stack = [] + + def run(self): + """Run the UI.""" + self.show_main_menu() + self.loop = urwid.MainLoop( + self.main_widget, + palette=PALETTE, + unhandled_input=self.handle_input, + handle_mouse=True # Enable mouse support! + ) + self.loop.run() + + def handle_input(self, key): + """Handle global key input.""" + if key in ('q', 'Q', 'esc'): + if self.overlay_stack: + self.close_overlay() + else: + raise urwid.ExitMainLoop() + + def refresh(self): + """Refresh the display.""" + if self.loop: + self.loop.draw_screen() + + def set_main(self, widget): + """Set the main widget.""" + self.main_widget = urwid.AttrMap(widget, 'body') + if self.loop: + self.loop.widget = self.main_widget + + def show_overlay(self, dialog, width=60, height=20): + """Show a dialog overlay.""" + overlay = urwid.Overlay( + dialog, + self.main_widget, + align='center', width=width, + valign='middle', height=height, + ) + self.overlay_stack.append(self.main_widget) + self.main_widget = overlay + if self.loop: + self.loop.widget = self.main_widget + + def close_overlay(self): + """Close the current overlay.""" + if self.overlay_stack: + self.main_widget = self.overlay_stack.pop() + if self.loop: + self.loop.widget = self.main_widget + + def message_box(self, title: str, message: str, callback=None): + """Show a message box.""" + def on_ok(_): + self.close_overlay() + if callback: + callback() + + body = urwid.Text(message) + dialog = Dialog(title, body, [("OK", on_ok)], width=50) + self.show_overlay(dialog, width=52, height=min(10 + message.count('\n'), 20)) + + def yes_no(self, title: str, message: str, on_yes=None, on_no=None): + """Show a yes/no dialog.""" + def handle_yes(_): + self.close_overlay() + if on_yes: + on_yes() + + def handle_no(_): + self.close_overlay() + if on_no: + on_no() + + body = urwid.Text(message) + dialog = Dialog(title, body, [("Yes", handle_yes), ("No", handle_no)], width=50) + self.show_overlay(dialog, width=52, height=10) + + def input_dialog(self, title: str, prompt: str, initial: str, callback: Callable[[str], None]): + """Show an input dialog.""" + edit = urwid.Edit(('label', f"{prompt}: "), initial) + edit = urwid.AttrMap(edit, 'edit', 'edit_focus') + + def on_ok(_): + value = edit.base_widget.edit_text + self.close_overlay() + callback(value) + + def on_cancel(_): + self.close_overlay() + + body = urwid.Filler(edit, valign='top') + dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)], width=50) + self.show_overlay(dialog, width=52, height=8) + + # ==================== Main Menu ==================== + + def show_main_menu(self): + """Show the main menu with tool list and info panel.""" + self._selected_tool_name = None + self._refresh_main_menu() + + def _refresh_main_menu(self): + """Refresh the main menu display.""" + from collections import defaultdict + + tools = list_tools() + self._tools_list = tools + + # Group tools by category + tools_by_category = defaultdict(list) + for name in tools: + tool = load_tool(name) + category = tool.category if tool else "Other" + tools_by_category[category].append(name) + + # Build tool list with category headers + tool_items = [] + + # Show categories in defined order, then any custom ones + all_categories = list(DEFAULT_CATEGORIES) + for cat in tools_by_category: + if cat not in all_categories: + all_categories.append(cat) + + for category in all_categories: + if category in tools_by_category and tools_by_category[category]: + # Category header (non-selectable) + header = urwid.AttrMap( + urwid.Text(f"─── {category} ───"), + 'label' + ) + tool_items.append(header) + + # Tools in this category + for name in sorted(tools_by_category[category]): + item = SelectableToolItem(name, on_select=self._on_tool_select) + tool_items.append(item) + + if not tools: + tool_items.append(urwid.Text(('label', " (no tools - click Create to add one) "))) + + self._tool_walker = urwid.SimpleFocusListWalker(tool_items) + tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus) + tool_box = urwid.LineBox(tool_listbox, title='Tools') + + # Action buttons - Tab navigates here from tool list (3D style) + create_btn = Button3DCompact("Create", lambda _: self._create_tool_before_selected()) + edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool()) + delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool()) + test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool()) + providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers()) + + buttons_row = urwid.Columns([ + ('pack', create_btn), + ('pack', urwid.Text(" ")), + ('pack', edit_btn), + ('pack', urwid.Text(" ")), + ('pack', delete_btn), + ('pack', urwid.Text(" ")), + ('pack', test_btn), + ('pack', urwid.Text(" ")), + ('pack', providers_btn), + ]) + buttons_padded = urwid.Padding(buttons_row, align='left', left=1) + + # Info panel - shows details of selected tool (not focusable) + self._info_name = urwid.Text("") + self._info_desc = urwid.Text("") + self._info_args = urwid.Text("") + self._info_steps = urwid.Text("") + self._info_output = urwid.Text("") + + info_content = urwid.Pile([ + self._info_name, + self._info_desc, + urwid.Divider(), + self._info_args, + urwid.Divider(), + self._info_steps, + urwid.Divider(), + self._info_output, + ]) + info_filler = urwid.Filler(info_content, valign='top') + info_box = urwid.LineBox(info_filler, title='Tool Info') + + # Exit button at bottom (3D style) + exit_btn = Button3DCompact("EXIT", lambda _: self.exit_app()) + exit_centered = urwid.Padding(exit_btn, align='center', width=12) + + # Use a custom Pile that handles Tab to cycle between tool list and buttons + self._main_pile = TabCyclePile([ + ('weight', 1, tool_box), + ('pack', buttons_padded), + ('pack', urwid.Divider('─')), + ('weight', 2, info_box), + ('pack', urwid.Divider()), + ('pack', exit_centered), + ], tab_positions=[0, 1, 5]) # Tool list, buttons row, exit button + + # Header + header = urwid.Text(('header', ' SmartTools Manager '), align='center') + + # Footer + footer = urwid.Text(('footer', ' Arrow:Navigate list | Tab:Jump to buttons | Enter/Click:Select | Q:Quit '), align='center') + + frame = urwid.Frame(self._main_pile, header=header, footer=footer) + self.set_main(frame) + + # Update info for first tool if any + if tools: + self._on_tool_focus(tools[0]) + + def _create_tool_before_selected(self): + """Create a new tool (will appear in list based on name sorting).""" + self.create_tool() + + def _on_tool_focus(self, name): + """Called when a tool is focused/highlighted.""" + self._selected_tool_name = name + + # Update selection state on all tool items + if hasattr(self, '_tool_walker'): + for item in self._tool_walker: + if isinstance(item, SelectableToolItem): + item.set_selected(item.name == name) + + tool = load_tool(name) + + if tool: + self._info_name.set_text(('label', f"Name: {tool.name}")) + self._info_desc.set_text(f"Description: {tool.description or '(none)'}") + + if tool.arguments: + args_text = "Arguments:\n" + for arg in tool.arguments: + default = f" = {arg.default}" if arg.default else "" + args_text += f" {arg.flag} -> {{{arg.variable}}}{default}\n" + else: + args_text = "Arguments: (none)" + self._info_args.set_text(args_text.rstrip()) + + if tool.steps: + steps_text = "Execution Steps:\n" + for i, step in enumerate(tool.steps): + if isinstance(step, PromptStep): + steps_text += f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}\n" + else: + steps_text += f" {i+1}. CODE -> {{{step.output_var}}}\n" + else: + steps_text = "Execution Steps: (none)" + self._info_steps.set_text(steps_text.rstrip()) + + self._info_output.set_text(f"Output: {tool.output}") + else: + self._info_name.set_text("") + self._info_desc.set_text("") + self._info_args.set_text("") + self._info_steps.set_text("") + self._info_output.set_text("") + + def _on_tool_select(self, name): + """Called when a tool is selected (Enter/double-click).""" + # Edit the tool on select + tool = load_tool(name) + if tool: + self.tool_builder(tool) + + def _edit_selected_tool(self): + """Edit the currently selected tool.""" + if self._selected_tool_name: + tool = load_tool(self._selected_tool_name) + if tool: + self.tool_builder(tool) + else: + self.message_box("Edit", "No tool selected.") + + def _delete_selected_tool(self): + """Delete the currently selected tool.""" + if self._selected_tool_name: + name = self._selected_tool_name + def do_delete(): + delete_tool(name) + self._selected_tool_name = None + self.message_box("Deleted", f"Tool '{name}' deleted.", self._refresh_main_menu) + self.yes_no("Confirm", f"Delete tool '{name}'?", on_yes=do_delete) + else: + self.message_box("Delete", "No tool selected.") + + def _test_selected_tool(self): + """Test the currently selected tool.""" + if self._selected_tool_name: + tool = load_tool(self._selected_tool_name) + if tool: + self._test_tool(tool) + else: + self.message_box("Test", "No tool selected.") + + def exit_app(self): + """Exit the application.""" + raise urwid.ExitMainLoop() + + + # ==================== Tool Builder ==================== + + def create_tool(self): + """Create a new tool.""" + self.tool_builder(None) + + def tool_builder(self, existing: Optional[Tool]): + """Main tool builder interface.""" + is_edit = existing is not None + + # Initialize tool + if existing: + tool = Tool( + name=existing.name, + description=existing.description, + arguments=list(existing.arguments), + steps=list(existing.steps), + output=existing.output + ) + else: + tool = Tool(name="", description="", arguments=[], steps=[], output="{input}") + + # Store references for callbacks + self._current_tool = tool + self._is_edit = is_edit + self._selected_arg_idx = None + self._selected_step_idx = None + + self._show_tool_builder() + + def _save_tool_fields(self): + """Save current edit field values to the tool object.""" + if not hasattr(self, '_name_edit') or not hasattr(self, '_current_tool'): + return + + tool = self._current_tool + + # Save name (only if it's an edit widget, not a text label) + if not self._is_edit and hasattr(self._name_edit, 'base_widget'): + name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit + if hasattr(name_edit, 'edit_text'): + tool.name = name_edit.edit_text.strip() + + # Save description + if hasattr(self, '_desc_edit') and hasattr(self._desc_edit, 'base_widget'): + tool.description = self._desc_edit.base_widget.edit_text.strip() + + # Save output + if hasattr(self, '_output_edit') and hasattr(self._output_edit, 'base_widget'): + tool.output = self._output_edit.base_widget.edit_text.strip() + + def _show_tool_builder(self): + """Render the tool builder screen.""" + tool = self._current_tool + + # Create edit widgets + if self._is_edit: + name_widget = urwid.Text(('label', f"Name: {tool.name}")) + else: + name_widget = urwid.AttrMap(urwid.Edit(('label', "Name: "), tool.name), 'edit', 'edit_focus') + self._name_edit = name_widget + + self._desc_edit = urwid.AttrMap(urwid.Edit(('label', "Desc: "), tool.description), 'edit', 'edit_focus') + self._output_edit = urwid.AttrMap(urwid.Edit(('label', "Output: "), tool.output), 'edit', 'edit_focus') + + # Category selector + self._selected_category = [tool.category or "Other"] + category_btn_text = urwid.Text(self._selected_category[0]) + category_btn = urwid.AttrMap( + urwid.Padding(category_btn_text, left=1, right=1), + 'edit', 'edit_focus' + ) + + def show_category_dropdown(_): + """Show category selection popup.""" + def select_category(cat): + def callback(_): + self._selected_category[0] = cat + category_btn_text.set_text(cat) + tool.category = cat + self.close_overlay() + return callback + + items = [] + for cat in DEFAULT_CATEGORIES: + btn = urwid.Button(cat, on_press=select_category(cat)) + items.append(urwid.AttrMap(btn, 'button', 'button_focus')) + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + popup = Dialog("Select Category", listbox, []) + self.show_overlay(popup, width=30, height=len(DEFAULT_CATEGORIES) + 4) + + category_select_btn = Button3DCompact("▼", on_press=show_category_dropdown) + + category_row = urwid.Columns([ + ('pack', urwid.Text(('label', "Category: "))), + ('weight', 1, category_btn), + ('pack', urwid.Text(" ")), + ('pack', category_select_btn), + ]) + + # Left column - fields + left_pile = urwid.Pile([ + ('pack', name_widget), + ('pack', urwid.Divider()), + ('pack', self._desc_edit), + ('pack', urwid.Divider()), + ('pack', category_row), + ('pack', urwid.Divider()), + ('pack', self._output_edit), + ]) + left_box = urwid.LineBox(urwid.Filler(left_pile, valign='top'), title='Tool Info') + + # Arguments list + arg_items = [] + for i, arg in enumerate(tool.arguments): + text = f"{arg.flag} -> {{{arg.variable}}}" + item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_arg_activate(idx)) + item.name = i # Store index + arg_items.append(item) + if not arg_items: + arg_items.append(urwid.Text(('label', " (none) "))) + + self._arg_walker = urwid.SimpleFocusListWalker(arg_items) + args_listbox = ToolListBox(self._arg_walker, on_focus_change=self._on_arg_focus) + args_box = urwid.LineBox(args_listbox, title='Arguments') + + # Argument buttons + arg_add_btn = ClickableButton("Add", lambda _: self._add_argument_dialog()) + arg_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_arg()) + arg_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_arg()) + arg_buttons = urwid.Columns([ + ('pack', arg_add_btn), + ('pack', urwid.Text(" ")), + ('pack', arg_edit_btn), + ('pack', urwid.Text(" ")), + ('pack', arg_del_btn), + ]) + arg_buttons_padded = urwid.Padding(arg_buttons, align='left', left=1) + + # Args section (list + buttons) + args_section = urwid.Pile([ + ('weight', 1, args_box), + ('pack', arg_buttons_padded), + ]) + + # Steps list + step_items = [] + for i, step in enumerate(tool.steps): + if isinstance(step, PromptStep): + text = f"P:{step.provider} -> {{{step.output_var}}}" + else: + text = f"C: -> {{{step.output_var}}}" + item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_step_activate(idx)) + item.name = i # Store index + step_items.append(item) + if not step_items: + step_items.append(urwid.Text(('label', " (none) "))) + + self._step_walker = urwid.SimpleFocusListWalker(step_items) + steps_listbox = ToolListBox(self._step_walker, on_focus_change=self._on_step_focus) + steps_box = urwid.LineBox(steps_listbox, title='Execution Steps') + + # Step buttons + step_add_btn = ClickableButton("Add", lambda _: self._add_step_choice()) + step_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_step()) + step_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_step()) + step_buttons = urwid.Columns([ + ('pack', step_add_btn), + ('pack', urwid.Text(" ")), + ('pack', step_edit_btn), + ('pack', urwid.Text(" ")), + ('pack', step_del_btn), + ]) + step_buttons_padded = urwid.Padding(step_buttons, align='left', left=1) + + # Steps section (list + buttons) + steps_section = urwid.Pile([ + ('weight', 1, steps_box), + ('pack', step_buttons_padded), + ]) + + # Save/Cancel buttons + save_btn = ClickableButton("Save", self._on_save_tool) + cancel_btn = ClickableButton("Cancel", self._on_cancel_tool) + bottom_buttons = urwid.Columns([ + ('pack', save_btn), + ('pack', urwid.Text(" ")), + ('pack', cancel_btn), + ], dividechars=1) + bottom_buttons_centered = urwid.Padding(bottom_buttons, align='center', width='pack') + + # Use ToolBuilderLayout for proper Tab cycling + # Pass LineBoxes for title highlighting and on_cancel for Escape key + body = ToolBuilderLayout( + left_box, args_box, steps_box, + args_section, steps_section, bottom_buttons_centered, + on_cancel=self._on_cancel_tool + ) + + # Frame + title = f"Edit Tool: {tool.name}" if self._is_edit and tool.name else "New Tool" + header = urwid.Text(('header', f' {title} '), align='center') + footer = urwid.Text(('footer', ' Arrow:Navigate | Tab:Next section | Enter/Click:Select | Esc:Cancel '), align='center') + + frame = urwid.Frame(body, header=header, footer=footer) + self.set_main(frame) + + # Set initial selection + if tool.arguments: + self._selected_arg_idx = 0 + self._on_arg_focus(0) + if tool.steps: + self._selected_step_idx = 0 + self._on_step_focus(0) + + def _on_arg_focus(self, idx): + """Called when an argument is focused.""" + if isinstance(idx, int): + self._selected_arg_idx = idx + # Update selection display + if hasattr(self, '_arg_walker'): + for i, item in enumerate(self._arg_walker): + if isinstance(item, SelectableToolItem): + item.set_selected(i == idx) + + def _on_arg_activate(self, idx): + """Called when an argument is activated (Enter/click).""" + self._selected_arg_idx = idx + self._edit_argument_at(idx) + + def _on_step_focus(self, idx): + """Called when a step is focused.""" + if isinstance(idx, int): + self._selected_step_idx = idx + # Update selection display + if hasattr(self, '_step_walker'): + for i, item in enumerate(self._step_walker): + if isinstance(item, SelectableToolItem): + item.set_selected(i == idx) + + def _on_step_activate(self, idx): + """Called when a step is activated (Enter/click).""" + self._selected_step_idx = idx + self._edit_step_at(idx) + + def _edit_selected_arg(self): + """Edit the currently selected argument.""" + if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): + self._edit_argument_at(self._selected_arg_idx) + else: + self.message_box("Edit", "No argument selected.") + + def _delete_selected_arg(self): + """Delete the currently selected argument.""" + if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): + idx = self._selected_arg_idx + arg = self._current_tool.arguments[idx] + def do_delete(): + self._save_tool_fields() + self._current_tool.arguments.pop(idx) + self._selected_arg_idx = None + self._show_tool_builder() + self.yes_no("Delete", f"Delete argument {arg.flag}?", on_yes=do_delete) + else: + self.message_box("Delete", "No argument selected.") + + def _edit_selected_step(self): + """Edit the currently selected step.""" + if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): + self._edit_step_at(self._selected_step_idx) + else: + self.message_box("Edit", "No step selected.") + + def _delete_selected_step(self): + """Delete the currently selected step.""" + if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): + idx = self._selected_step_idx + def do_delete(): + self._save_tool_fields() + self._current_tool.steps.pop(idx) + self._selected_step_idx = None + self._show_tool_builder() + self.yes_no("Delete", f"Delete step {idx + 1}?", on_yes=do_delete) + else: + self.message_box("Delete", "No step selected.") + + def _edit_argument_at(self, idx): + """Edit argument at index.""" + self._do_edit_argument(idx) + + def _edit_step_at(self, idx): + """Edit step at index - opens the appropriate dialog based on step type.""" + # Save current field values before showing dialog + self._save_tool_fields() + + step = self._current_tool.steps[idx] + if isinstance(step, PromptStep): + self._add_prompt_dialog(step, idx) + else: + self._add_code_dialog(step, idx) + + def _add_argument_dialog(self): + """Show add argument dialog.""" + # Save current field values before showing dialog + self._save_tool_fields() + + flag_edit = urwid.Edit(('label', "Flag: "), "--") + var_edit = urwid.Edit(('label', "Variable: "), "") + default_edit = urwid.Edit(('label', "Default: "), "") + + def on_ok(_): + flag = flag_edit.edit_text.strip() + var = var_edit.edit_text.strip() + default = default_edit.edit_text.strip() or None + + if not flag: + return + if not var: + var = flag.lstrip("-").replace("-", "_") + + self._current_tool.arguments.append(ToolArgument( + flag=flag, variable=var, default=default, description="" + )) + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Pile([ + urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(var_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(default_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Add Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=14) + + def _do_edit_argument(self, idx): + """Edit an existing argument.""" + # Save current field values before showing dialog + self._save_tool_fields() + + arg = self._current_tool.arguments[idx] + + flag_edit = urwid.Edit(('label', "Flag: "), arg.flag) + var_edit = urwid.Edit(('label', "Variable: "), arg.variable) + default_edit = urwid.Edit(('label', "Default: "), arg.default or "") + + def on_ok(_): + arg.flag = flag_edit.edit_text.strip() + arg.variable = var_edit.edit_text.strip() + arg.default = default_edit.edit_text.strip() or None + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Pile([ + urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(var_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(default_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Edit Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=50, height=14) + + def _add_step_choice(self): + """Choose step type to add.""" + # Save current field values before showing dialog + self._save_tool_fields() + + def on_prompt(_): + self.close_overlay() + # Defer dialog opening to avoid overlay rendering issues + if self.loop: + self.loop.set_alarm_in(0, lambda loop, data: self._add_prompt_dialog()) + else: + self._add_prompt_dialog() + + def on_code(_): + self.close_overlay() + # Defer dialog opening to avoid overlay rendering issues + if self.loop: + self.loop.set_alarm_in(0, lambda loop, data: self._add_code_dialog()) + else: + self._add_code_dialog() + + def on_cancel(_): + self.close_overlay() + + body = urwid.Text("Choose step type:") + dialog = Dialog("Add Step", body, [("Prompt", on_prompt), ("Code", on_code), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=45, height=9) + + def _get_available_vars(self, up_to=-1): + """Get available variables.""" + tool = self._current_tool + variables = ["input"] + for arg in tool.arguments: + variables.append(arg.variable) + if up_to == -1: + up_to = len(tool.steps) + for i, step in enumerate(tool.steps): + if i >= up_to: + break + variables.append(step.output_var) + return variables + + def _add_prompt_dialog(self, existing=None, idx=-1): + """Add/edit prompt step with provider dropdown and multiline prompt.""" + providers = load_providers() + provider_names = [p.name for p in providers] + if not provider_names: + provider_names = ["mock"] + current_provider = existing.provider if existing else provider_names[0] + + # Provider selector state + selected_provider = [current_provider] # Use list to allow mutation in closures + + # Provider dropdown button + provider_btn_text = urwid.Text(current_provider) + provider_btn = urwid.AttrMap( + urwid.Padding(provider_btn_text, left=1, right=1), + 'edit', 'edit_focus' + ) + + def show_provider_dropdown(_): + """Show provider selection popup with descriptions.""" + # Build provider lookup for descriptions + provider_lookup = {p.name: p.description for p in providers} + + # Description display (updates on focus change) + desc_text = urwid.Text("") + desc_box = urwid.AttrMap( + urwid.Padding(desc_text, left=1, right=1), + 'label' + ) + + def update_description(name): + """Update the description text for the focused provider.""" + desc = provider_lookup.get(name, "") + desc_text.set_text(('label', desc if desc else "No description")) + + def select_provider(name): + def callback(_): + selected_provider[0] = name + provider_btn_text.set_text(name) + self.close_overlay() + return callback + + # Create focusable buttons that update description on focus + class DescriptiveButton(urwid.Button): + def __init__(self, name, desc_callback): + super().__init__(name, on_press=select_provider(name)) + self._name = name + self._desc_callback = desc_callback + + def render(self, size, focus=False): + if focus: + self._desc_callback(self._name) + return super().render(size, focus) + + items = [] + for name in provider_names: + # Show short hint inline: "name | short_desc" + short_desc = provider_lookup.get(name, "") + # Extract just the key info (after the timing) + if "|" in short_desc: + short_desc = short_desc.split("|", 1)[1].strip()[:20] + else: + short_desc = short_desc[:20] + + label = f"{name:<18} {short_desc}" + btn = DescriptiveButton(name, update_description) + btn.set_label(label) + items.append(urwid.AttrMap(btn, 'button', 'button_focus')) + + # Set initial description + update_description(provider_names[0]) + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + + # Combine listbox with description footer + body = urwid.Pile([ + ('weight', 1, listbox), + ('pack', urwid.Divider('─')), + ('pack', desc_box), + ]) + + popup = Dialog("Select Provider", body, []) + self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) + + provider_select_btn = Button3DCompact("▼", on_press=show_provider_dropdown) + + # File input for external prompt + default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt" + file_edit = urwid.Edit(('label', "File: "), default_file) + + # Multiline prompt editor - use TabPassEdit so Tab passes through for navigation + prompt_edit = TabPassEdit( + edit_text=existing.prompt if existing else "{input}", + multiline=True + ) + + output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "response") + + vars_available = self._get_available_vars(idx) + vars_text = urwid.Text(('label', f"Variables: {', '.join('{'+v+'}' for v in vars_available)}")) + + status_text = urwid.Text("") + + def do_load(): + """Actually load prompt from file.""" + filename = file_edit.edit_text.strip() + tool_dir = get_tools_dir() / self._current_tool.name + prompt_path = tool_dir / filename + + try: + prompt_edit.set_edit_text(prompt_path.read_text()) + status_text.set_text(('success', f"Loaded from {filename}")) + except Exception as e: + status_text.set_text(('error', f"Load error: {e}")) + + def on_load(_): + """Load prompt from file with confirmation.""" + filename = file_edit.edit_text.strip() + if not filename: + status_text.set_text(('error', "Enter a filename first")) + return + + tool_dir = get_tools_dir() / self._current_tool.name + prompt_path = tool_dir / filename + + if not prompt_path.exists(): + status_text.set_text(('error', f"File not found: {filename}")) + return + + # Show confirmation dialog + def on_yes(_): + self.close_overlay() + do_load() + + def on_no(_): + self.close_overlay() + + confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current prompt.") + confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) + self.show_overlay(confirm_dialog, width=50, height=8) + + def on_ok(_): + provider = selected_provider[0] + prompt = prompt_edit.edit_text.strip() + output_var = output_edit.edit_text.strip() or "response" + prompt_file = file_edit.edit_text.strip() or None + + # Auto-save to file if filename is set + if prompt_file: + tool_dir = get_tools_dir() / self._current_tool.name + tool_dir.mkdir(parents=True, exist_ok=True) + prompt_path = tool_dir / prompt_file + try: + prompt_path.write_text(prompt) + except Exception as e: + status_text.set_text(('error', f"Save error: {e}")) + return + + step = PromptStep(prompt=prompt, provider=provider, output_var=output_var, prompt_file=prompt_file) + + if existing and idx >= 0: + self._current_tool.steps[idx] = step + else: + self._current_tool.steps.append(step) + + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + def on_external_edit(_): + """Open prompt in external editor ($EDITOR).""" + import os + import subprocess + import tempfile + + current_prompt = prompt_edit.edit_text + + # Stop the urwid loop temporarily + if self.loop: + self.loop.stop() + + try: + # Create temp file with current prompt + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write(current_prompt) + temp_path = f.name + + # Get editor from environment + editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) + + # Run editor + subprocess.run([editor, temp_path], check=True) + + # Read back the edited prompt + with open(temp_path, 'r') as f: + new_prompt = f.read() + + # Update the prompt editor + prompt_edit.set_edit_text(new_prompt) + status_text.set_text(('success', f"Prompt updated from {editor}")) + + # Clean up temp file + os.unlink(temp_path) + + except subprocess.CalledProcessError: + status_text.set_text(('error', "Editor exited with error")) + except FileNotFoundError: + status_text.set_text(('error', f"Editor '{editor}' not found")) + except Exception as e: + status_text.set_text(('error', f"Edit error: {e}")) + finally: + # Restart the urwid loop + if self.loop: + self.loop.start() + + load_btn = Button3DCompact("Load", on_load) + edit_btn = Button3DCompact("$EDITOR", on_external_edit) + + # Prompt editor in a box - use ListBox for proper focus handling and scrolling + # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons + prompt_edit_styled = urwid.AttrMap(prompt_edit, 'edit', 'edit_focus') + prompt_walker = urwid.SimpleFocusListWalker([prompt_edit_styled]) + prompt_listbox = urwid.ListBox(prompt_walker) + prompt_scrollbar = DOSScrollBar(prompt_listbox) + prompt_box = urwid.LineBox(prompt_scrollbar, title="Prompt") + + # Use TabCyclePile so Tab cycles between sections + # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when + # the Pile contains weighted items (urwid 3.x requirement) + body = TabCyclePile([ + ('pack', vars_text), + ('pack', urwid.Divider()), + ('pack', urwid.Columns([ + ('pack', urwid.Text(('label', "Provider: "))), + ('weight', 1, provider_btn), + ('pack', urwid.Text(" ")), + ('pack', provider_select_btn), + ])), + ('pack', urwid.Divider()), + ('pack', urwid.Columns([ + ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), + ('pack', urwid.Text(" ")), + ('pack', load_btn), + ('pack', urwid.Text(" ")), + ('pack', edit_btn), + ])), + ('pack', status_text), + ('weight', 1, prompt_box), + ('pack', urwid.Divider()), + ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), + ], tab_positions=[2, 4, 6, 8]) + + title = "Edit Prompt Step" if existing else "Add Prompt Step" + dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=75, height=22) + + def _add_code_dialog(self, existing=None, idx=-1): + """Add/edit code step with multiline editor, file support, and AI auto-adjust.""" + from ..providers import call_provider + + # File name input (default based on output_var) + default_output_var = existing.output_var if existing else "processed" + default_file = existing.code_file if existing and existing.code_file else f"{default_output_var}.py" + file_edit = urwid.Edit(('label', "File: "), default_file) + + # Multiline code editor with undo/redo (Alt+U / Alt+R) + default_code = existing.code if existing else f"{default_output_var} = input.upper()" + code_edit = UndoableEdit( + edit_text=default_code, + multiline=True + ) + + output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "processed") + + vars_available = self._get_available_vars(idx) + vars_text = urwid.Text(('label', f"Variables: {', '.join(vars_available)}")) + + status_text = urwid.Text("") + + # --- Auto-adjust AI feature --- + providers = load_providers() + provider_names = [p.name for p in providers] + if not provider_names: + provider_names = ["mock"] + selected_ai_provider = [provider_names[0]] + + ai_provider_btn_text = urwid.Text(provider_names[0]) + ai_provider_btn = urwid.AttrMap( + urwid.Padding(ai_provider_btn_text, left=1, right=1), + 'edit', 'edit_focus' + ) + + def show_ai_provider_dropdown(_): + provider_lookup = {p.name: p.description for p in providers} + desc_text = urwid.Text("") + desc_box = urwid.AttrMap(urwid.Padding(desc_text, left=1, right=1), 'label') + + def update_description(name): + desc = provider_lookup.get(name, "") + desc_text.set_text(('label', desc if desc else "No description")) + + def select_provider(name): + def callback(_): + selected_ai_provider[0] = name + ai_provider_btn_text.set_text(name) + self.close_overlay() + return callback + + class DescriptiveButton(urwid.Button): + def __init__(btn_self, name, desc_callback): + super().__init__(name, on_press=select_provider(name)) + btn_self._name = name + btn_self._desc_callback = desc_callback + + def render(btn_self, size, focus=False): + if focus: + btn_self._desc_callback(btn_self._name) + return super().render(size, focus) + + items = [] + for name in provider_names: + short_desc = provider_lookup.get(name, "") + if "|" in short_desc: + short_desc = short_desc.split("|", 1)[1].strip()[:20] + else: + short_desc = short_desc[:20] + label = f"{name:<18} {short_desc}" + btn = DescriptiveButton(name, update_description) + btn.set_label(label) + items.append(urwid.AttrMap(btn, 'button', 'button_focus')) + + update_description(provider_names[0]) + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + popup_body = urwid.Pile([ + ('weight', 1, listbox), + ('pack', urwid.Divider('─')), + ('pack', desc_box), + ]) + popup = Dialog("Select Provider", popup_body, []) + self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) + + ai_provider_select_btn = Button3DCompact("▼", on_press=show_ai_provider_dropdown) + + # Default prompt template for AI code generation/adjustment + # Show variables in triple-quote format so the AI follows the pattern + vars_formatted = ', '.join(f'\"\"\"{{{v}}}\"\"\"' for v in vars_available) + default_ai_prompt = f"""Write inline Python code (NOT a function definition) according to my instruction. + +The code runs directly with variable substitution. Assign any "Available Variables" used to a new standard variable first, then use that variable in the code. Use triple quotes and curly braces since the substituted content may contain quotes/newlines. + +Example: +my_var = \"\"\"{{variable}}\"\"\" + +INSTRUCTION: [Describe what you want] + +CURRENT CODE: +```python +{{code}} +``` + +AVAILABLE VARIABLES: {vars_formatted} + +IMPORTANT: Return ONLY executable inline code. Do NOT wrap in a function. +No explanations, no markdown fencing, just the code.""" + + # Multiline editable prompt for AI with DOS-style scrollbar + ai_prompt_edit = TabPassEdit(edit_text=default_ai_prompt, multiline=True) + ai_prompt_styled = urwid.AttrMap(ai_prompt_edit, 'edit', 'edit_focus') + ai_prompt_walker = urwid.SimpleFocusListWalker([ai_prompt_styled]) + ai_prompt_listbox = urwid.ListBox(ai_prompt_walker) + ai_prompt_scrollbar = DOSScrollBar(ai_prompt_listbox) + ai_prompt_box = urwid.LineBox(ai_prompt_scrollbar, title="Prompt") + + # Output/feedback area for AI responses + ai_output_text = urwid.Text("") + ai_output_walker = urwid.SimpleFocusListWalker([ai_output_text]) + ai_output_listbox = urwid.ListBox(ai_output_walker) + ai_output_box = urwid.LineBox(ai_output_listbox, title="Output & Feedback") + + def on_auto_adjust(_): + prompt_template = ai_prompt_edit.edit_text.strip() + if not prompt_template: + ai_output_text.set_text(('error', "Enter a prompt for the AI")) + return + + current_code = code_edit.edit_text.strip() + + # Replace {code} placeholder with actual code + prompt = prompt_template.replace("{code}", current_code) + + provider_name = selected_ai_provider[0] + ai_output_text.set_text(('label', f"Calling {provider_name}...\nPlease wait...")) + self.refresh() + + result = call_provider(provider_name, prompt) + + if result.success: + new_code = result.text.strip() + # Strip markdown code fences if present + if new_code.startswith("```python"): + new_code = new_code[9:] + if new_code.startswith("```"): + new_code = new_code[3:] + if new_code.endswith("```"): + new_code = new_code[:-3] + new_code = new_code.strip() + + code_edit.set_edit_text(new_code) + ai_output_text.set_text(('success', f"✓ Code updated successfully!\n\nProvider: {provider_name}\nResponse length: {len(result.text)} chars")) + else: + error_msg = result.error or "Unknown error" + ai_output_text.set_text(('error', f"✗ Error from {provider_name}:\n\n{error_msg}")) + + auto_adjust_btn = Button3DCompact("Auto-adjust", on_auto_adjust) + + # Build the AI assist box with provider selector, prompt editor, output area, and button + ai_provider_row = urwid.Columns([ + ('pack', urwid.Text(('label', "Provider: "))), + ('pack', ai_provider_btn), + ('pack', ai_provider_select_btn), + ]) + + ai_assist_content = urwid.Pile([ + ('pack', ai_provider_row), + ('pack', urwid.Divider()), + ('weight', 2, ai_prompt_box), + ('weight', 1, ai_output_box), + ('pack', urwid.Padding(auto_adjust_btn, align='center', width=16)), + ]) + ai_assist_box = urwid.LineBox(ai_assist_content, title="AI Assisted Auto-adjust") + # --- End Auto-adjust feature --- + + def do_load(): + """Actually load code from file.""" + filename = file_edit.edit_text.strip() + tool_dir = get_tools_dir() / self._current_tool.name + code_path = tool_dir / filename + + try: + code_edit.set_edit_text(code_path.read_text()) + status_text.set_text(('success', f"Loaded from {filename}")) + except Exception as e: + status_text.set_text(('error', f"Load error: {e}")) + + def on_load(_): + """Load code from file with confirmation.""" + filename = file_edit.edit_text.strip() + if not filename: + status_text.set_text(('error', "Enter a filename first")) + return + + tool_dir = get_tools_dir() / self._current_tool.name + code_path = tool_dir / filename + + if not code_path.exists(): + status_text.set_text(('error', f"File not found: {filename}")) + return + + def on_yes(_): + self.close_overlay() + do_load() + + def on_no(_): + self.close_overlay() + + confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current code.") + confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) + self.show_overlay(confirm_dialog, width=50, height=8) + + def on_ok(_): + import ast + + code = code_edit.edit_text.strip() + output_var = output_edit.edit_text.strip() or "processed" + code_file = file_edit.edit_text.strip() or None + + if code: + try: + ast.parse(code) + except SyntaxError as e: + line_info = f" (line {e.lineno})" if e.lineno else "" + status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}")) + return + + if code_file: + tool_dir = get_tools_dir() / self._current_tool.name + tool_dir.mkdir(parents=True, exist_ok=True) + code_path = tool_dir / code_file + try: + code_path.write_text(code) + except Exception as e: + status_text.set_text(('error', f"Save error: {e}")) + return + + from ..tool import CodeStep + step = CodeStep(code=code, output_var=output_var, code_file=code_file) + + if existing and idx >= 0: + self._current_tool.steps[idx] = step + else: + self._current_tool.steps.append(step) + + self.close_overlay() + self._show_tool_builder() + + def on_cancel(_): + self.close_overlay() + + def on_external_edit(_): + """Open code in external editor ($EDITOR).""" + import os + import subprocess + import tempfile + + current_code = code_edit.edit_text + + # Stop the urwid loop temporarily + if self.loop: + self.loop.stop() + + try: + # Create temp file with current code + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + f.write(current_code) + temp_path = f.name + + # Get editor from environment + editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) + + # Run editor + subprocess.run([editor, temp_path], check=True) + + # Read back the edited code + with open(temp_path, 'r') as f: + new_code = f.read() + + # Update the code editor + code_edit.set_edit_text(new_code) + status_text.set_text(('success', f"Code updated from {editor}")) + + # Clean up temp file + os.unlink(temp_path) + + except subprocess.CalledProcessError: + status_text.set_text(('error', "Editor exited with error")) + except FileNotFoundError: + status_text.set_text(('error', f"Editor '{editor}' not found")) + except Exception as e: + status_text.set_text(('error', f"Edit error: {e}")) + finally: + # Restart the urwid loop + if self.loop: + self.loop.start() + + load_btn = Button3DCompact("Load", on_load) + edit_btn = Button3DCompact("$EDITOR", on_external_edit) + + # Code editor in a box - use ListBox for proper focus handling and scrolling + # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons + code_edit_styled = urwid.AttrMap(code_edit, 'edit', 'edit_focus') + code_walker = urwid.SimpleFocusListWalker([code_edit_styled]) + code_listbox = urwid.ListBox(code_walker) + code_scrollbar = DOSScrollBar(code_listbox) + code_box = urwid.LineBox(code_scrollbar, title="Code") + + # Layout: Code editor on left, AI assist box on right + main_columns = urwid.Columns([ + ('weight', 1, code_box), + ('weight', 1, ai_assist_box), + ], dividechars=1) + + # Use TabCyclePile so Tab cycles between sections + # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when + # the Pile contains weighted items (urwid 3.x requirement) + body = TabCyclePile([ + ('pack', vars_text), + ('pack', urwid.Divider()), + ('pack', urwid.Columns([ + ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), + ('pack', urwid.Text(" ")), + ('pack', load_btn), + ('pack', urwid.Text(" ")), + ('pack', edit_btn), + ])), + ('pack', status_text), + ('weight', 1, main_columns), + ('pack', urwid.Divider()), + ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), + ], tab_positions=[2, 4, 6]) + + title = "Edit Code Step" if existing else "Add Code Step" + dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=90, height=30) + + def _on_save_tool(self, _): + """Save the tool.""" + tool = self._current_tool + + # Update from edits - widgets are wrapped in AttrMap, access base_widget + if not self._is_edit: + # Name edit is an AttrMap wrapping an Edit + name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit + if hasattr(name_edit, 'edit_text'): + tool.name = name_edit.edit_text.strip() + tool.description = self._desc_edit.base_widget.edit_text.strip() + tool.output = self._output_edit.base_widget.edit_text.strip() + + if not tool.name: + self.message_box("Error", "Tool name is required.") + return + + # Validate tool name + is_valid, error_msg = validate_tool_name(tool.name) + if not is_valid: + self.message_box("Error", error_msg) + return + + if not self._is_edit and tool_exists(tool.name): + def on_yes(): + save_tool(tool) + self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) + self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", on_yes=on_yes) + else: + save_tool(tool) + self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) + + def _on_cancel_tool(self, _): + """Cancel tool editing.""" + self.show_main_menu() + + def _test_tool(self, tool): + """Test a tool with mock input.""" + def on_input(text): + from ..runner import run_tool + output, code = run_tool( + tool=tool, + input_text=text, + custom_args={}, + provider_override="mock", + dry_run=False, + show_prompt=False, + verbose=False + ) + result = f"Exit code: {code}\n\nOutput:\n{output[:300]}" + self.message_box("Test Result", result) + + self.input_dialog("Test Input", "Enter test input", "Hello world", on_input) + + + # ==================== Provider Management ==================== + + def manage_providers(self): + """Manage providers.""" + self._show_providers_menu() + + def _show_providers_menu(self): + """Show providers management menu.""" + providers = load_providers() + self._selected_provider_name = None + self._provider_walker = None + + def on_provider_focus(name): + """Called when a provider is focused.""" + self._selected_provider_name = name + # Update selection state on all items + if self._provider_walker: + for item in self._provider_walker: + if isinstance(item, SelectableToolItem): + item.set_selected(item.name == name) + + def on_provider_activate(name): + """Called when Enter is pressed on a provider.""" + self.close_overlay() + self._edit_provider_menu(name) + + def on_add(_): + self.close_overlay() + self._add_provider_dialog() + + def on_edit(_): + if self._selected_provider_name: + self.close_overlay() + self._edit_provider_menu(self._selected_provider_name) + else: + self.message_box("Edit", "No provider selected.") + + def on_cancel(_): + self.close_overlay() + + # Build provider list + items = [] + for p in providers: + item = SelectableToolItem(f"{p.name}: {p.command}", on_select=on_provider_activate) + item.name = p.name # Store the actual provider name + items.append(item) + + if not items: + items.append(urwid.Text(('label', " (no providers) "))) + + self._provider_walker = urwid.SimpleFocusListWalker(items) + listbox = ToolListBox(self._provider_walker, on_focus_change=on_provider_focus) + listbox_box = urwid.LineBox(listbox, title='Providers') + + # Buttons row + add_btn = ClickableButton("Add", on_add) + edit_btn = ClickableButton("Edit", on_edit) + cancel_btn = ClickableButton("Cancel", on_cancel) + buttons = urwid.Columns([ + ('pack', add_btn), + ('pack', urwid.Text(" ")), + ('pack', edit_btn), + ('pack', urwid.Text(" ")), + ('pack', cancel_btn), + ]) + buttons_centered = urwid.Padding(buttons, align='center', width='pack') + + # Layout + body = urwid.Pile([ + ('weight', 1, listbox_box), + ('pack', urwid.Divider()), + ('pack', buttons_centered), + ]) + + # Wrap in a frame with title + header = urwid.Text(('header', ' Manage Providers '), align='center') + frame = urwid.Frame(body, header=header) + frame = urwid.LineBox(frame) + frame = urwid.AttrMap(frame, 'dialog') + + height = min(len(providers) + 10, 18) + self.show_overlay(frame, width=55, height=height) + + # Set initial selection + if providers: + self._selected_provider_name = providers[0].name + on_provider_focus(providers[0].name) + + def _add_provider_dialog(self): + """Add a new provider.""" + name_edit = urwid.Edit(('label', "Name: "), "") + cmd_edit = urwid.Edit(('label', "Command: "), "") + desc_edit = urwid.Edit(('label', "Description: "), "") + + def on_ok(_): + name = name_edit.edit_text.strip() + cmd = cmd_edit.edit_text.strip() + desc = desc_edit.edit_text.strip() + + if name and cmd: + add_provider(Provider(name=name, command=cmd, description=desc)) + self.close_overlay() + self.message_box("Success", f"Provider '{name}' added.", self._show_providers_menu) + else: + self.message_box("Error", "Name and command are required.") + + def on_cancel(_): + self.close_overlay() + self._show_providers_menu() + + body = urwid.Pile([ + urwid.AttrMap(name_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Add Provider", body, [("OK", on_ok), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=55, height=14) + + def _edit_provider_menu(self, name): + """Edit a provider.""" + provider = get_provider(name) + if not provider: + return + + name_edit = urwid.Edit(('label', "Name: "), provider.name) + cmd_edit = urwid.Edit(('label', "Command: "), provider.command) + desc_edit = urwid.Edit(('label', "Description: "), provider.description or "") + + def on_save(_): + new_name = name_edit.edit_text.strip() + cmd = cmd_edit.edit_text.strip() + desc = desc_edit.edit_text.strip() + + if new_name and cmd: + # Delete old provider if name changed + if new_name != name: + delete_provider(name) + # Save with new/same name + add_provider(Provider(name=new_name, command=cmd, description=desc)) + self.close_overlay() + self.message_box("Success", f"Provider '{new_name}' saved.", self._show_providers_menu) + else: + self.message_box("Error", "Name and command are required.") + + def on_delete(_): + self.close_overlay() + def do_delete(): + delete_provider(name) + self.message_box("Deleted", f"Provider '{name}' deleted.", self._show_providers_menu) + self.yes_no("Confirm", f"Delete provider '{name}'?", on_yes=do_delete, on_no=self._show_providers_menu) + + def on_cancel(_): + self.close_overlay() + self._show_providers_menu() + + body = urwid.Pile([ + urwid.AttrMap(name_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), + urwid.Divider(), + urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), + ]) + + dialog = Dialog("Edit Provider", body, [("Save", on_save), ("Delete", on_delete), ("Cancel", on_cancel)]) + self.show_overlay(dialog, width=55, height=16) + + +def run_ui(): + """Entry point for the urwid UI.""" + ui = SmartToolsUI() + ui.run() diff --git a/src/smarttools/ui_urwid/__main__.py b/src/smarttools/ui_urwid/__main__.py new file mode 100644 index 0000000..71b1bd2 --- /dev/null +++ b/src/smarttools/ui_urwid/__main__.py @@ -0,0 +1,6 @@ +"""Allow running the UI as a module.""" + +from . import run_ui + +if __name__ == "__main__": + run_ui() diff --git a/src/smarttools/ui_urwid/palette.py b/src/smarttools/ui_urwid/palette.py new file mode 100644 index 0000000..b65cff9 --- /dev/null +++ b/src/smarttools/ui_urwid/palette.py @@ -0,0 +1,30 @@ +"""Color palette for the BIOS-style TUI.""" + +# Color palette - BIOS style with 3D button effects +PALETTE = [ + ('body', 'white', 'dark blue'), + ('header', 'white', 'dark red', 'bold'), + ('footer', 'black', 'light gray'), + # Button colors - raised 3D effect + ('button', 'black', 'light gray'), + ('button_focus', 'white', 'dark red', 'bold'), + ('button_highlight', 'white', 'light gray'), # Top/left edge (light) + ('button_shadow', 'dark gray', 'light gray'), # Bottom/right edge (dark) + ('button_pressed', 'black', 'dark gray'), # Pressed state + # Edit fields + ('edit', 'black', 'light gray'), + ('edit_focus', 'black', 'yellow'), + # List items + ('listbox', 'black', 'light gray'), + ('listbox_focus', 'white', 'dark red'), + # Dialog + ('dialog', 'black', 'light gray'), + ('dialog_border', 'white', 'dark blue'), + # Text styles + ('label', 'yellow', 'dark blue', 'bold'), + ('error', 'white', 'dark red', 'bold'), + ('success', 'light green', 'dark blue', 'bold'), + # 3D shadow elements + ('shadow', 'black', 'black'), + ('shadow_edge', 'dark gray', 'dark blue'), +] diff --git a/src/smarttools/ui_urwid/widgets.py b/src/smarttools/ui_urwid/widgets.py new file mode 100644 index 0000000..f08313d --- /dev/null +++ b/src/smarttools/ui_urwid/widgets.py @@ -0,0 +1,715 @@ +"""Custom widgets for the BIOS-style TUI.""" + +import urwid + + +class SelectableText(urwid.WidgetWrap): + """A selectable text widget for list items.""" + + def __init__(self, text, value=None, on_select=None): + self.value = value + self.on_select = on_select + self.text_widget = urwid.Text(text) + display = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') + super().__init__(display) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == 'enter' and self.on_select: + self.on_select(self.value) + return None + return key + + def mouse_event(self, size, event, button, col, row, focus): + if event == 'mouse press' and button == 1 and self.on_select: + self.on_select(self.value) + return True + return False + + +class Button3D(urwid.WidgetWrap): + """A 3D-style button using box-drawing characters for depth. + + Creates a raised button effect like DOS/BIOS interfaces: + ┌──────────┐ + │ Label │▄ + └──────────┘█ + + When focused, colors change to show selection. + """ + + signals = ['click'] + + def __init__(self, label, on_press=None, user_data=None): + self.label = label + self.on_press = on_press + self.user_data = user_data + self._pressed = False + + # Build the 3D button structure + self._build_widget() + super().__init__(self._widget) + + def _build_widget(self): + """Build the 3D button widget structure.""" + label = self.label + width = len(label) + 4 # Padding inside button + + # Button face with border + # Top edge: ┌────┐ + top = '┌' + '─' * (width - 2) + '┐' + # Middle: │ Label │ with shadow + middle_text = '│ ' + label + ' │' + # Bottom edge: └────┘ with shadow + bottom = '└' + '─' * (width - 2) + '┘' + + # Shadow characters (right and bottom) + shadow_right = '▄' + shadow_bottom = '█' + + # Create the rows + top_row = urwid.Text(top + ' ') # Space for shadow alignment + middle_row = urwid.Columns([ + ('pack', urwid.Text(middle_text)), + ('pack', urwid.Text(('shadow_edge', shadow_right))), + ]) + bottom_row = urwid.Columns([ + ('pack', urwid.Text(bottom)), + ('pack', urwid.Text(('shadow_edge', shadow_right))), + ]) + shadow_row = urwid.Text(('shadow_edge', ' ' + shadow_bottom * (width - 1))) + + # Stack them + pile = urwid.Pile([ + top_row, + middle_row, + bottom_row, + shadow_row, + ]) + + self._widget = urwid.AttrMap(pile, 'button', 'button_focus') + + def selectable(self): + return True + + def keypress(self, size, key): + if key == 'enter': + self._activate() + return None + return key + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1: + if event == 'mouse press': + self._pressed = True + return True + elif event == 'mouse release' and self._pressed: + self._pressed = False + self._activate() + return True + return False + + def _activate(self): + """Trigger the button callback.""" + if self.on_press: + self.on_press(self.user_data) + self._emit('click') + + +class Button3DCompact(urwid.WidgetWrap): + """A compact 3D button that fits on a single line with shadow effect. + + Creates a subtle 3D effect: [ Label ]▌ + + Better for inline use where vertical space is limited. + """ + + signals = ['click'] + + def __init__(self, label, on_press=None, user_data=None): + self.label = label + self.on_press = on_press + self.user_data = user_data + + # Build compact button: ▐ Label ▌ with shadow + # Using block characters for edges + button_text = urwid.Text([ + ('button_highlight', '▐'), + ('button', f' {label} '), + ('button_shadow', '▌'), + ('shadow_edge', '▄'), + ]) + + self._widget = urwid.AttrMap(button_text, None, { + 'button': 'button_focus', + 'button_highlight': 'button_focus', + 'button_shadow': 'button_focus', + }) + super().__init__(self._widget) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == 'enter': + self._activate() + return None + return key + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and event == 'mouse release': + self._activate() + return True + return False + + def _activate(self): + if self.on_press: + self.on_press(self.user_data) + self._emit('click') + + +class ClickableButton(urwid.WidgetWrap): + """A button that responds to mouse clicks (legacy wrapper).""" + + def __init__(self, label, on_press=None, user_data=None): + self.on_press = on_press + self.user_data = user_data + button = urwid.Button(label) + if on_press: + urwid.connect_signal(button, 'click', self._handle_click) + display = urwid.AttrMap(button, 'button', 'button_focus') + super().__init__(display) + + def _handle_click(self, button): + if self.on_press: + self.on_press(self.user_data) + + +class SelectableToolItem(urwid.WidgetWrap): + """A selectable tool item that maintains selection state.""" + + def __init__(self, name, on_select=None): + self.name = name + self.on_select = on_select + self._selected = False + self.text_widget = urwid.Text(f" {name} ") + self.attr_map = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') + super().__init__(self.attr_map) + + def selectable(self): + return True + + def set_selected(self, selected): + """Set whether this item is the selected tool.""" + self._selected = selected + if self._selected: + self.attr_map.set_attr_map({None: 'listbox_focus'}) + else: + self.attr_map.set_attr_map({None: 'listbox'}) + + def keypress(self, size, key): + if key == 'enter' and self.on_select: + self.on_select(self.name) + return None + return key + + def mouse_event(self, size, event, button, col, row, focus): + if event == 'mouse press' and button == 1: + # Single click just selects/focuses - don't call on_select + # on_select is only called on Enter key (to edit) + return True + return False + + +class ToolListBox(urwid.ListBox): + """A ListBox that keeps arrow keys internal and passes Tab out.""" + + def __init__(self, body, on_focus_change=None): + super().__init__(body) + self.on_focus_change = on_focus_change + self._last_focus = None + + def keypress(self, size, key): + if key in ('up', 'down'): + # Handle arrow keys internally - navigate within list + result = super().keypress(size, key) + # Check if focus changed + self._check_focus_change() + return result + elif key == 'tab': + # Pass tab out to parent for focus cycling + return key + elif key == 'shift tab': + return key + else: + return super().keypress(size, key) + + def _check_focus_change(self): + """Check if focus changed and notify callback.""" + try: + current = self.focus + if current is not self._last_focus: + self._last_focus = current + if self.on_focus_change and isinstance(current, SelectableToolItem): + self.on_focus_change(current.name) + except (IndexError, TypeError): + pass + + def render(self, size, focus=False): + # Check focus on render too (for initial display) + if focus: + self._check_focus_change() + return super().render(size, focus) + + +class TabCyclePile(urwid.Pile): + """A Pile that uses Tab/Shift-Tab to cycle between specific positions. + + Args: + widget_list: List of widgets (same as urwid.Pile) + tab_positions: List of indices in the pile that Tab should cycle between. + Default is [0] (only first position). + """ + + def __init__(self, widget_list, tab_positions=None): + super().__init__(widget_list) + # Positions in the pile that Tab should cycle between + self.tab_positions = tab_positions or [0] + self._current_tab_idx = 0 + + def keypress(self, size, key): + if key == 'tab': + # Move to next tab position + self._current_tab_idx = (self._current_tab_idx + 1) % len(self.tab_positions) + self.focus_position = self.tab_positions[self._current_tab_idx] + return None + elif key == 'shift tab': + # Move to previous tab position + self._current_tab_idx = (self._current_tab_idx - 1) % len(self.tab_positions) + self.focus_position = self.tab_positions[self._current_tab_idx] + return None + else: + return super().keypress(size, key) + + +class TabPassEdit(urwid.Edit): + """A multiline Edit that passes Tab through for focus cycling instead of inserting tabs.""" + + def keypress(self, size, key): + if key in ('tab', 'shift tab'): + # Pass Tab through to parent for focus cycling + return key + return super().keypress(size, key) + + +class UndoableEdit(urwid.Edit): + """A multiline Edit with undo/redo support. + + Features: + - Undo with Alt+U (up to 50 states) + - Redo with Alt+R + - Tab passes through for focus cycling + """ + + MAX_UNDO = 50 # Maximum undo history size + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._undo_stack = [] # List of (text, cursor_pos) tuples + self._redo_stack = [] + + def keypress(self, size, key): + if key in ('tab', 'shift tab'): + return key + + # Handle undo (Alt+U or meta u) + if key in ('meta u', 'alt u'): + self._undo() + return None + + # Handle redo (Alt+R or meta r) + if key in ('meta r', 'alt r'): + self._redo() + return None + + # Save current state BEFORE the edit for undo + old_text = self.edit_text + old_pos = self.edit_pos + + # Let the parent handle the keypress + result = super().keypress(size, key) + + # If text changed, save the old state to undo stack + if self.edit_text != old_text: + self._save_undo_state(old_text, old_pos) + self._redo_stack.clear() # Clear redo on new edit + + return result + + def _save_undo_state(self, text, pos): + """Save state to undo stack.""" + # Don't save duplicate states + if self._undo_stack and self._undo_stack[-1][0] == text: + return + if len(self._undo_stack) >= self.MAX_UNDO: + self._undo_stack.pop(0) + self._undo_stack.append((text, pos)) + + def _undo(self): + """Restore previous state from undo stack.""" + if not self._undo_stack: + return + + # Save current state to redo stack + self._redo_stack.append((self.edit_text, self.edit_pos)) + + # Restore previous state + text, pos = self._undo_stack.pop() + self.set_edit_text(text) + self.set_edit_pos(min(pos, len(text))) + + def _redo(self): + """Restore state from redo stack.""" + if not self._redo_stack: + return + + # Save current state to undo stack + self._undo_stack.append((self.edit_text, self.edit_pos)) + + # Restore redo state + text, pos = self._redo_stack.pop() + self.set_edit_text(text) + self.set_edit_pos(min(pos, len(text))) + + +class DOSScrollBar(urwid.WidgetWrap): + """A DOS-style scrollbar with arrow buttons at top and bottom. + + Renders a scrollbar on the right side of the wrapped widget with: + - ▲ arrow at top (click to scroll up) + - ░ track with █ thumb showing scroll position + - ▼ arrow at bottom (click to scroll down) + + Click zones (expanded to last 2 columns for easier clicking): + - Top 25%: scroll up 3 lines + - Bottom 25%: scroll down 3 lines + - Middle: page up/down based on which half clicked + """ + + def __init__(self, widget): + self._wrapped = widget + # Create a columns layout: content on left, scrollbar on right + super().__init__(widget) + + def render(self, size, focus=False): + maxcol, maxrow = size + + # Render the wrapped widget with one less column for scrollbar + content_size = (maxcol - 1, maxrow) + content_canvas = self._wrapped.render(content_size, focus) + + # Build the scrollbar column + scrollbar_chars = [] + + # Up arrow at top + scrollbar_chars.append('▲') + + # Calculate thumb position + if maxrow > 2: + track_height = maxrow - 2 # Minus the two arrow buttons + + # Get scroll position info from wrapped widget + try: + if hasattr(self._wrapped, 'rows_max'): + rows_max = self._wrapped.rows_max(content_size) + scroll_pos = self._wrapped.get_scrollpos(content_size) + else: + rows_max = maxrow + scroll_pos = 0 + + if rows_max > maxrow: + # Calculate thumb position within track + thumb_pos = int((scroll_pos / (rows_max - maxrow)) * (track_height - 1)) + thumb_pos = max(0, min(thumb_pos, track_height - 1)) + else: + thumb_pos = 0 + except (AttributeError, TypeError, ZeroDivisionError): + thumb_pos = 0 + + # Build track with thumb + for i in range(track_height): + if i == thumb_pos: + scrollbar_chars.append('█') # Thumb + else: + scrollbar_chars.append('░') # Track + + # Down arrow at bottom + scrollbar_chars.append('▼') + + # Create scrollbar canvas + scrollbar_text = '\n'.join(scrollbar_chars[:maxrow]) + scrollbar_canvas = urwid.Text(scrollbar_text).render((1,)) + + # Combine canvases + combined = urwid.CanvasJoin([ + (content_canvas, None, focus, content_size[0]), + (scrollbar_canvas, None, False, 1), + ]) + + return combined + + def keypress(self, size, key): + maxcol, maxrow = size + content_size = (maxcol - 1, maxrow) + return self._wrapped.keypress(content_size, key) + + def mouse_event(self, size, event, button, col, row, focus): + maxcol, maxrow = size + content_size = (maxcol - 1, maxrow) + + # Expand clickable area - last 2 columns count as scrollbar + if col >= maxcol - 2: + if button == 1 and event == 'mouse press': + # Top 25% of scrollbar = scroll up + if row < maxrow // 4: + for _ in range(3): + self._wrapped.keypress(content_size, 'up') + self._invalidate() + return True + # Bottom 25% of scrollbar = scroll down + elif row >= maxrow - (maxrow // 4): + for _ in range(3): + self._wrapped.keypress(content_size, 'down') + self._invalidate() + return True + # Middle = page up/down based on which half + elif row < maxrow // 2: + for _ in range(maxrow // 2): + self._wrapped.keypress(content_size, 'up') + self._invalidate() + return True + else: + for _ in range(maxrow // 2): + self._wrapped.keypress(content_size, 'down') + self._invalidate() + return True + + # Handle mouse wheel on scrollbar + if button == 4: # Scroll up + for _ in range(3): + self._wrapped.keypress(content_size, 'up') + self._invalidate() + return True + elif button == 5: # Scroll down + for _ in range(3): + self._wrapped.keypress(content_size, 'down') + self._invalidate() + return True + + return True # Consume other scrollbar clicks + + # Pass to wrapped widget + return self._wrapped.mouse_event(content_size, event, button, col, row, focus) + + def selectable(self): + return self._wrapped.selectable() + + def sizing(self): + return frozenset([urwid.Sizing.BOX]) + + +class ToolBuilderLayout(urwid.WidgetWrap): + """Custom layout for tool builder that handles Tab cycling across all sections.""" + + def __init__(self, left_box, args_box, steps_box, args_section, steps_section, bottom_buttons, on_cancel=None): + self._current_section = 0 + self.on_cancel = on_cancel + + # Store references to LineBoxes for title highlighting + self.left_box = left_box + self.args_box = args_box + self.steps_box = steps_box + + # Build visual layout: left column and right column side by side + right_pile = urwid.Pile([ + ('weight', 1, args_section), + ('pack', urwid.Divider()), + ('weight', 1, steps_section), + ]) + + columns = urwid.Columns([ + ('weight', 1, left_box), + ('weight', 1, right_pile), + ], dividechars=1) + + main_pile = urwid.Pile([ + ('weight', 1, columns), + ('pack', urwid.Divider()), + ('pack', bottom_buttons), + ]) + + super().__init__(main_pile) + + # Set initial highlight + self._update_section_titles() + + def keypress(self, size, key): + if key == 'tab': + self._current_section = (self._current_section + 1) % 4 + self._focus_section(self._current_section) + self._update_section_titles() + return None + elif key == 'shift tab': + self._current_section = (self._current_section - 1) % 4 + self._focus_section(self._current_section) + self._update_section_titles() + return None + elif key == 'esc': + # Go back to main menu instead of exiting + if self.on_cancel: + self.on_cancel(None) + return None + else: + return super().keypress(size, key) + + def mouse_event(self, size, event, button, col, row, focus): + # Let the parent handle the mouse event first + result = super().mouse_event(size, event, button, col, row, focus) + + # After mouse click, detect which section has focus and update titles + if event == 'mouse press': + self._detect_current_section() + self._update_section_titles() + + return result + + def _detect_current_section(self): + """Detect which section currently has focus based on widget hierarchy.""" + main_pile = self._w + + # Check if bottom buttons have focus (position 2) + if main_pile.focus_position == 2: + self._current_section = 3 + return + + # Focus is on columns (position 0) + columns = main_pile.contents[0][0] + + if columns.focus_position == 0: + # Left box (Tool Info) + self._current_section = 0 + else: + # Right pile + right_pile = columns.contents[1][0] + if right_pile.focus_position == 0: + # Args section + self._current_section = 1 + else: + # Steps section + self._current_section = 2 + + def _update_section_titles(self): + """Update section titles to highlight the current one with markers.""" + # Section 0 = Tool Info, Section 1 = Arguments, Section 2 = Steps, Section 3 = buttons + if self._current_section == 0: + self.left_box.set_title('[ Tool Info ]') + self.args_box.set_title('Arguments') + self.steps_box.set_title('Execution Steps') + elif self._current_section == 1: + self.left_box.set_title('Tool Info') + self.args_box.set_title('[ Arguments ]') + self.steps_box.set_title('Execution Steps') + elif self._current_section == 2: + self.left_box.set_title('Tool Info') + self.args_box.set_title('Arguments') + self.steps_box.set_title('[ Execution Steps ]') + else: + # Buttons focused - no section highlighted + self.left_box.set_title('Tool Info') + self.args_box.set_title('Arguments') + self.steps_box.set_title('Execution Steps') + + def _focus_section(self, section_idx): + """Set focus to the specified section.""" + # Get the main pile + main_pile = self._w + + if section_idx == 0: + # Tool Info (left box) - focus columns, then left + main_pile.focus_position = 0 # columns + columns = main_pile.contents[0][0] + columns.focus_position = 0 # left box + elif section_idx == 1: + # Arguments section - focus columns, then right, then args + main_pile.focus_position = 0 # columns + columns = main_pile.contents[0][0] + columns.focus_position = 1 # right pile + right_pile = columns.contents[1][0] + right_pile.focus_position = 0 # args section + elif section_idx == 2: + # Steps section - focus columns, then right, then steps + main_pile.focus_position = 0 # columns + columns = main_pile.contents[0][0] + columns.focus_position = 1 # right pile + right_pile = columns.contents[1][0] + right_pile.focus_position = 2 # steps section (after divider) + elif section_idx == 3: + # Save/Cancel buttons + main_pile.focus_position = 2 # bottom buttons (after divider) + + +class Dialog(urwid.WidgetWrap): + """A dialog box overlay with 3D-style buttons.""" + + def __init__(self, title, body, buttons, width=60, height=None): + # Title + title_widget = urwid.Text(('header', f' {title} '), align='center') + + # Buttons row - use 3D compact buttons for dialog actions + button_widgets = [] + for label, callback in buttons: + btn = Button3DCompact(label, callback) + button_widgets.append(btn) + buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2) + buttons_centered = urwid.Padding(buttons_row, align='center', width='pack') + + # Check if body is a box widget + # ListBox is always a box widget. For Piles with weighted items, + # check if it ONLY supports BOX sizing (not FLOW). + is_box_widget = isinstance(body, (urwid.ListBox, urwid.Scrollable, urwid.ScrollBar)) + if not is_box_widget: + try: + sizing = body.sizing() + # Box widget if it ONLY supports BOX sizing + is_box_widget = sizing == frozenset({urwid.Sizing.BOX}) + except (AttributeError, TypeError): + pass + + if is_box_widget: + # Box widget - use directly with weight + pile = urwid.Pile([ + ('pack', title_widget), + ('pack', urwid.Divider('─')), + ('weight', 1, body), + ('pack', urwid.Divider('─')), + ('pack', buttons_centered), + ]) + else: + # Flow widget - wrap in Filler + body_padded = urwid.Padding(body, left=1, right=1) + body_filled = urwid.Filler(body_padded, valign='top') + pile = urwid.Pile([ + ('pack', title_widget), + ('pack', urwid.Divider('─')), + body_filled, + ('pack', urwid.Divider('─')), + ('pack', buttons_centered), + ]) + + # Box it + box = urwid.LineBox(pile, title='', title_align='center') + box = urwid.AttrMap(box, 'dialog') + + super().__init__(box) diff --git a/src/smarttools/web/routes.py b/src/smarttools/web/routes.py index f5cb433..be31255 100644 --- a/src/smarttools/web/routes.py +++ b/src/smarttools/web/routes.py @@ -22,6 +22,25 @@ def _api_get(path: str, params: Optional[Dict[str, Any]] = None, token: Optional return response.status_code, response.get_json(silent=True) or {} +def _api_post(path: str, data: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]: + client = current_app.test_client() + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + import json + response = client.post(path, data=json.dumps(data or {}), headers=headers) + return response.status_code, response.get_json(silent=True) or {} + + +def _api_delete(path: str, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]: + client = current_app.test_client() + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + response = client.delete(path, headers=headers) + return response.status_code, response.get_json(silent=True) or {} + + def _title_case(value: str) -> str: return value.replace("-", " ").title() @@ -640,3 +659,62 @@ def sitemap(): xml_parts.append("") return Response("\n".join(xml_parts), mimetype="application/xml") + + +# ============================================ +# Dashboard API Proxy Routes +# These proxy browser requests to the API using session auth +# ============================================ + +from flask import jsonify + + +@web_bp.route("/dashboard/api/tokens", methods=["POST"]) +def dashboard_create_token(): + """Proxy token creation from dashboard to API.""" + redirect_response = _require_login() + if redirect_response: + return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401 + + token = session.get("auth_token") + data = request.get_json() or {} + status, payload = _api_post("/api/v1/tokens", data=data, token=token) + return jsonify(payload), status + + +@web_bp.route("/dashboard/api/tokens/", methods=["DELETE"]) +def dashboard_revoke_token(token_id: int): + """Proxy token revocation from dashboard to API.""" + redirect_response = _require_login() + if redirect_response: + return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401 + + token = session.get("auth_token") + status, payload = _api_delete(f"/api/v1/tokens/{token_id}", token=token) + return jsonify(payload), status + + +@web_bp.route("/dashboard/api/tools///deprecate", methods=["POST"]) +def dashboard_deprecate_tool(owner: str, name: str): + """Proxy tool deprecation from dashboard to API.""" + redirect_response = _require_login() + if redirect_response: + return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401 + + token = session.get("auth_token") + data = request.get_json() or {} + status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/deprecate", data=data, token=token) + return jsonify(payload), status + + +@web_bp.route("/dashboard/api/tools///undeprecate", methods=["POST"]) +def dashboard_undeprecate_tool(owner: str, name: str): + """Proxy tool undeprecation from dashboard to API.""" + redirect_response = _require_login() + if redirect_response: + return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401 + + token = session.get("auth_token") + data = request.get_json() or {} + status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/undeprecate", data=data, token=token) + return jsonify(payload), status diff --git a/src/smarttools/web/static/js/main.js b/src/smarttools/web/static/js/main.js index e19fca6..7c0e991 100644 --- a/src/smarttools/web/static/js/main.js +++ b/src/smarttools/web/static/js/main.js @@ -163,10 +163,10 @@ function submitReport(owner, name) { form.addEventListener('submit', async function (e) { e.preventDefault(); const payload = { - tool_owner: ownerField ? ownerField.value : owner, - tool_name: nameField ? nameField.value : name, + owner: ownerField ? ownerField.value : owner, + name: nameField ? nameField.value : name, reason: form.querySelector('[name="reason"]')?.value || '', - description: form.querySelector('[name="description"]')?.value || '' + details: form.querySelector('[name="description"]')?.value || '' }; try { @@ -193,8 +193,14 @@ function submitReport(owner, name) { openReportModal(); } -// Analytics +// Analytics (respects consent) function trackPageView() { + // Only track if user has given analytics consent + const consent = document.body.dataset.analyticsConsent; + if (consent !== 'true') { + return; + } + fetch('/api/v1/analytics/pageview', { method: 'POST', headers: {'Content-Type': 'application/json'}, diff --git a/src/smarttools/web/templates/base.html b/src/smarttools/web/templates/base.html index 9008267..397cb65 100644 --- a/src/smarttools/web/templates/base.html +++ b/src/smarttools/web/templates/base.html @@ -47,7 +47,7 @@ {% endblock %} - + Skip to content diff --git a/src/smarttools/web/templates/dashboard/tokens.html b/src/smarttools/web/templates/dashboard/tokens.html index 70cc76e..d49a020 100644 --- a/src/smarttools/web/templates/dashboard/tokens.html +++ b/src/smarttools/web/templates/dashboard/tokens.html @@ -246,16 +246,16 @@ async function createToken(event) { const name = form.querySelector('[name="name"]').value; try { - const response = await fetch('/api/v1/tokens', { + const response = await fetch('/dashboard/api/tokens', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: name}) }); if (response.ok) { - const data = await response.json(); + const result = await response.json(); closeCreateTokenModal(); - showNewToken(data.token); + showNewToken(result.data.token); } else { const error = await response.json(); alert(error.error || 'Failed to create token'); @@ -294,7 +294,7 @@ async function revokeToken(tokenId, tokenName) { } try { - const response = await fetch(`/api/v1/tokens/${tokenId}`, { + const response = await fetch(`/dashboard/api/tokens/${tokenId}`, { method: 'DELETE' }); diff --git a/src/smarttools/web/templates/dashboard/tools.html b/src/smarttools/web/templates/dashboard/tools.html index e14ff41..04576bd 100644 --- a/src/smarttools/web/templates/dashboard/tools.html +++ b/src/smarttools/web/templates/dashboard/tools.html @@ -202,6 +202,8 @@ {% endblock %}