Author SHA1 Message Date
robandClaude Opus 4.5 9a33d57de1 Add JavaScript interactivity and consent API
- Complete main.js with all interactive functions:
  - Mobile menu toggle with icon switching
  - Search modal with live search
  - Dashboard modals (tokens, deprecate, settings)
  - Tool detail page (copy install, report modal)
  - Toast notifications
- Add /api/v1/consent endpoint for cookie preferences
- Session-based consent storage

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 21:06:40 -04:00
robandClaude Opus 4.5 e029166ba6 Change web blueprint static URL path to avoid conflict
The main Flask app and web blueprint both used /static, causing
conflicts. Changed web blueprint to use /web-static instead.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 20:08:20 -04:00
robandClaude Opus 4.5 9070029d31 Fix static folder path resolution for blueprints
Use absolute paths for templates and static folders to ensure
they resolve correctly regardless of the working directory.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 20:06:34 -04:00
robandClaude Opus 4.5 24c91b8de7 Add deployment guide to WEB_UI.md
Includes:
- Quick start instructions
- Environment variables
- Database configuration (with mergerfs note)
- systemd service example
- nginx reverse proxy config
- SSL setup with certbot
- Tailwind CSS build commands
- Health check and troubleshooting

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 20:00:59 -04:00
robandClaude Opus 4.5 b26ae5092b Add error handlers for 404 and 500 pages
Register Flask error handlers to render the error templates.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:59:01 -04:00
robandClaude Opus 4.5 c41d2eace2 Update .gitignore and remove untracked files
- Added node_modules/, package-lock.json, discussions/, diagrams/
  and temporary files to .gitignore
- Removed accidentally committed files from git tracking

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:50:13 -04:00
robandClaude Opus 4.5 16554c0694 Fix csrf_token collision in auth.py
The auth.py was passing csrf_token as a string variable which
collided with the global csrf_token function in Jinja2. Removed
the redundant passing since csrf_token() is already available
as a Jinja global.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:47:29 -04:00
robandClaude Opus 4.5 0200ad4f6e Add 'now' global function to Jinja2 templates
The footer template uses now().year for the copyright date.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:43:43 -04:00
robandClaude Opus 4.5 b90e632ec7 Fix session interface method indentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:40:42 -04:00
robandClaude Opus 4.5 d6519bde66 Fix blueprint route registration order
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:36:05 -04:00
robandClaude Opus 4.5 c8a937ec15 Fix Semver dataclass order conflict with Python 3.11
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:34:36 -04:00
robandClaude Opus 4.5 cfb9858967 Add built Tailwind CSS and lockfile
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:14:54 -04:00
robandClaude Opus 4.5 34428f0e72 Add SmartTools Registry and Web UI (Phases 1-7)
Registry Features (Phases 1-6):
- Tool manifest format and validation
- Registry client for API communication
- Resolver integration for tool installation
- CLI commands: install, search, browse, publish, auth
- TUI browse interface with urwid
- Smart features: auto-categorization, similarity suggestions

Web UI (Phase 7):
- Flask blueprint with server-side sessions
- Authentication routes (login, register, logout)
- Public pages: landing, tools browse, tool detail, search
- Dashboard: overview, tools management, API tokens, settings
- Template components: cards, forms, callouts, code blocks
- Tailwind CSS build pipeline
- SEO infrastructure (sitemap, robots.txt)
- Cookie consent and privacy compliance

Infrastructure:
- SQLite database with FTS5 search
- Rate limiting for API endpoints
- Git-based tool submission workflow
- Session management with auto-cleanup

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:03:41 -04:00
robandClaude Opus 4.5 284e9981bc Add issue templates for bug reports, feature requests, and questions
Adds Gitea issue templates in .gitea/ISSUE_TEMPLATE/:
- bug_report.md: Environment, steps to reproduce, error output
- feature_request.md: Use case, proposed solution, alternatives
- question.md: Context, what's been tried

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 03:04:23 -04:00
robandClaude Opus 4.5 b67d44ac8b Clean up OpenCode ProviderModelNotFoundError messages
- Detect ProviderModelNotFoundError and show clean message
- Extract provider and model IDs from error
- Suggest: connect provider in opencode, use --provider flag, or edit in UI
- Truncate other stderr to 200 chars to avoid code dumps

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Button3DCompact (single-line):
  ▐ Label ▌▄

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

~430 lines of Python as designed.

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

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

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

Co-Authored-By: Claude <[email protected]>
2025-11-28 04:30:55 -04:00
113 changed files with 25329 additions and 422 deletions
+35
View File
@@ -0,0 +1,35 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info
.eggs
*.egg
dist
build
.venv
venv
ENV
# IDE
.idea
.vscode
*.swp
# Docker
Dockerfile*
docker-compose*
.docker
# Misc
*.md
!README.md
.coverage
.pytest_cache
htmlcov
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug Report
about: Report something that isn't working
labels: bug
---
## Description
What happened?
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What should have happened?
## Environment
- OS:
- SmartTools version:
- Provider:
- Docker or native install?
## Error Output
```
Paste any error messages here
```
+21
View File
@@ -0,0 +1,21 @@
---
name: Feature Request
about: Suggest a new feature or improvement
labels: enhancement
---
## Feature Description
A clear description of the feature you'd like to see.
## Use Case
Why do you need this? What problem does it solve?
## Proposed Solution
How do you think it should work? (optional)
## Alternatives Considered
Any other approaches you've thought about? (optional)
+17
View File
@@ -0,0 +1,17 @@
---
name: Question
about: Ask a question about SmartTools
labels: question
---
## Question
What would you like to know?
## What I've Tried
Any steps you've already taken to find the answer?
## Context
Any additional context that might help?
+47
View File
@@ -0,0 +1,47 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Node.js
node_modules/
package-lock.json
# Project specific
*.log
discussions/
diagrams/
*.puml
.tmp_*
-16
View File
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/smarttools/` contains the core Python package (CLI entry point, tool model, runner, providers, UI backends).
- `tests/` holds pytest suites.
- `docs/` includes installation, provider setup, examples, and design notes.
- `examples/` provides sample tools and an installer script.
- `wiki/` contains additional reference material.
## Architecture Overview
- `cli.py` routes subcommands like `list`, `create`, `run`, `test`, `ui`, and `refresh`.
- `tool.py` defines tool/step models and handles YAML config loading and wrapper generation.
- `runner.py` executes steps and performs `{input}`/argument variable substitution.
- `providers.py` shells out to configured AI provider CLIs (or the `mock` provider).
- `ui_urwid.py` and `ui_snack.py` provide the TUI implementations, selected by `ui.py`.
## Build, Test, and Development Commands
- `pip install -e ".[dev]"` installs SmartTools in editable mode with dev dependencies.
- `pytest` runs the full test suite.
- `pytest tests/test.py::test_name` runs a focused test.
- `python -m smarttools.cli` runs the CLI module directly.
- `smarttools ui` launches the TUI (requires `urwid` or `python-newt`).
- `docker-compose build` builds the dev container image.
- `docker-compose run --rm test` runs tests inside Docker.
## Coding Style & Naming Conventions
- Python code uses 4-space indentation and module/variable names in `snake_case`.
- Keep CLI command names and tool IDs lowercase with hyphens (e.g., `fix-grammar`, `json-extract`).
- Prefer small, composable functions that mirror the Unix pipe flow described in `README.md`.
## Testing Guidelines
- Framework: `pytest` (configured in `pyproject.toml`).
- Place new tests under `tests/` and name files `test_*.py`.
- For provider-dependent logic, prefer the built-in `mock` provider to avoid network calls.
## Commit & Pull Request Guidelines
- Commit messages follow short, imperative, sentence-case subjects (e.g., "Improve provider error messages").
- PRs should include a clear summary, test results (or rationale if tests are skipped), and screenshots when UI behavior changes.
## Security & Configuration Tips
- Provider credentials live in `~/.smarttools/providers.yaml`; avoid committing secrets.
- User tool configs live in `~/.smarttools/<toolname>/config.yaml` and should not be added to the repo.
+80
View File
@@ -0,0 +1,80 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
SmartTools is a lightweight personal tool builder for AI-powered CLI commands. It lets users create custom terminal commands that call AI providers, chain prompts with Python code steps, and use them like any Unix pipe command.
## Development Commands
```bash
# Install for development
pip install -e ".[dev]"
# Run tests
pytest
# Run a single test
pytest tests/test.py::test_name
# Run the CLI
python -m smarttools.cli
# Launch the UI
smarttools ui
```
## Architecture
### Core Modules (`src/smarttools/`)
- **cli.py**: Entry point (`smarttools` command). Routes subcommands: list, create, edit, delete, test, run, ui, refresh
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.smarttools/providers.yaml`
- **ui.py**: UI dispatcher - selects between urwid and snack implementations
- **ui_urwid.py**: Full TUI implementation using urwid library
- **ui_snack.py**: Fallback TUI using python-newt/snack
### Key Paths
- **Tools storage**: `~/.smarttools/<toolname>/config.yaml`
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
- **Provider config**: `~/.smarttools/providers.yaml`
### Tool Structure
Tools are YAML configs with:
- `name`, `description`, `category`
- `arguments`: Custom flags with defaults (e.g., `--max` → `{max}`)
- `steps`: Ordered list of `prompt` or `code` steps
- `output`: Template for final output (e.g., `"{response}"`)
### Step Types
1. **Prompt Step**: Calls AI provider with template, stores result in `output_var`
2. **Code Step**: Executes Python code via `exec()`, captures specified variables
### Variable Flow
Variables are passed between steps:
- `{input}` - always available (stdin/file content)
- `{argname}` - from tool arguments
- `{step_output_var}` - from previous step's `output_var`
Variable substitution is simple string replacement in `runner.py:substitute_variables()`.
## Provider System
Providers are CLI tools that accept prompts via stdin and output to stdout. Defined in `~/.smarttools/providers.yaml`:
```yaml
providers:
- name: claude
command: "claude -p"
- name: mock
command: "echo '[MOCK]'"
```
The `mock` provider is built-in for testing without API calls.
+56
View File
@@ -0,0 +1,56 @@
# SmartTools - AI-powered CLI command builder
# Build: docker build -t smarttools .
# Run: docker run -it --rm -v ~/.smarttools:/root/.smarttools smarttools
FROM python:3.12-slim
LABEL maintainer="rob"
LABEL description="SmartTools - Personal AI-powered CLI command builder"
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
# Node.js for Claude CLI
nodejs \
npm \
# Browser for OAuth authentication
firefox-esr \
# xdg-open for opening URLs (used by Claude CLI)
xdg-utils \
# X11 libraries for display
libx11-6 \
libxext6 \
libxrender1 \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy project files
COPY pyproject.toml README.md ./
COPY src/ ./src/
COPY examples/ ./examples/
COPY docs/ ./docs/
# Install SmartTools and dependencies
RUN pip install --no-cache-dir -e ".[dev]"
# Create smarttools directory
RUN mkdir -p /root/.smarttools /root/.local/bin
# Install example tools
RUN python examples/install.py
# Generate CLI wrappers
RUN smarttools refresh
# Add local bin to PATH
ENV PATH="/root/.local/bin:${PATH}"
# Default command - show help
CMD ["smarttools", "--help"]
# For interactive use:
# docker run -it --rm smarttools smarttools ui
# docker run -it --rm smarttools bash
+560
View File
@@ -0,0 +1,560 @@
# SmartTools
**A lightweight personal tool builder for AI-powered CLI commands.**
Turn any AI model into a Unix-style pipe command. Build once, use forever.
```bash
# Fix grammar in any file
echo "teh cat sat on teh mat" | fix-grammar
# Output: The cat sat on the mat
# Explain errors instantly
cat error.log | explain-error
# Generate commit messages
git diff --staged | commit-msg
# Extract data as validated JSON
echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku"
# Output: {"price": 49.99, "sku": "ABC-123"}
```
## Why SmartTools?
- **Unix Philosophy** - Tools that do one thing well, composable with pipes
- **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models
- **No Lock-in** - Your tools are YAML files, your prompts are yours
- **Multi-step Pipelines** - Chain AI prompts with Python code for validation
- **12+ Providers Profiled** - We tested them so you don't have to
## Try It Now (60 seconds)
See SmartTools in action without installing anything on your system:
```bash
# Pull the container
docker pull gitea.brrd.tech/rob/smarttools:latest
# Run it
docker run -it --rm gitea.brrd.tech/rob/smarttools bash
# Inside the container - install OpenCode (includes free AI models)
smarttools providers install # Select option 4, then 'y'
source ~/.bashrc
# Try it! This explains the README using the free Big Pickle model
cat README.md | eli5
```
That's it - you just used AI to explain itself. The `eli5` tool uses a free model by default, no sign-up required.
## Quick Start (Native Install)
For regular use, install natively:
```bash
# Clone and install
git clone https://gitea.brrd.tech/rob/SmartTools.git
cd SmartTools
pip install -e .
# Ensure ~/.local/bin is in PATH
export PATH="$HOME/.local/bin:$PATH"
# Install an AI provider (interactive guide)
smarttools providers install
# Launch the UI
smarttools ui
# Or create your first tool
smarttools create summarize
```
## Installation
### Native Install
```bash
git clone https://gitea.brrd.tech/rob/SmartTools.git
cd SmartTools
pip install -e .
```
### With Development Dependencies
```bash
git clone https://gitea.brrd.tech/rob/SmartTools.git
cd SmartTools
pip install -e ".[dev]"
```
### Requirements
- Python 3.10+
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
- Optional: `urwid` for the TUI (`pip install urwid`)
### Post-Install
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
Then refresh wrapper scripts:
```bash
smarttools refresh
```
## Usage
### UI Mode (Recommended for Beginners)
```bash
smarttools ui
```
Navigate with arrow keys, Tab, Enter, and mouse. Create tools visually with the built-in prompt editor.
### CLI Mode
```bash
smarttools list # List all tools
smarttools create mytool # Create new tool
smarttools edit mytool # Edit in $EDITOR
smarttools delete mytool # Delete tool
smarttools run mytool # Run a tool
smarttools test mytool # Test with mock provider
smarttools refresh # Update executable wrappers
```
### Running Tools
Once created, tools work like any Unix command:
```bash
# Direct invocation
mytool -i input.txt -o output.txt
# Pipe input (most common)
cat file.txt | mytool
# With arguments
echo "hello" | translate --lang French
# Preview without calling AI
cat file.txt | mytool --dry-run
# Test with mock provider
cat file.txt | mytool --provider mock
```
### Composing Tools
SmartTools follows Unix philosophy: each tool does one thing well, and tools chain together for complex workflows.
```bash
# Chain SmartTools together
cat error.log | log-errors | summarize | translate --lang Spanish
# Code review pipeline: focus on changes, review, then summarize
git diff | diff-focus | review-code | tldr
# Extract data, convert format, analyze
cat report.pdf | json-extract --fields "revenue, costs, date" | json2csv | csv-insights
# Process text through multiple transformations
cat technical_doc.txt | simplify | fix-grammar | tone-shift --tone friendly
# Generate code, validate it, then explain what it does
echo "parse CSV and calculate averages" | code-validate | explain-code
```
**Mix with standard Unix tools:**
```bash
# Extract emails, deduplicate, count
cat inbox.txt | extract-emails | sort -u | wc -l
# Find errors in multiple logs, explain them
cat *.log | grep -i error | explain-error
# Generate changelog for specific author
git log --author="rob" --oneline | changelog > CHANGELOG.md
# Review only large files in a commit
git diff --stat | awk '$3 > 100 {print $1}' | xargs cat | review-code
```
**Build shell functions for common pipelines:**
```bash
# ~/.bashrc
quick-review() {
git diff "$@" | diff-focus | review-code --focus "bugs and security" | tldr
}
translate-doc() {
cat "$1" | summarize | translate --lang "$2"
}
# Usage: quick-review HEAD~3
# Usage: translate-doc manual.txt French
```
## Example Tools
SmartTools comes with 28 pre-built examples you can install:
### Text Processing
| Tool | Description | Example |
|------|-------------|---------|
| `summarize` | Condense documents | `cat article.txt \| summarize` |
| `translate` | Translate text | `echo "Hello" \| translate --lang Spanish` |
| `fix-grammar` | Fix spelling/grammar | `cat draft.txt \| fix-grammar` |
| `simplify` | Rewrite for clarity | `cat legal.txt \| simplify --level "5th grade"` |
| `tone-shift` | Change tone | `cat email.txt \| tone-shift --tone professional` |
| `eli5` | Explain like I'm 5 | `cat quantum.txt \| eli5` |
| `tldr` | One-line summary | `cat readme.txt \| tldr` |
| `expand` | Expand bullet points | `cat notes.txt \| expand` |
### Developer Tools
| Tool | Description | Example |
|------|-------------|---------|
| `explain-error` | Explain stack traces | `cat error.log \| explain-error` |
| `explain-code` | Explain what code does | `cat script.py \| explain-code` |
| `review-code` | Quick code review | `cat pr.diff \| review-code --focus security` |
| `gen-tests` | Generate unit tests | `cat module.py \| gen-tests --framework pytest` |
| `docstring` | Add docstrings | `cat functions.py \| docstring` |
| `commit-msg` | Generate commit message | `git diff --staged \| commit-msg` |
### Data Tools
| Tool | Description | Example |
|------|-------------|---------|
| `json-extract` | Extract as validated JSON | `cat text.txt \| json-extract --fields "name, email"` |
| `json2csv` | Convert JSON to CSV | `cat data.json \| json2csv` |
| `extract-emails` | Extract email addresses | `cat page.html \| extract-emails` |
| `extract-contacts` | Extract contacts as CSV | `cat notes.txt \| extract-contacts` |
| `sql-from-text` | Natural language to SQL | `echo "get active users" \| sql-from-text` |
| `safe-sql` | SQL with safety checks | `echo "delete old records" \| safe-sql` |
| `parse-log` | Analyze log files | `cat app.log \| parse-log --focus errors` |
| `csv-insights` | Analyze CSV data | `cat sales.csv \| csv-insights --question "trends?"` |
### Advanced Multi-Step Tools
| Tool | Description | Pattern |
|------|-------------|---------|
| `log-errors` | Extract & explain errors from huge logs | Code→AI |
| `diff-focus` | Review only added lines | Code→AI |
| `changelog` | Git log to formatted changelog | Code→AI→Code |
| `code-validate` | Generate syntax-checked Python | AI→Code |
### Install Example Tools
```bash
# Run the example installer (from the SmartTools directory)
python examples/install.py
smarttools refresh
```
## Providers
SmartTools works with any AI CLI tool. We've profiled 12 providers:
| Provider | Speed | Accuracy | Cost | Best For |
|----------|-------|----------|------|----------|
| `opencode-deepseek` | 13s | 4/4 | Cheap | **Best value** - daily use |
| `opencode-pickle` | 13s | 4/4 | FREE | **Best free** - accurate |
| `claude-haiku` | 14s | 4/4 | Paid | Fast + accurate |
| `codex` | 14s | 4/4 | Paid | Reliable |
| `claude-opus` | 18s | 4/4 | $$$ | Highest quality |
| `gemini` | 91s | 3/4 | Paid | Large docs (1M tokens) |
See [docs/PROVIDERS.md](docs/PROVIDERS.md) for setup instructions.
## Tool Anatomy
A tool is a YAML config file in `~/.smarttools/<name>/config.yaml`:
```yaml
name: summarize
description: Condense documents to key points
category: Text # Optional: Text, Developer, Data, or Other
arguments:
- flag: --length
variable: length
default: "3-5 bullet points"
steps:
- type: prompt
prompt: |
Summarize this text into {length}:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
### Categories
Tools can be organized into categories for easier navigation in the UI:
| Category | Description |
|----------|-------------|
| `Text` | Text processing (summarize, translate, grammar) |
| `Developer` | Dev tools (explain-error, gen-tests, commit-msg) |
| `Data` | Data extraction and conversion (json-extract, csv) |
| `Other` | Default for uncategorized tools |
### Multi-Step Tools
Chain AI prompts with Python code for powerful workflows:
```yaml
name: json-extract
description: Extract data as validated JSON
steps:
# Step 1: AI extracts data
- type: prompt
prompt: "Extract {fields} as JSON from: {input}"
provider: opencode-deepseek
output_var: raw_json
# Step 2: Code validates JSON
- type: code
code: |
import json
try:
parsed = json.loads(raw_json)
validated = json.dumps(parsed, indent=2)
except:
validated = "ERROR: Invalid JSON"
output_var: validated
output: "{validated}"
```
### Variables
| Variable | Description |
|----------|-------------|
| `{input}` | The piped/file input |
| `{argname}` | Custom argument value |
| `{step_output}` | Output from previous step |
## Shell Integration
### Git Hooks
```bash
# .git/hooks/prepare-commit-msg
#!/bin/bash
git diff --cached | commit-msg > "$1"
```
### Aliases
```bash
# ~/.bashrc
alias gc='git diff --staged | commit-msg'
alias wtf='explain-error'
alias fixme='fix-grammar'
```
### Vim Integration
```vim
" Fix grammar in selection
vnoremap <leader>fg :!fix-grammar<CR>
" Explain selected code
vnoremap <leader>ec :!explain-code<CR>
```
## UI Navigation
| Action | Keys |
|--------|------|
| Cycle sections | `Tab` |
| Go back | `Escape` |
| Select | `Enter` or click |
| Navigate | Arrow keys |
| **Scroll content** | Mouse wheel |
| **Scroll up** | Click top of scrollbar |
| **Scroll down** | Click bottom of scrollbar |
| **Page up/down** | Click middle of scrollbar |
| **Select text** | `Shift` + mouse drag |
| **Copy** | Terminal native (with Shift) |
| **Paste** | `Ctrl+Shift+V` |
| **Undo** (code editor) | `Alt+U` |
| **Redo** (code editor) | `Alt+R` |
**Tips:**
- Hold `Shift` while using mouse for terminal-native text selection.
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`.
- Use the `$EDITOR` button to open code or prompts in your external editor.
## AI-Assisted Code Generation
When adding or editing a **Code Step**, the dialog includes an AI assist panel:
```
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │
│ │ │ ┌─ Prompt ──────────────────────────┐ │
│ │ │ │ Modify this code to... │ │
│ │ │ │ {code} │ │
│ │ │ └──────────────────────────────────┘ │
│ │ │ ┌─ Output & Feedback ───────────────┐ │
│ │ │ │ ✓ Code updated successfully! │ │
│ │ │ └──────────────────────────────────┘ │
│ │ │ < Auto-adjust > │
└────────────────────┘ └───────────────────────────────────────┘
```
- **Provider**: Select any configured AI provider
- **Prompt**: Fully editable template - use `{code}` placeholder for current code
- **Output**: Shows status, success/error messages, and provider feedback
- **Auto-adjust**: Sends prompt to AI and replaces code with response
This lets you generate or modify Python code using AI directly within the tool builder.
## Philosophy
SmartTools is built on **Unix philosophy**:
1. **Do one thing well** - Each tool solves one problem. `summarize` summarizes. `translate` translates. No bloated mega-tools.
2. **Compose freely** - Tools read stdin, write stdout. Chain them: `cat doc.txt | summarize | translate --lang French`
3. **Text is the interface** - No proprietary formats. Pipe text in, get text out. Works with `grep`, `awk`, `sed`, and every other Unix tool.
4. **You own everything** - Tools are YAML files. Prompts are plain text. No vendor lock-in, no cloud dependency for your tool definitions.
5. **Swap parts freely** - Change AI providers per-tool or per-run. Today's best model might not be tomorrow's.
## Contributing
```bash
git clone https://gitea.brrd.tech/rob/smarttools.git
cd smarttools
pip install -e ".[dev]"
pytest
```
## Support
If SmartTools saves you time, consider supporting the project:
**Bitcoin:** `3KeLqGv2Xo8jGYkqA1i68a6nXwgQnuJoza`
**Questions?** robdickson444@hotmail.com
## License
MIT - Use it, modify it, share it.
## Docker
### Pre-built Container (Fastest)
Pull and run the pre-built image directly:
```bash
# Pull from registry
docker pull gitea.brrd.tech/rob/smarttools:latest
# Run SmartTools
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools --help
# List available tools
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools list
# Interactive shell
docker run -it --rm gitea.brrd.tech/rob/smarttools bash
```
### Build from Source
Alternatively, build the container yourself:
```bash
# Clone the repo
git clone https://gitea.brrd.tech/rob/SmartTools.git
cd SmartTools
# Build the container
docker-compose build
# Run tests
docker-compose run --rm test
# Use the CLI
docker-compose run --rm cli smarttools list
# Interactive shell
docker-compose run --rm shell
```
### Installing AI Providers in Docker
AI providers require browser-based authentication. You need display access for the browser to open.
**Using the pre-built container:**
```bash
# Allow Docker to access your display (Linux)
xhost +local:docker
# Run with display access and persistent storage
docker run -it --rm \
-e DISPLAY=$DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v smarttools-data:/root/.smarttools \
--network host \
gitea.brrd.tech/rob/smarttools bash
# Inside the container:
smarttools providers install
```
**Using docker-compose (build from source):**
```bash
xhost +local:docker
docker-compose run --rm setup
# Inside the container:
smarttools providers install
```
**After installing a provider:**
```bash
# Test the provider works
smarttools providers test claude
```
**Available Providers:**
| # | Provider | Cost | Notes |
|---|----------|------|-------|
| 1 | Claude | Pay-per-use | Anthropic account |
| 2 | Codex | Pay-per-use | OpenAI account |
| 3 | Gemini | Free tier | Google account |
| 4 | OpenCode | Free tier | Multiple models |
| 5 | Ollama | FREE | Local, no internet |
## Links
- [Installation Guide](docs/INSTALL.md)
- [Provider Setup](docs/PROVIDERS.md)
- [Example Tools](docs/EXAMPLES.md)
- [Design Document](docs/DESIGN.md)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[237],{3363(e,t,i){i.d(t,{A:()=>a});i(6540);var o=i(4164),n=i(1312),s=i(1107),r=i(4848);function a({className:e}){return(0,r.jsx)("main",{className:(0,o.A)("container margin-vert--xl",e),children:(0,r.jsx)("div",{className:"row",children:(0,r.jsxs)("div",{className:"col col--6 col--offset-3",children:[(0,r.jsx)(s.A,{as:"h1",className:"hero__title",children:(0,r.jsx)(n.A,{id:"theme.NotFound.title",description:"The title of the 404 page",children:"Page Not Found"})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p1",description:"The first paragraph of the 404 page",children:"We could not find what you were looking for."})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p2",description:"The 2nd paragraph of the 404 page",children:"Please contact the owner of the site that linked you to the original URL and let them know their link is broken."})})]})})})}},2237(e,t,i){i.r(t),i.d(t,{default:()=>l});i(6540);var o=i(1312),n=i(5500),s=i(1656),r=i(3363),a=i(4848);function l(){const e=(0,o.T)({id:"theme.NotFound.title",message:"Page Not Found"});return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(n.be,{title:e}),(0,a.jsx)(s.A,{children:(0,a.jsx)(r.A,{})})]})}}}]);
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[854],{5253(e){e.exports=JSON.parse('{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"docs":[{"type":"link","href":"/rob/CmdForge/","label":"CmdForge Overview","docId":"overview","unlisted":false},{"type":"link","href":"/rob/CmdForge/architecture","label":"CmdForge Architecture","docId":"architecture","unlisted":false},{"type":"link","href":"/rob/CmdForge/goals","label":"Goals","docId":"goals","unlisted":false},{"type":"link","href":"/rob/CmdForge/ideas-and-exploration","label":"Ideas & Exploration","docId":"ideas-and-exploration","unlisted":false},{"type":"link","href":"/rob/CmdForge/milestones","label":"Milestones","docId":"milestones","unlisted":false}]},"docs":{"architecture":{"id":"architecture","title":"CmdForge Architecture","description":"Module Structure","sidebar":"docs"},"goals":{"id":"goals","title":"Goals","description":"Vision","sidebar":"docs"},"ideas-and-exploration":{"id":"ideas-and-exploration","title":"Ideas & Exploration","description":"Completed","sidebar":"docs"},"milestones":{"id":"milestones","title":"Milestones","description":"Active","sidebar":"docs"},"overview":{"id":"overview","title":"CmdForge Overview","description":"A lightweight personal tool builder for AI-powered CLI commands.","sidebar":"docs"}}}}')}}]);
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[647],{7121(e,c,s){s.r(c),s.d(c,{default:()=>t});s(6540);var r=s(4164),u=s(7559),a=s(5500),l=s(2831),o=s(1656),p=s(4848);function t(e){return(0,p.jsx)(a.e3,{className:(0,r.A)(u.G.wrapper.docsPages),children:(0,p.jsx)(o.A,{children:(0,l.v)(e.route.routes)})})}}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[98],{1723(n,e,s){s.r(e),s.d(e,{default:()=>d});s(6540);var r=s(5500);function o(n,e){return`docs-${n}-${e}`}var c=s(3025),t=s(2831),i=s(1463),u=s(4848);function l(n){const{version:e}=n;return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(i.A,{version:e.version,tag:o(e.pluginId,e.version)}),(0,u.jsx)(r.be,{children:e.noIndex&&(0,u.jsx)("meta",{name:"robots",content:"noindex, nofollow"})})]})}function a(n){const{version:e,route:s}=n;return(0,u.jsx)(r.e3,{className:e.className,children:(0,u.jsx)(c.n,{version:e,children:(0,t.v)(s.routes)})})}function d(n){return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(l,{...n}),(0,u.jsx)(a,{...n})]})}}}]);
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[742],{7093(c){c.exports=JSON.parse('{"name":"docusaurus-plugin-content-docs","id":"default"}')}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-61
View File
@@ -1,61 +0,0 @@
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
* @license MIT */
/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
-1
View File
@@ -1 +0,0 @@
(()=>{"use strict";var e,r,t,a,o,n={},c={};function i(e){var r=c[e];if(void 0!==r)return r.exports;var t=c[e]={id:e,loaded:!1,exports:{}};return n[e].call(t.exports,t,t.exports,i),t.loaded=!0,t.exports}i.m=n,i.c=c,e=[],i.O=(r,t,a,o)=>{if(!t){var n=1/0;for(l=0;l<e.length;l++){for(var[t,a,o]=e[l],c=!0,f=0;f<t.length;f++)(!1&o||n>=o)&&Object.keys(i.O).every(e=>i.O[e](t[f]))?t.splice(f--,1):(c=!1,o<n&&(n=o));if(c){e.splice(l--,1);var d=a();void 0!==d&&(r=d)}}return r}o=o||0;for(var l=e.length;l>0&&e[l-1][2]>o;l--)e[l]=e[l-1];e[l]=[t,a,o]},i.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return i.d(r,{a:r}),r},t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__,i.t=function(e,a){if(1&a&&(e=this(e)),8&a)return e;if("object"==typeof e&&e){if(4&a&&e.__esModule)return e;if(16&a&&"function"==typeof e.then)return e}var o=Object.create(null);i.r(o);var n={};r=r||[null,t({}),t([]),t(t)];for(var c=2&a&&e;("object"==typeof c||"function"==typeof c)&&!~r.indexOf(c);c=t(c))Object.getOwnPropertyNames(c).forEach(r=>n[r]=()=>e[r]);return n.default=()=>e,i.d(o,n),o},i.d=(e,r)=>{for(var t in r)i.o(r,t)&&!i.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},i.f={},i.e=e=>Promise.all(Object.keys(i.f).reduce((r,t)=>(i.f[t](e,r),r),[])),i.u=e=>"assets/js/"+({48:"a94703ab",98:"a7bd4aaa",207:"e719f3dc",401:"17896441",413:"1db64337",443:"5281b7a2",574:"817f7194",647:"5e95c892",742:"aba21aa0",854:"3fbf7384",894:"5eebbccf"}[e]||e)+"."+{48:"c0ce3492",98:"3ba34601",207:"5bfd6599",237:"46c4f719",401:"a2525508",413:"ab5ebde9",443:"1c1a5adb",574:"070f961b",647:"a3b66919",742:"4a552a5c",854:"1561f6b5",894:"7d0f69ae"}[e]+".js",i.miniCssF=e=>{},i.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),a={},o="project-public-docs:",i.l=(e,r,t,n)=>{if(a[e])a[e].push(r);else{var c,f;if(void 0!==t)for(var d=document.getElementsByTagName("script"),l=0;l<d.length;l++){var u=d[l];if(u.getAttribute("src")==e||u.getAttribute("data-webpack")==o+t){c=u;break}}c||(f=!0,(c=document.createElement("script")).charset="utf-8",i.nc&&c.setAttribute("nonce",i.nc),c.setAttribute("data-webpack",o+t),c.src=e),a[e]=[r];var b=(r,t)=>{c.onerror=c.onload=null,clearTimeout(s);var o=a[e];if(delete a[e],c.parentNode&&c.parentNode.removeChild(c),o&&o.forEach(e=>e(t)),r)return r(t)},s=setTimeout(b.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=b.bind(null,c.onerror),c.onload=b.bind(null,c.onload),f&&document.head.appendChild(c)}},i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.p="/rob/CmdForge/",i.gca=function(e){return e={17896441:"401",a94703ab:"48",a7bd4aaa:"98",e719f3dc:"207","1db64337":"413","5281b7a2":"443","817f7194":"574","5e95c892":"647",aba21aa0:"742","3fbf7384":"854","5eebbccf":"894"}[e]||e,i.p+i.u(e)},(()=>{var e={354:0,869:0};i.f.j=(r,t)=>{var a=i.o(e,r)?e[r]:void 0;if(0!==a)if(a)t.push(a[2]);else if(/^(354|869)$/.test(r))e[r]=0;else{var o=new Promise((t,o)=>a=e[r]=[t,o]);t.push(a[2]=o);var n=i.p+i.u(r),c=new Error;i.l(n,t=>{if(i.o(e,r)&&(0!==(a=e[r])&&(e[r]=void 0),a)){var o=t&&("load"===t.type?"missing":t.type),n=t&&t.target&&t.target.src;c.message="Loading chunk "+r+" failed.\n("+o+": "+n+")",c.name="ChunkLoadError",c.type=o,c.request=n,a[1](c)}},"chunk-"+r,r)}},i.O.j=r=>0===e[r];var r=(r,t)=>{var a,o,[n,c,f]=t,d=0;if(n.some(r=>0!==e[r])){for(a in c)i.o(c,a)&&(i.m[a]=c[a]);if(f)var l=f(i)}for(r&&r(t);d<n.length;d++)o=n[d],i.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return i.O(l)},t=globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})()})();
+97
View File
@@ -0,0 +1,97 @@
# SmartTools - AI-powered CLI command builder
#
# Quick Start:
# docker-compose build # Build the image
# docker-compose run --rm test # Run tests
# docker-compose run --rm cli smarttools list
#
# This is a standalone project with no external dependencies.
version: '3.8'
services:
# ============================================================================
# Main CLI
# ============================================================================
cli:
build:
context: .
dockerfile: Dockerfile
image: smarttools:latest
volumes:
- smarttools-data:/root/.smarttools
command: ["smarttools", "--help"]
# ============================================================================
# Tests
# ============================================================================
test:
build:
context: .
dockerfile: Dockerfile
image: smarttools:latest
volumes:
- smarttools-data:/root/.smarttools
command: ["pytest", "-v"]
# ============================================================================
# Interactive Shell
# ============================================================================
shell:
build:
context: .
dockerfile: Dockerfile
image: smarttools:latest
volumes:
- smarttools-data:/root/.smarttools
command: ["/bin/bash"]
stdin_open: true
tty: true
# ============================================================================
# Setup (with display for browser-based auth)
# ============================================================================
# Use this for installing providers that need browser authentication
# First run: xhost +local:docker
setup:
build:
context: .
dockerfile: Dockerfile
image: smarttools:latest
environment:
- DISPLAY=${DISPLAY:-:0}
volumes:
- smarttools-data:/root/.smarttools
- /tmp/.X11-unix:/tmp/.X11-unix:ro
command: ["/bin/bash"]
stdin_open: true
tty: true
network_mode: host
volumes:
smarttools-data:
# Persists ~/.smarttools between container runs
# ==============================================================================
# Usage Examples
# ==============================================================================
#
# Build:
# docker-compose build
#
# Run tests:
# docker-compose run --rm test
#
# Use CLI:
# docker-compose run --rm cli smarttools list
# docker-compose run --rm cli smarttools run hello-world
#
# Interactive shell:
# docker-compose run --rm shell
#
# Install providers (with browser auth):
# xhost +local:docker
# docker-compose run --rm setup
# # Inside container:
# smarttools providers install # Interactive guide
# # Select a provider, it will install and open browser for auth
+408
View File
@@ -0,0 +1,408 @@
# SmartTools - Design Document
> A lightweight personal tool builder for AI-powered CLI commands
## Overview
SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, steps, provider), then use it like any Linux command.
**Example:**
```bash
# Create a summarizer tool, then use it:
sum -i text.txt -o summary.txt --max 512
```
## Core Concepts
### Tool = Directory + Config
```
~/.smarttools/
sum/
config.yaml
processed.py # Optional external code file
reviewer/
config.yaml
translator/
config.yaml
```
### config.yaml Format
```yaml
name: sum
description: "Summarize documents"
arguments:
- flag: --max
variable: max
default: "500"
description: "Maximum words in summary"
steps:
- type: prompt
prompt: |
Summarize the following text in {max} words or less:
{input}
provider: claude
output_var: response
output: "{response}"
```
### Step Types
**Prompt Step** - Calls an AI provider:
```yaml
- type: prompt
prompt: "Your prompt template with {variables}"
provider: claude
output_var: response
```
**Code Step** - Runs Python code:
```yaml
- type: code
code: |
processed = input.upper()
count = len(processed.split())
output_var: processed, count
code_file: processed.py # Optional: external file storage
```
Steps execute in order. Each step's `output_var` becomes available to subsequent steps.
### Variables
- `{input}` - Always available, contains stdin or input file content (empty string if no input)
- `{variable_name}` - From arguments (e.g., `{max}`)
- `{output_var}` - From previous steps (e.g., `{response}`, `{processed}`)
### Output Variables
The `output_var` field specifies which Python variable(s) to capture from your code:
**Single variable:**
```yaml
output_var: processed
```
```python
processed = input.upper() # This gets captured
```
**Multiple variables (comma-separated):**
```yaml
output_var: processed, count, summary
```
```python
processed = input.upper()
count = len(processed.split())
summary = f"Processed {count} words"
# All three are captured and available as {processed}, {count}, {summary}
```
## CLI Interface
### Running Tools
```bash
# Basic usage (wrapper script in ~/.local/bin)
sum -i document.txt -o summary.txt
# With custom args
sum -i document.txt --max 200
# Preview prompt without calling AI
sum -i document.txt --dry-run
# See what prompt gets sent
sum -i document.txt --show-prompt
# Test with mock (no API call)
sum -i document.txt --provider mock
# Read from stdin, write to stdout
cat doc.txt | sum | less
# Interactive stdin input
sum --stdin
# No input (empty string) - useful for tools using only arguments
sum --max 100
# Or via smarttools run
smarttools run sum -i document.txt
```
### Input Handling
| Scenario | Behavior |
|----------|----------|
| Piped stdin | Automatically read (`cat file.txt \| mytool`) |
| `-i file.txt` | Read from file |
| `--stdin` | Interactive input (type then Ctrl+D) |
| No input | Empty string (useful for argument-only tools) |
### Universal Flags (all tools)
| Flag | Short | Description |
|------|-------|-------------|
| `--input` | `-i` | Input file |
| `--output` | `-o` | Output file (or stdout if omitted) |
| `--stdin` | | Read input interactively (type then Ctrl+D) |
| `--dry-run` | | Show prompt, don't call AI |
| `--show-prompt` | | Call AI but also print prompt to stderr |
| `--provider` | `-p` | Override provider (e.g., `--provider mock`) |
| `--verbose` | `-v` | Show debug info |
| `--help` | `-h` | Show help |
### Managing Tools
```bash
# Launch the UI to manage tools
smarttools
# Or use CLI directly:
smarttools list # List all tools
smarttools create sum # Create new tool (basic)
smarttools edit sum # Edit tool config in $EDITOR
smarttools delete sum # Delete tool
smarttools test sum # Test with mock provider
smarttools run sum # Run tool for real
smarttools refresh # Refresh all wrapper scripts
smarttools ui # Launch interactive UI
```
## Terminal UI
A BIOS-style terminal UI using `urwid` with full mouse support.
### Main Menu
```
┌──────────────────────────────────────┐
│ SmartTools Manager │
├──────────────────────────────────────┤
│ Tools: │
│ ┌────────────────────────────────┐ │
│ │ sum │ │
│ │ reviewer │ │
│ │ translator │ │
│ └────────────────────────────────┘ │
│ │
│ [Create] [Edit] [Delete] [Test] │
│ │
│ Tool Info: │
│ Name: sum │
│ Description: Summarize documents │
│ Arguments: --max │
│ Steps: PROMPT[claude] -> {response} │
│ │
│ [EXIT] │
└──────────────────────────────────────┘
```
### Tool Builder
```
┌──────────────────────────────────────────────────────────────┐
│ New Tool │
├──────────────────────────────────────────────────────────────┤
│ ┌─[ Tool Info ]──────────┐ ┌─ Arguments ─────────────────┐ │
│ │ Name: [sum__________] │ │ --max -> {max} │ │
│ │ │ │ │ │
│ │ Desc: [Summarize____] │ └─────────────────────────────┘ │
│ │ │ [Add] [Edit] [Delete] │
│ │ Output: [{response}_] │ │
│ └────────────────────────┘ ┌─ Execution Steps ───────────┐ │
│ │ P:claude -> {response} │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ [Add] [Edit] [Delete] │
│ │
│ [Save] [Cancel] │
└──────────────────────────────────────────────────────────────┘
```
**Navigation:**
- **Tab** - Cycle between sections (Tool Info -> Arguments -> Steps -> Buttons)
- **Arrow keys** - Navigate within lists
- **Enter/Click** - Select/activate
- **Escape** - Cancel/back
- **Shift + mouse** - Terminal-native text selection (bypasses UI mouse handling)
- **Ctrl+Shift+V** - Paste
The current section's title is highlighted with brackets: `[ Tool Info ]`
**Copy/Paste tip:** Hold `Shift` while dragging with mouse to select text, then use your terminal's copy function (usually right-click or Ctrl+Shift+C).
### Code Step Dialog
```
┌──────────────── Add Code Step ─────────────────┐
│ Variables: input, max, response │
│ │
│ File: [processed.py______________] < Load > │
│ │
│ ┌─ Code ─────────────────────────────────────┐ │
│ │processed = input.upper() │ │
│ │count = len(processed.split()) │ │
│ │ │ │
│ └────────────────────────────────────────────┘ │
│ │
│ Output var: [processed, count____] │
├────────────────────────────────────────────────┤
│ < OK > < Cancel > │
└────────────────────────────────────────────────┘
```
**Code Step Features:**
- **Multiline editor** - Write multi-line Python code
- **External file storage** - Code is auto-saved to `~/.smarttools/<toolname>/<filename>` on OK
- **Load button** - Load code from external file (with confirmation)
- **Multiple output vars** - Capture multiple variables (comma-separated)
## Implementation
### Directory Structure
```
smarttools/
__init__.py
cli.py # Entry point, argument parsing
ui.py # UI dispatcher (chooses urwid/snack/dialog)
ui_urwid.py # Urwid-based BIOS-style UI
ui_snack.py # Snack/newt fallback UI
tool.py # Tool loading/saving/wrapper scripts
runner.py # Execute tools
providers.py # Provider abstraction
```
### Provider System
Providers are defined in `~/.smarttools/providers.yaml`:
```yaml
providers:
- name: claude
command: "claude -p"
description: "Anthropic Claude"
- name: codex
command: "codex -p"
description: "OpenAI Codex"
- name: gemini
command: "gemini"
description: "Google Gemini"
```
The provider command receives the prompt via stdin and outputs to stdout.
### Wrapper Scripts
When you save a tool, SmartTools creates a wrapper script in `~/.local/bin/`:
```bash
#!/bin/bash
# SmartTools wrapper for 'sum'
# Auto-generated - do not edit
exec /path/to/python -m smarttools.runner sum "$@"
```
The wrapper uses the full Python path to ensure the correct environment is used.
Use `smarttools refresh` to regenerate all wrapper scripts (e.g., after changing Python environments).
### Tool Name Validation
Tool names must:
- Start with a letter or underscore
- Contain only letters, numbers, underscores, and dashes
- Not contain spaces or shell-problematic characters (`/\|&;$`"'<>(){}[]!?*#~`)
## Tool Composition
SmartTools are designed to chain together like any Unix command.
### External Pipelines (Tool-to-Tool)
```bash
# Chain multiple SmartTools
cat logs.txt | log-errors | summarize | translate --lang Japanese
# Mix with standard Unix tools
git log --oneline | head -20 | changelog | tee CHANGELOG.md
# Build complex workflows
cat *.py | review-code --focus security | json-extract --fields "issue, severity, file" | json2csv
```
Each tool reads stdin and writes stdout. No special integration needed.
### Internal Pipelines (Multi-Step)
Within a single tool, chain steps for preprocessing/validation:
```yaml
steps:
- type: code # Preprocess
code: |
filtered = '\n'.join(l for l in input.split('\n') if 'ERROR' in l)
output_var: filtered
- type: prompt # AI processes filtered input
prompt: "Explain these errors: {filtered}"
provider: claude-haiku
output_var: explanation
- type: code # Post-process
code: |
result = f"Found {len(filtered.split(chr(10)))} errors:\n\n{explanation}"
output_var: result
output: "{result}"
```
**When to use which:**
- **External pipelines**: Reusable tools, different providers per stage, standard Unix interop
- **Internal pipelines**: Tightly coupled steps, shared context, validation of AI output
## What This Design Doesn't Include
Intentionally omitted (not needed for personal use):
- Trust tiers / security levels
- Cryptographic signing
- Container isolation / sandboxing
- Certification testing
- Distribution packaging
- PII redaction
- Audit logging
- Provider capability negotiation
**Why?** This is a personal tool builder. You write the tools, you run the tools, you accept the responsibility. Just like any bash script you write.
## Dependencies
Required:
- Python 3.10+
- PyYAML
- urwid (for TUI)
Optional fallbacks:
- python3-newt/snack (simpler TUI)
- dialog/whiptail (basic TUI)
## Example Workflow
1. Run `smarttools` to open UI
2. Select "Create" to create a new tool
3. Fill in: name, description, output template
4. Add arguments (e.g., `--max` with default `500`)
5. Add a prompt step with your prompt template and provider
6. Click "Save"
7. Exit UI
8. Run `sum -i myfile.txt -o summary.txt`
Done. No containers, no signing, no certification. Just a tool that works.
## Default Values
| Step Type | Default Output Var | Default File |
|-----------|-------------------|--------------|
| Prompt | `response` | n/a |
| Code | `processed` | `processed.py` |
+1058
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
# Installation Guide
Complete installation instructions for SmartTools.
## Quick Install
```bash
git clone https://gitea.brrd.tech/rob/SmartTools.git
cd SmartTools
pip install -e .
export PATH="$HOME/.local/bin:$PATH"
smarttools providers install
```
## Detailed Installation
### Step 1: Install Python 3.10+
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install python3 python3-pip
```
**macOS:**
```bash
brew install [email protected]
```
**Verify:**
```bash
python3 --version # Should be 3.10 or higher
```
### Step 2: Install SmartTools
```bash
git clone https://gitea.brrd.tech/rob/SmartTools.git
cd SmartTools
pip install -e .
```
**With TUI and development dependencies:**
```bash
pip install -e ".[dev]"
```
### Step 3: Configure PATH
SmartTools creates executable wrappers in `~/.local/bin/`. Add this to your shell config:
**Bash (~/.bashrc):**
```bash
export PATH="$HOME/.local/bin:$PATH"
```
**Zsh (~/.zshrc):**
```bash
export PATH="$HOME/.local/bin:$PATH"
```
**Fish (~/.config/fish/config.fish):**
```fish
set -gx PATH $HOME/.local/bin $PATH
```
Then reload:
```bash
source ~/.bashrc # or restart terminal
```
### Step 4: Install an AI Provider
You need at least one AI CLI tool. The easiest way is the interactive installer:
```bash
smarttools providers install
```
This guides you through installing and authenticating providers.
#### Manual Installation
**OpenCode (free tier available):**
```bash
curl -fsSL https://opencode.ai/install | bash
source ~/.bashrc # or start new shell
opencode # Opens browser for sign-in
```
**Claude CLI (pay-per-use):**
```bash
npm install -g @anthropic-ai/claude-code
claude # Opens browser for sign-in
```
**Codex (pay-per-use):**
```bash
npm install -g @openai/codex
codex # Opens browser for sign-in
```
**Gemini (free tier available):**
```bash
npm install -g @google/gemini-cli
gemini # Opens browser for Google sign-in
```
**Ollama (free, runs locally):**
```bash
curl -fsSL https://ollama.ai/install.sh | bash
ollama pull llama3
smarttools providers add ollama "ollama run llama3" -d "Local Llama 3"
```
See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
### Step 5: Verify Installation
```bash
# Check SmartTools is installed
smarttools --version
# List configured providers
smarttools providers list
# Check which providers are available
smarttools providers check
# Test with mock provider (no AI needed)
echo "hello world" | smarttools run --provider mock
```
### Step 6: Create Wrapper Scripts
After installation or any Python environment change:
```bash
smarttools refresh
```
This creates executable scripts in `~/.local/bin/` for all your tools.
## Installing Example Tools
### Quick Install (All 28 Tools)
```bash
python examples/install.py
smarttools refresh
```
### Manual Install
Copy individual tool configs to `~/.smarttools/<toolname>/config.yaml`.
See [EXAMPLES.md](EXAMPLES.md) for all tool configurations.
## Troubleshooting
### "smarttools: command not found"
PATH is not configured. Add to your shell config:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
### "Provider 'X' not found"
1. Check provider is installed: `which claude` or `which opencode`
2. Check provider is configured: `smarttools providers`
3. Add missing provider: `smarttools providers add`
### "Permission denied" on tool execution
Refresh wrapper scripts:
```bash
smarttools refresh
```
### TUI doesn't start / looks broken
Install urwid:
```bash
pip install urwid
```
Ensure your terminal supports mouse (most modern terminals do).
### Tools work with `smarttools run` but not directly
The wrapper scripts may be outdated. Refresh them:
```bash
smarttools refresh
```
### Python version mismatch
If you have multiple Python versions, ensure SmartTools uses the right one:
```bash
python3.11 -m pip install smarttools
```
## Uninstalling
```bash
# Remove package
pip uninstall smarttools
# Remove config and tools (optional)
rm -rf ~/.smarttools
# Remove wrapper scripts (optional)
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
```
## Upgrading
```bash
pip install --upgrade smarttools
smarttools refresh
```
## Directory Structure
After installation:
```
~/.smarttools/
├── providers.yaml # Provider configurations
├── summarize/
│ └── config.yaml # Tool config
├── translate/
│ ├── config.yaml
│ └── prompt.txt # External prompt file (optional)
└── ...
~/.local/bin/
├── smarttools # Main command
├── summarize # Tool wrapper script
├── translate # Tool wrapper script
└── ...
```
## Next Steps
1. [Set up providers](PROVIDERS.md)
2. [Browse example tools](EXAMPLES.md)
3. Run `smarttools ui` to create your first tool
+328
View File
@@ -0,0 +1,328 @@
# Provider Setup Guide
SmartTools works with any AI CLI tool that accepts input via stdin or arguments. This guide covers setup for the most popular providers.
## Provider Comparison
We profiled 12 providers with a 4-task benchmark (Math, Code, Reasoning, Data Extraction):
| Provider | Speed | Score | Cost | Best For |
|----------|-------|-------|------|----------|
| **opencode-deepseek** | 13s | 4/4 | ~$0.28/M tokens | **Best value** - daily driver |
| **opencode-pickle** | 13s | 4/4 | FREE | **Best free** - accurate |
| **claude-haiku** | 14s | 4/4 | ~$0.25/M tokens | Fast + high quality |
| **codex** | 14s | 4/4 | ~$1.25/M tokens | Reliable, auto-routes |
| **claude** | 18s | 4/4 | Varies | Auto-routes to best |
| **claude-opus** | 18s | 4/4 | ~$15/M tokens | Highest quality |
| **claude-sonnet** | 21s | 4/4 | ~$3/M tokens | Balanced |
| **opencode-nano** | 24s | 4/4 | Paid | GPT-5 Nano |
| **gemini-flash** | 28s | 4/4 | ~$0.075/M tokens | Google, faster |
| **opencode-reasoner** | 33s | 4/4 | ~$0.28/M tokens | Complex reasoning |
| **gemini** | 91s | 3/4 | ~$1.25/M tokens | 1M token context |
| **opencode-grok** | 11s | 2/4 | FREE | Fastest but unreliable |
### Recommendations
- **Daily use:** `opencode-deepseek` or `opencode-pickle` (free)
- **Quality work:** `claude-haiku` or `claude-opus`
- **Complex reasoning:** `opencode-reasoner`
- **Large documents:** `gemini` (1M token context window)
- **Budget:** `opencode-pickle` (free) or `opencode-deepseek` (cheap)
## Provider Setup
### OpenCode (Recommended)
OpenCode provides access to multiple models including free options.
**Install:**
```bash
curl -fsSL https://opencode.ai/install | bash
```
**Authenticate:**
```bash
~/.opencode/bin/opencode auth
```
**Available Models:**
| Provider Name | Model | Cost |
|---------------|-------|------|
| `opencode-deepseek` | deepseek-chat | Cheap |
| `opencode-pickle` | big-pickle | FREE |
| `opencode-grok` | grok-code | FREE |
| `opencode-nano` | gpt-5-nano | Paid |
| `opencode-reasoner` | deepseek-reasoner | Cheap |
**Test:**
```bash
echo "Hello" | ~/.opencode/bin/opencode run --model opencode/big-pickle
```
### Claude CLI
Anthropic's official CLI for Claude models.
**Install:**
```bash
npm install -g @anthropic-ai/claude-code
```
**Authenticate:**
```bash
claude # Opens browser for sign-in (auto-saves auth tokens)
```
**Available Models:**
| Provider Name | Model | Cost |
|---------------|-------|------|
| `claude` | Auto-routes | Varies |
| `claude-haiku` | Haiku 4.5 | Cheap |
| `claude-sonnet` | Sonnet 4.5 | Medium |
| `claude-opus` | Opus 4.5 | Expensive |
**Test:**
```bash
echo "Hello" | claude -p
```
### Codex (OpenAI)
OpenAI's Codex CLI with auto-routing.
**Install:**
```bash
npm install -g @openai/codex
```
**Authenticate:**
```bash
codex # Opens browser for sign-in (auto-saves auth tokens)
```
**Test:**
```bash
echo "Hello" | codex exec -
```
### Gemini
Google's Gemini models. Best for large context (1M tokens).
**Install:**
```bash
npm install -g @google/gemini-cli
```
**Authenticate:**
```bash
gemini # Opens browser for Google sign-in
```
**Available Models:**
| Provider Name | Model | Notes |
|---------------|-------|-------|
| `gemini` | gemini-2.5-pro | Quality, slow CLI |
| `gemini-flash` | gemini-2.5-flash | Faster |
**Note:** Gemini CLI has known performance issues. Use `gemini-flash` for interactive tasks, `gemini` for large documents.
**Test:**
```bash
echo "Hello" | gemini --model gemini-2.5-flash
```
## Managing Providers
### Interactive Installation (Recommended)
The easiest way to install providers:
```bash
smarttools providers install
```
This interactive guide:
- Shows available AI providers with costs
- Runs the installation command
- Updates PATH automatically
- Shows next steps for authentication
### List Providers
```bash
smarttools providers list
```
### Check Availability
```bash
smarttools providers check
```
### Add Custom Provider
```bash
smarttools providers add myname "my-command --args" -d "Description"
```
Or edit `~/.smarttools/providers.yaml`:
```yaml
providers:
- name: my-custom
command: my-ai-tool --prompt
description: My custom AI tool
```
### Provider Command Format
The command should:
1. Accept input via stdin
2. Output response to stdout
3. Exit 0 on success
Example commands:
```bash
# Claude
claude -p
# OpenCode
$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat
# Gemini
gemini --model gemini-2.5-flash
# Codex
codex exec -
# Custom (any tool that reads stdin)
my-tool --input -
```
### Environment Variables
Provider commands can use environment variables:
```yaml
providers:
- name: opencode
command: $HOME/.opencode/bin/opencode run
```
`$HOME` and `~` are expanded automatically.
## Using Providers in Tools
### In Tool Config
```yaml
steps:
- type: prompt
prompt: "Summarize: {input}"
provider: opencode-pickle # Use this provider
output_var: response
```
### Override at Runtime
```bash
# Use a different provider for this run
cat file.txt | summarize --provider claude-opus
```
### Provider Selection Strategy
1. **Tool default** - Set in tool's config.yaml
2. **Runtime override** - `--provider` flag
3. **Cost optimization** - Use cheap providers for simple tasks
4. **Quality needs** - Use opus/sonnet for important work
## Troubleshooting
### "Provider 'X' not found"
1. Check it's in your providers list: `smarttools providers`
2. Verify the command works: `echo "test" | <command>`
3. Add it: `smarttools providers add`
### "Command 'X' not found"
The AI CLI tool isn't installed or not in PATH:
```bash
which claude # Should show path
which opencode # Might need full path
```
For OpenCode, use full path in provider:
```yaml
command: $HOME/.opencode/bin/opencode run
```
### Slow Provider
- Use `gemini-flash` instead of `gemini`
- Use `claude-haiku` instead of `claude-opus`
- Use `opencode-deepseek` for best speed/quality ratio
### Provider Errors
Check the provider works directly:
```bash
echo "Say hello" | claude -p
echo "Say hello" | ~/.opencode/bin/opencode run
```
If it works directly but not in SmartTools, check:
1. Provider command in `~/.smarttools/providers.yaml`
2. Environment variables are expanded correctly
## Cost Optimization
### Free Providers
- `opencode-pickle` - Big Pickle model (FREE, accurate)
- `opencode-grok` - Grok Code (FREE, fast but less reliable)
### Cheap Providers
- `opencode-deepseek` - ~$0.28/M tokens
- `opencode-reasoner` - ~$0.28/M tokens
- `claude-haiku` - ~$0.25/M tokens
### Tips
1. Use `opencode-pickle` for simple tasks (free + accurate)
2. Use `claude-haiku` when you need reliability
3. Reserve `claude-opus` for important work
4. Use `gemini` only for large document analysis
## Adding New Providers
Any CLI tool that:
- Reads from stdin
- Writes to stdout
- Exits 0 on success
Can be a provider. Examples:
**Local LLM (Ollama):**
```yaml
- name: ollama-llama
command: ollama run llama3
description: Local Llama 3
```
**Custom API wrapper:**
```yaml
- name: my-api
command: curl -s -X POST https://my-api.com/chat -d @-
description: My custom API
```
**Python script:**
```yaml
- name: my-python
command: python3 ~/scripts/my_ai.py
description: Custom Python AI
```
+1896
View File
File diff suppressed because it is too large Load Diff
+1462
View File
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Install all SmartTools example tools.
Usage:
python3 install.py # Install all tools
python3 install.py --list # List available tools
python3 install.py summarize # Install specific tool
python3 install.py --category dev # Install category
Run from anywhere:
curl -sSL https://gitea.brrd.tech/rob/smarttools/raw/branch/main/examples/install.py | python3
"""
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("PyYAML required. Install with: pip install pyyaml")
sys.exit(1)
TOOLS_DIR = Path.home() / ".smarttools"
# All example tool configurations
TOOLS = {
# TEXT PROCESSING
"summarize": {
"category": "text",
"description": "Condense long documents to key points",
"arguments": [{"flag": "--length", "variable": "length", "default": "3-5 bullet points"}],
"steps": [{"type": "prompt", "prompt": "Summarize the following text into {length}. Be concise and capture the key points:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"translate": {
"category": "text",
"description": "Translate text to any language",
"arguments": [{"flag": "--lang", "variable": "lang", "default": "Spanish"}],
"steps": [{"type": "prompt", "prompt": "Translate the following text to {lang}. Only output the translation, nothing else:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"fix-grammar": {
"category": "text",
"description": "Fix grammar and spelling errors",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Fix all grammar, spelling, and punctuation errors in the following text. Only output the corrected text, no explanations:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"simplify": {
"category": "text",
"description": "Rewrite text for easier understanding",
"arguments": [{"flag": "--level", "variable": "level", "default": "5th grade reading level"}],
"steps": [{"type": "prompt", "prompt": "Rewrite the following text for a {level}. Keep the meaning but use simpler words and shorter sentences:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"tone-shift": {
"category": "text",
"description": "Change the tone of text",
"arguments": [{"flag": "--tone", "variable": "tone", "default": "professional"}],
"steps": [{"type": "prompt", "prompt": "Rewrite the following text in a {tone} tone. Keep the core message but adjust the style:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"eli5": {
"category": "text",
"description": "Explain like I'm 5",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Explain this like I'm 5 years old. Use simple words and fun analogies:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"tldr": {
"category": "text",
"description": "One-line summary",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Give a one-line TL;DR summary of this text:\n\n{input}", "provider": "opencode-grok", "output_var": "response"}],
"output": "{response}"
},
"expand": {
"category": "text",
"description": "Expand bullet points to paragraphs",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Expand these bullet points into well-written paragraphs:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
# DEVELOPER TOOLS
"explain-error": {
"category": "dev",
"description": "Explain error messages and stack traces",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Explain this error/stack trace in plain English. What went wrong and how to fix it:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"explain-code": {
"category": "dev",
"description": "Explain what code does",
"arguments": [{"flag": "--detail", "variable": "detail", "default": "moderate"}],
"steps": [{"type": "prompt", "prompt": "Explain what this code does at a {detail} level of detail:\n\n```\n{input}\n```", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"review-code": {
"category": "dev",
"description": "Quick code review with suggestions",
"arguments": [{"flag": "--focus", "variable": "focus", "default": "bugs, security, and improvements"}],
"steps": [{"type": "prompt", "prompt": "Review this code focusing on {focus}. Be concise and actionable:\n\n```\n{input}\n```", "provider": "claude-sonnet", "output_var": "response"}],
"output": "{response}"
},
"gen-tests": {
"category": "dev",
"description": "Generate unit tests for code",
"arguments": [{"flag": "--framework", "variable": "framework", "default": "pytest"}],
"steps": [{"type": "prompt", "prompt": "Generate comprehensive unit tests for this code using {framework}. Include edge cases:\n\n```\n{input}\n```", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"docstring": {
"category": "dev",
"description": "Add docstrings to functions/classes",
"arguments": [{"flag": "--style", "variable": "style", "default": "Google style"}],
"steps": [{"type": "prompt", "prompt": "Add {style} docstrings to all functions and classes in this code. Output the complete code with docstrings:\n\n```\n{input}\n```", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"commit-msg": {
"category": "dev",
"description": "Generate commit message from diff",
"arguments": [{"flag": "--style", "variable": "style", "default": "conventional commits"}],
"steps": [{"type": "prompt", "prompt": "Generate a concise {style} commit message for this diff. Just the message, no explanation:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
# DATA TOOLS
"json-extract": {
"category": "data",
"description": "Extract structured data as validated JSON",
"arguments": [{"flag": "--fields", "variable": "fields", "default": "any relevant fields"}],
"steps": [
{"type": "prompt", "prompt": "Extract {fields} from this text as a JSON object. Output ONLY valid JSON, no markdown, no explanation:\n\n{input}", "provider": "opencode-deepseek", "output_var": "raw_json"},
{"type": "code", "code": "import json\nimport re\ntext = raw_json.strip()\ntext = re.sub(r'^```json?\\s*', '', text)\ntext = re.sub(r'\\s*```$', '', text)\ntry:\n parsed = json.loads(text)\n validated = json.dumps(parsed, indent=2)\nexcept json.JSONDecodeError as e:\n validated = f\"ERROR: Invalid JSON - {e}\\nRaw output: {text[:500]}\"", "output_var": "validated"}
],
"output": "{validated}"
},
"json2csv": {
"category": "data",
"description": "Convert JSON to CSV format",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Convert this JSON to CSV format. Output only the CSV, no explanation:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"extract-emails": {
"category": "data",
"description": "Extract email addresses from text",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Extract all email addresses from this text. Output one email per line, nothing else:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"extract-contacts": {
"category": "data",
"description": "Extract contact info as structured CSV",
"arguments": [],
"steps": [
{"type": "prompt", "prompt": "Extract all contact information (name, email, phone, company) from this text. Output as JSON array with objects having keys: name, email, phone, company. Use null for missing fields. Output ONLY the JSON array:\n\n{input}", "provider": "opencode-pickle", "output_var": "contacts_json"},
{"type": "code", "code": "import json\nimport re\ntext = contacts_json.strip()\ntext = re.sub(r'^```json?\\s*', '', text)\ntext = re.sub(r'\\s*```$', '', text)\ntry:\n contacts = json.loads(text)\n lines = ['name,email,phone,company']\n for c in contacts:\n row = [str(c.get('name') or ''), str(c.get('email') or ''), str(c.get('phone') or ''), str(c.get('company') or '')]\n lines.append(','.join(f'\"{f}\"' for f in row))\n csv_output = '\\n'.join(lines)\nexcept:\n csv_output = f\"Error parsing contacts: {text[:200]}\"", "output_var": "csv_output"}
],
"output": "{csv_output}"
},
"sql-from-text": {
"category": "data",
"description": "Generate SQL from natural language",
"arguments": [{"flag": "--dialect", "variable": "dialect", "default": "PostgreSQL"}],
"steps": [{"type": "prompt", "prompt": "Generate a {dialect} SQL query for this request. Output only the SQL, no explanation:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"safe-sql": {
"category": "data",
"description": "Generate SQL with safety checks",
"arguments": [{"flag": "--dialect", "variable": "dialect", "default": "PostgreSQL"}],
"steps": [
{"type": "prompt", "prompt": "Generate a {dialect} SELECT query for: {input}\n\nOutput ONLY the SQL query, no explanation.", "provider": "claude-haiku", "output_var": "raw_sql"},
{"type": "code", "code": "import re\nsql = raw_sql.strip()\nsql = re.sub(r'^```sql?\\s*', '', sql)\nsql = re.sub(r'\\s*```$', '', sql)\ndangerous = ['DROP', 'DELETE', 'TRUNCATE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT']\nwarnings = []\nfor keyword in dangerous:\n if re.search(r'\\b' + keyword + r'\\b', sql, re.I):\n warnings.append(f\"WARNING: Contains {keyword}\")\nif warnings:\n validated = '\\n'.join(warnings) + '\\n\\n' + sql\nelse:\n validated = sql", "output_var": "validated"}
],
"output": "{validated}"
},
"parse-log": {
"category": "data",
"description": "Summarize and analyze log files",
"arguments": [{"flag": "--focus", "variable": "focus", "default": "errors and warnings"}],
"steps": [{"type": "prompt", "prompt": "Analyze these logs focusing on {focus}. Summarize key issues and patterns:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"csv-insights": {
"category": "data",
"description": "Analyze CSV with sampled data for large files",
"arguments": [{"flag": "--question", "variable": "question", "default": "What patterns and insights can you find?"}],
"steps": [
{"type": "code", "code": "import random\nlines = input.strip().split('\\n')\nheader = lines[0] if lines else ''\ndata_lines = lines[1:] if len(lines) > 1 else []\nif len(data_lines) > 50:\n sampled = random.sample(data_lines, 50)\n sample_note = f\"(Sampled 50 of {len(data_lines)} rows)\"\nelse:\n sampled = data_lines\n sample_note = f\"({len(data_lines)} rows)\"\nsampled_csv = header + '\\n' + '\\n'.join(sampled)\nstats = f\"Columns: {len(header.split(','))}, Rows: {len(data_lines)} {sample_note}\"", "output_var": "sampled_csv,stats"},
{"type": "prompt", "prompt": "Analyze this CSV data. {stats}\n\nQuestion: {question}\n\nData:\n{sampled_csv}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
# ADVANCED TOOLS
"log-errors": {
"category": "advanced",
"description": "Extract and explain errors from large log files",
"arguments": [],
"steps": [
{"type": "code", "code": "import re\nlines = input.split('\\n')\nerrors = [l for l in lines if re.search(r'\\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\\b', l, re.I)]\nresult = []\nfor i, line in enumerate(lines):\n if re.search(r'\\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\\b', line, re.I):\n result.extend(lines[i:i+5])\nextracted = '\\n'.join(result[:200])", "output_var": "extracted"},
{"type": "prompt", "prompt": "Analyze these error log entries. Group by error type, explain likely causes, and suggest fixes:\n\n{extracted}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
"diff-focus": {
"category": "advanced",
"description": "Review only the added/changed code in a diff",
"arguments": [],
"steps": [
{"type": "code", "code": "lines = input.split('\\n')\nresult = []\nfor i, line in enumerate(lines):\n if line.startswith('@@') or line.startswith('+++') or line.startswith('---'):\n result.append(line)\n elif line.startswith('+') and not line.startswith('+++'):\n result.append(line)\nextracted = '\\n'.join(result)", "output_var": "extracted"},
{"type": "prompt", "prompt": "Review these added lines of code. Focus on bugs, security issues, and improvements:\n\n{extracted}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
"changelog": {
"category": "advanced",
"description": "Generate changelog from git commits",
"arguments": [{"flag": "--since", "variable": "since", "default": "last week"}],
"steps": [
{"type": "code", "code": "lines = input.strip().split('\\n')\ncommits = []\nfor line in lines:\n if line.strip() and not line.startswith('commit '):\n commits.append(line.strip())\ngit_log = '\\n'.join(commits[:100])", "output_var": "git_log"},
{"type": "prompt", "prompt": "Generate a user-friendly changelog from these git commits. Group by category (Features, Fixes, Improvements, etc). Use markdown with bullet points:\n\n{git_log}", "provider": "opencode-pickle", "output_var": "changelog_raw"},
{"type": "code", "code": "from datetime import datetime\nheader = f\"\"\"# Changelog\\n\\nGenerated: {datetime.now().strftime('%Y-%m-%d')}\\n\\n\"\"\"\nformatted = header + changelog_raw", "output_var": "formatted"}
],
"output": "{formatted}"
},
"code-validate": {
"category": "advanced",
"description": "Generate and validate Python code",
"arguments": [{"flag": "--task", "variable": "task", "default": ""}],
"steps": [
{"type": "prompt", "prompt": "Write Python code to: {task}\n\nContext/input data:\n{input}\n\nOutput ONLY the Python code, no markdown, no explanation.", "provider": "claude-haiku", "output_var": "raw_code"},
{"type": "code", "code": "import ast\nimport re\ncode = raw_code.strip()\ncode = re.sub(r'^```python?\\s*', '', code)\ncode = re.sub(r'\\s*```$', '', code)\ntry:\n ast.parse(code)\n validated = code\nexcept SyntaxError as e:\n validated = f\"# SYNTAX ERROR at line {e.lineno}: {e.msg}\\n# Fix needed:\\n\\n{code}\"", "output_var": "validated"}
],
"output": "{validated}"
},
"reply-email": {
"category": "text",
"description": "Draft email replies",
"arguments": [{"flag": "--tone", "variable": "tone", "default": "professional and friendly"}],
"steps": [{"type": "prompt", "prompt": "Draft a {tone} reply to this email:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
}
CATEGORIES = {
"text": "Text Processing",
"dev": "Developer Tools",
"data": "Data Tools",
"advanced": "Advanced Multi-Step Tools",
}
def install_tool(name: str, config: dict, force: bool = False) -> bool:
"""Install a single tool."""
tool_dir = TOOLS_DIR / name
config_file = tool_dir / "config.yaml"
if config_file.exists() and not force:
print(f" {name}: already exists (use --force to overwrite)")
return False
tool_dir.mkdir(parents=True, exist_ok=True)
# Build config without category
tool_config = {
"name": name,
"description": config["description"],
"arguments": config.get("arguments", []),
"steps": config["steps"],
"output": config["output"]
}
with open(config_file, 'w') as f:
yaml.dump(tool_config, f, default_flow_style=False, sort_keys=False)
print(f" {name}: installed")
return True
def list_tools():
"""List all available tools."""
print("Available example tools:\n")
for cat_id, cat_name in CATEGORIES.items():
tools_in_cat = [(n, c) for n, c in TOOLS.items() if c.get("category") == cat_id]
if tools_in_cat:
print(f"{cat_name}:")
for name, config in tools_in_cat:
print(f" {name:<20} {config['description']}")
print()
def main():
parser = argparse.ArgumentParser(description="Install SmartTools example tools")
parser.add_argument("tools", nargs="*", help="Specific tools to install (default: all)")
parser.add_argument("--list", action="store_true", help="List available tools")
parser.add_argument("--category", "-c", choices=list(CATEGORIES.keys()), help="Install tools from category")
parser.add_argument("--force", "-f", action="store_true", help="Overwrite existing tools")
args = parser.parse_args()
if args.list:
list_tools()
return
# Determine which tools to install
if args.tools:
to_install = {n: c for n, c in TOOLS.items() if n in args.tools}
unknown = set(args.tools) - set(TOOLS.keys())
if unknown:
print(f"Unknown tools: {', '.join(unknown)}")
print("Use --list to see available tools")
return
elif args.category:
to_install = {n: c for n, c in TOOLS.items() if c.get("category") == args.category}
else:
to_install = TOOLS
if not to_install:
print("No tools to install")
return
print(f"Installing {len(to_install)} tools to {TOOLS_DIR}...\n")
installed = 0
for name, config in to_install.items():
if install_tool(name, config, args.force):
installed += 1
print(f"\nInstalled {installed} tools.")
print("\nRun 'smarttools refresh' to create executable wrappers.")
if __name__ == "__main__":
main()
-55
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
View File
-55
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1015
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"name": "smarttools-web",
"private": true,
"scripts": {
"css:build": "tailwindcss -i src/smarttools/web/static/src/input.css -o src/smarttools/web/static/css/main.css --minify",
"css:watch": "tailwindcss -i src/smarttools/web/static/src/input.css -o src/smarttools/web/static/css/main.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.19"
}
}
+73
View File
@@ -0,0 +1,73 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "smarttools"
version = "0.1.0"
description = "A lightweight personal tool builder for AI-powered CLI commands"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Rob"}
]
keywords = ["ai", "cli", "tools", "automation", "llm", "terminal", "unix", "pipe"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Text Processing",
]
dependencies = [
"PyYAML>=6.0",
"requests>=2.28",
]
[project.optional-dependencies]
tui = [
"urwid>=2.1.0",
]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"urwid>=2.1.0",
]
registry = [
"Flask>=2.3",
"argon2-cffi>=21.0",
]
all = [
"urwid>=2.1.0",
"Flask>=2.3",
"argon2-cffi>=21.0",
]
[project.scripts]
smarttools = "smarttools.cli:main"
[project.urls]
Homepage = "https://gitea.brrd.tech/rob/smarttools"
Documentation = "https://gitea.brrd.tech/rob/smarttools#readme"
Repository = "https://gitea.brrd.tech/rob/smarttools.git"
Issues = "https://gitea.brrd.tech/rob/smarttools/issues"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"integration: marks tests as integration tests (require running server)",
]
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Sync registry tools into the database.
Usage: python scripts/sync_to_db.py /path/to/registry/repo
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Dict, Any
import yaml
# Allow running from repo root
sys.path.append(str(Path(__file__).resolve().parents[1] / "src"))
from smarttools.registry.db import connect_db, query_one
from smarttools.registry.sync import ensure_publisher, normalize_tags
def load_yaml(path: Path) -> Dict[str, Any]:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def sync_repo(repo_root: Path) -> int:
tools_root = repo_root / "tools"
if not tools_root.exists():
print(f"Missing tools directory at {tools_root}")
return 1
conn = connect_db()
synced = 0
skipped = 0
try:
for config_path in tools_root.glob("*/*/config.yaml"):
owner = config_path.parent.parent.name
name = config_path.parent.name
config_text = config_path.read_text(encoding="utf-8")
data = load_yaml(config_path)
version = (data.get("version") or "").strip()
if not version:
skipped += 1
continue
existing = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
if existing:
skipped += 1
continue
readme_path = config_path.parent / "README.md"
readme_text = readme_path.read_text(encoding="utf-8") if readme_path.exists() else ""
publisher_id = ensure_publisher(conn, owner)
tags = normalize_tags(data.get("tags"))
conn.execute(
"""
INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
name,
version,
data.get("description"),
data.get("category"),
tags,
config_text,
readme_text,
publisher_id,
int(bool(data.get("deprecated"))),
data.get("deprecated_message"),
data.get("replacement"),
int((data.get("registry") or {}).get("downloads", 0) or 0),
(data.get("registry") or {}).get("published_at"),
],
)
synced += 1
conn.commit()
finally:
conn.close()
print(f"Synced: {synced}")
print(f"Skipped (existing/invalid): {skipped}")
return 0
def main() -> int:
if len(sys.argv) < 2:
print("Usage: python scripts/sync_to_db.py /path/to/registry/repo")
return 1
repo_root = Path(sys.argv[1])
return sync_repo(repo_root)
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Validate a registry tool submission.
Usage: python scripts/validate_tool.py path/to/tool
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import List
import yaml
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+.+)?$")
REQUIRED_README_SECTIONS = ["## Usage", "## Examples"]
def find_repo_root(start: Path) -> Path | None:
current = start.resolve()
while current != current.parent:
if (current / "categories" / "categories.yaml").exists():
return current
current = current.parent
if (current / "categories" / "categories.yaml").exists():
return current
return None
def load_categories(repo_root: Path) -> List[str]:
categories_path = repo_root / "categories" / "categories.yaml"
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
categories = data.get("categories", [])
return [c.get("name") for c in categories if c.get("name")]
def validate_tool(tool_path: Path) -> List[str]:
errors: List[str] = []
if tool_path.is_dir():
config_path = tool_path / "config.yaml"
readme_path = tool_path / "README.md"
else:
config_path = tool_path
readme_path = tool_path.parent / "README.md"
if not config_path.exists():
return [f"Missing config.yaml at {config_path}"]
try:
config_text = config_path.read_text(encoding="utf-8")
data = yaml.safe_load(config_text) or {}
except Exception as exc:
return [f"Invalid YAML in config.yaml: {exc}"]
name = (data.get("name") or "").strip()
version = (data.get("version") or "").strip()
description = (data.get("description") or "").strip()
category = (data.get("category") or "").strip()
if not name:
errors.append("Missing required field: name")
elif not TOOL_NAME_RE.match(name):
errors.append("Tool name must match ^[A-Za-z0-9-]{1,64}$")
if not version:
errors.append("Missing required field: version")
elif not SEMVER_RE.match(version):
errors.append("Version must be valid semver (MAJOR.MINOR.PATCH)")
if not description:
errors.append("Missing required field: description")
repo_root = find_repo_root(tool_path)
if repo_root:
categories = load_categories(repo_root)
if category and category not in categories:
errors.append(f"Unknown category '{category}' (not in categories.yaml)")
else:
if category:
errors.append("Cannot validate category (categories.yaml not found)")
if not readme_path.exists():
errors.append(f"Missing README.md at {readme_path}")
else:
readme_text = readme_path.read_text(encoding="utf-8")
for section in REQUIRED_README_SECTIONS:
if section not in readme_text:
errors.append(f"README.md missing section: {section}")
return errors
def main() -> int:
if len(sys.argv) < 2:
print("Usage: python scripts/validate_tool.py path/to/tool")
return 1
tool_path = Path(sys.argv[1])
errors = validate_tool(tool_path)
if errors:
print("Validation failed:")
for err in errors:
print(f"- {err}")
return 1
print("Validation passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://pages.brrd.tech/rob/CmdForge/architecture/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/goals/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/ideas-and-exploration/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/milestones/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
+3
View File
@@ -0,0 +1,3 @@
"""SmartTools - A lightweight personal tool builder for AI-powered CLI commands."""
__version__ = "0.1.0"
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
"""Global configuration handling for SmartTools.
Manages ~/.smarttools/config.yaml with registry settings, tokens, and preferences.
"""
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
# Default configuration directory
CONFIG_DIR = Path.home() / ".smarttools"
CONFIG_FILE = CONFIG_DIR / "config.yaml"
# Default registry URL (canonical base path)
DEFAULT_REGISTRY_URL = "https://gitea.brrd.tech/api/v1"
@dataclass
class RegistryConfig:
"""Registry-related configuration."""
url: str = DEFAULT_REGISTRY_URL
token: Optional[str] = None
def to_dict(self) -> dict:
d = {"url": self.url}
if self.token:
d["token"] = self.token
return d
@classmethod
def from_dict(cls, data: dict) -> "RegistryConfig":
return cls(
url=data.get("url", DEFAULT_REGISTRY_URL),
token=data.get("token")
)
@dataclass
class Config:
"""Global SmartTools configuration."""
registry: RegistryConfig = field(default_factory=RegistryConfig)
client_id: str = ""
auto_fetch_from_registry: bool = True
default_provider: Optional[str] = None
def __post_init__(self):
# Generate client_id if not set
if not self.client_id:
self.client_id = f"anon_{uuid.uuid4().hex[:16]}"
def to_dict(self) -> dict:
d = {
"registry": self.registry.to_dict(),
"client_id": self.client_id,
"auto_fetch_from_registry": self.auto_fetch_from_registry,
}
if self.default_provider:
d["default_provider"] = self.default_provider
return d
@classmethod
def from_dict(cls, data: dict) -> "Config":
registry_data = data.get("registry", {})
return cls(
registry=RegistryConfig.from_dict(registry_data),
client_id=data.get("client_id", ""),
auto_fetch_from_registry=data.get("auto_fetch_from_registry", True),
default_provider=data.get("default_provider")
)
def get_config_dir() -> Path:
"""Get the config directory, creating it if needed."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
return CONFIG_DIR
def load_config() -> Config:
"""Load configuration from disk, creating defaults if needed."""
config_path = get_config_dir() / "config.yaml"
if not config_path.exists():
# Create default config
config = Config()
save_config(config)
return config
try:
data = yaml.safe_load(config_path.read_text()) or {}
return Config.from_dict(data)
except Exception as e:
print(f"Warning: Error loading config, using defaults: {e}")
return Config()
def save_config(config: Config) -> Path:
"""Save configuration to disk."""
config_path = get_config_dir() / "config.yaml"
config_path.write_text(yaml.dump(config.to_dict(), default_flow_style=False, sort_keys=False))
return config_path
def get_registry_url() -> str:
"""Get the configured registry URL."""
config = load_config()
return config.registry.url
def get_registry_token() -> Optional[str]:
"""Get the configured registry token."""
config = load_config()
return config.registry.token
def set_registry_token(token: str) -> None:
"""Set and save the registry token."""
config = load_config()
config.registry.token = token
save_config(config)
def get_client_id() -> str:
"""Get the client ID for anonymous usage tracking."""
config = load_config()
return config.client_id
def is_auto_fetch_enabled() -> bool:
"""Check if auto-fetch from registry is enabled."""
config = load_config()
return config.auto_fetch_from_registry
+276
View File
@@ -0,0 +1,276 @@
"""Project manifest (smarttools.yaml) handling.
Manages project-level tool dependencies and overrides.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List, Dict
import yaml
MANIFEST_FILENAME = "smarttools.yaml"
@dataclass
class Dependency:
"""A tool dependency declaration."""
name: str # owner/name format (e.g., "rob/summarize")
version: str = "*" # Version constraint (e.g., ">=1.0.0", "^1.2.0")
@property
def owner(self) -> Optional[str]:
"""Extract owner from name if present."""
if "/" in self.name:
return self.name.split("/")[0]
return None
@property
def tool_name(self) -> str:
"""Extract tool name without owner."""
if "/" in self.name:
return self.name.split("/")[1]
return self.name
def to_dict(self) -> dict:
return {
"name": self.name,
"version": self.version
}
@classmethod
def from_dict(cls, data: dict) -> "Dependency":
if isinstance(data, str):
# Simple string format: "rob/summarize" or "rob/summarize@^1.0.0"
if "@" in data:
name, version = data.rsplit("@", 1)
return cls(name=name, version=version)
return cls(name=data)
return cls(
name=data["name"],
version=data.get("version", "*")
)
@dataclass
class ToolOverride:
"""Runtime overrides for a tool."""
provider: Optional[str] = None
# Future: other overrides like timeout, retries, etc.
def to_dict(self) -> dict:
d = {}
if self.provider:
d["provider"] = self.provider
return d
@classmethod
def from_dict(cls, data: dict) -> "ToolOverride":
return cls(
provider=data.get("provider")
)
@dataclass
class Manifest:
"""Project manifest (smarttools.yaml)."""
name: str = "my-project"
version: str = "1.0.0"
dependencies: List[Dependency] = field(default_factory=list)
overrides: Dict[str, ToolOverride] = field(default_factory=dict)
def to_dict(self) -> dict:
d = {
"name": self.name,
"version": self.version,
}
if self.dependencies:
d["dependencies"] = [dep.to_dict() for dep in self.dependencies]
if self.overrides:
d["overrides"] = {
name: override.to_dict()
for name, override in self.overrides.items()
}
return d
@classmethod
def from_dict(cls, data: dict) -> "Manifest":
dependencies = []
for dep in data.get("dependencies", []):
dependencies.append(Dependency.from_dict(dep))
overrides = {}
for name, override_data in data.get("overrides", {}).items():
overrides[name] = ToolOverride.from_dict(override_data)
return cls(
name=data.get("name", "my-project"),
version=data.get("version", "1.0.0"),
dependencies=dependencies,
overrides=overrides
)
def get_override(self, tool_name: str) -> Optional[ToolOverride]:
"""Get override for a tool by name (checks both full and short names)."""
# Try exact match first
if tool_name in self.overrides:
return self.overrides[tool_name]
# Try matching just the tool name part (without owner)
short_name = tool_name.split("/")[-1] if "/" in tool_name else tool_name
for override_name, override in self.overrides.items():
override_short = override_name.split("/")[-1] if "/" in override_name else override_name
if override_short == short_name:
return override
return None
def add_dependency(self, name: str, version: str = "*") -> None:
"""Add or update a dependency."""
# Check if already exists
for dep in self.dependencies:
if dep.name == name:
dep.version = version
return
self.dependencies.append(Dependency(name=name, version=version))
def find_manifest(start_dir: Optional[Path] = None) -> Optional[Path]:
"""
Find smarttools.yaml by searching up from start_dir.
Args:
start_dir: Directory to start searching from (default: cwd)
Returns:
Path to manifest file, or None if not found
"""
if start_dir is None:
start_dir = Path.cwd()
current = start_dir.resolve()
while current != current.parent:
manifest_path = current / MANIFEST_FILENAME
if manifest_path.exists():
return manifest_path
current = current.parent
# Check root
manifest_path = current / MANIFEST_FILENAME
if manifest_path.exists():
return manifest_path
return None
def load_manifest(path: Optional[Path] = None) -> Optional[Manifest]:
"""
Load a project manifest.
Args:
path: Path to manifest file, or None to search
Returns:
Manifest object, or None if not found
"""
if path is None:
path = find_manifest()
if path is None or not path.exists():
return None
try:
data = yaml.safe_load(path.read_text()) or {}
return Manifest.from_dict(data)
except Exception as e:
print(f"Warning: Error loading manifest: {e}")
return None
def save_manifest(manifest: Manifest, path: Optional[Path] = None) -> Path:
"""
Save a manifest to disk.
Args:
manifest: Manifest to save
path: Path to save to (default: ./smarttools.yaml)
Returns:
Path where manifest was saved
"""
if path is None:
path = Path.cwd() / MANIFEST_FILENAME
path.write_text(yaml.dump(manifest.to_dict(), default_flow_style=False, sort_keys=False))
return path
def create_manifest(
name: str = "my-project",
version: str = "1.0.0",
path: Optional[Path] = None
) -> Manifest:
"""
Create a new manifest.
Args:
name: Project name
version: Project version
path: Path to save to (optional)
Returns:
Created Manifest object
"""
manifest = Manifest(name=name, version=version)
if path is not None:
save_manifest(manifest, path)
return manifest
def parse_version_constraint(constraint: str) -> dict:
"""
Parse a version constraint string.
Args:
constraint: Version constraint (e.g., ">=1.0.0", "^1.2.3", "~1.2.0")
Returns:
Dict with operator and version parts
"""
constraint = constraint.strip()
# Exact version
if re.match(r'^\d+\.\d+\.\d+', constraint):
return {"operator": "=", "version": constraint}
# Any version
if constraint == "*" or constraint == "latest":
return {"operator": "*", "version": None}
# Range operators
patterns = [
(r'^>=(.+)$', ">="),
(r'^<=(.+)$', "<="),
(r'^>(.+)$', ">"),
(r'^<(.+)$', "<"),
(r'^\^(.+)$', "^"), # Compatible (same major)
(r'^~(.+)$', "~"), # Approximately (same minor)
]
for pattern, operator in patterns:
match = re.match(pattern, constraint)
if match:
return {"operator": operator, "version": match.group(1)}
# Default to exact match
return {"operator": "=", "version": constraint}
+266
View File
@@ -0,0 +1,266 @@
"""Provider abstraction for AI CLI tools."""
import os
import subprocess
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List
import yaml
# Default providers config location
PROVIDERS_FILE = Path.home() / ".smarttools" / "providers.yaml"
@dataclass
class Provider:
"""Definition of an AI provider."""
name: str
command: str
description: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"command": self.command,
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> "Provider":
return cls(
name=data["name"],
command=data["command"],
description=data.get("description", ""),
)
@dataclass
class ProviderResult:
"""Result from a provider call."""
text: str
success: bool
error: Optional[str] = None
# Default providers that come pre-configured
# Profiled with 4-task test: Math, Code, Reasoning, Extract (Dec 2025)
DEFAULT_PROVIDERS = [
# TOP PICKS - best value/performance ratio
Provider("opencode-deepseek", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat", "13s 4/4 | BEST VALUE, cheap, fast, accurate"),
Provider("opencode-pickle", "$HOME/.opencode/bin/opencode run --model opencode/big-pickle", "13s 4/4 | BEST FREE, accurate"),
Provider("claude-haiku", "claude -p --model haiku", "14s 4/4 | fast, accurate, best paid option"),
Provider("codex", "codex exec -", "14s 4/4 | reliable, auto-routes"),
# CLAUDE - all accurate, paid, good for code
Provider("claude", "claude -p", "18s 4/4 | auto-routes to best model"),
Provider("claude-opus", "claude -p --model opus", "18s 4/4 | highest quality, expensive"),
Provider("claude-sonnet", "claude -p --model sonnet", "21s 4/4 | balanced quality/speed"),
# OPENCODE - additional models
Provider("opencode-nano", "$HOME/.opencode/bin/opencode run --model opencode/gpt-5-nano", "24s 4/4 | GPT-5 Nano, reliable"),
Provider("opencode-reasoner", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-reasoner", "33s 4/4 | complex reasoning, cheap"),
Provider("opencode-grok", "$HOME/.opencode/bin/opencode run --model opencode/grok-code", "11s 2/4 | fastest but unreliable, FREE"),
# GEMINI - slow CLI but good for large docs (1M token context)
Provider("gemini-flash", "gemini --model gemini-2.5-flash", "28s 4/4 | use this for quick tasks"),
Provider("gemini", "gemini --model gemini-2.5-pro", "91s 3/4 | slow CLI, best for large docs/PDFs"),
# Mock for testing
Provider("mock", "mock", "Mock provider for testing"),
]
def get_providers_file() -> Path:
"""Get the providers config file, creating default if needed."""
if not PROVIDERS_FILE.exists():
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
save_providers(DEFAULT_PROVIDERS)
return PROVIDERS_FILE
def load_providers() -> List[Provider]:
"""Load all defined providers."""
providers_file = get_providers_file()
try:
data = yaml.safe_load(providers_file.read_text())
if not data or "providers" not in data:
return DEFAULT_PROVIDERS.copy()
return [Provider.from_dict(p) for p in data["providers"]]
except Exception:
return DEFAULT_PROVIDERS.copy()
def save_providers(providers: List[Provider]):
"""Save providers to config file."""
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
data = {"providers": [p.to_dict() for p in providers]}
PROVIDERS_FILE.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
def get_provider(name: str) -> Optional[Provider]:
"""Get a provider by name."""
providers = load_providers()
for p in providers:
if p.name == name:
return p
return None
def add_provider(provider: Provider) -> bool:
"""Add or update a provider."""
providers = load_providers()
# Update if exists, otherwise add
for i, p in enumerate(providers):
if p.name == provider.name:
providers[i] = provider
save_providers(providers)
return True
providers.append(provider)
save_providers(providers)
return True
def delete_provider(name: str) -> bool:
"""Delete a provider by name."""
providers = load_providers()
original_len = len(providers)
providers = [p for p in providers if p.name != name]
if len(providers) < original_len:
save_providers(providers)
return True
return False
def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> ProviderResult:
"""
Call an AI provider with the given prompt.
Args:
provider_name: Name of the provider to use
prompt: The prompt to send
timeout: Maximum execution time in seconds
Returns:
ProviderResult with the response text or error
"""
# Handle mock provider specially
if provider_name.lower() == "mock":
return mock_provider(prompt)
# Look up provider
provider = get_provider(provider_name)
if not provider:
return ProviderResult(
text="",
success=False,
error=f"Provider '{provider_name}' not found. Use 'smarttools providers' to manage providers."
)
# Parse command (expand environment variables)
cmd = os.path.expandvars(provider.command)
# Check if base command exists
base_cmd = cmd.split()[0]
# Expand ~ for the which check
base_cmd_expanded = os.path.expanduser(base_cmd)
if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded):
return ProviderResult(
text="",
success=False,
error=f"Command '{base_cmd}' not found. Is it installed and in PATH?\n\nTo install AI providers, run: smarttools providers install"
)
try:
result = subprocess.run(
cmd,
shell=True,
input=prompt,
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode != 0:
error_msg = f"Provider exited with code {result.returncode}: {result.stderr}"
if "not found" in result.stderr.lower() or "not installed" in result.stderr.lower():
error_msg += "\n\nTo install AI providers, run: smarttools providers install"
return ProviderResult(
text="",
success=False,
error=error_msg
)
# Warn if output is empty (provider ran but returned nothing)
if not result.stdout.strip():
stderr = result.stderr.strip()
# Check for OpenCode's ProviderModelNotFoundError
if "ProviderModelNotFoundError" in stderr or "ModelNotFoundError" in stderr:
# Extract provider and model info if possible
import re
provider_match = re.search(r'providerID:\s*"([^"]+)"', stderr)
model_match = re.search(r'modelID:\s*"([^"]+)"', stderr)
provider_id = provider_match.group(1) if provider_match else "unknown"
model_id = model_match.group(1) if model_match else "unknown"
return ProviderResult(
text="",
success=False,
error=f"Model '{model_id}' from provider '{provider_id}' is not available.\n\n"
f"To fix this, either:\n"
f" 1. Run 'opencode' to connect the {provider_id} provider\n"
f" 2. Use --provider to pick a different model (e.g., --provider opencode-pickle)\n"
f" 3. Run 'smarttools ui' to edit the tool's default provider"
)
stderr_hint = f" (stderr: {stderr[:200]}...)" if len(stderr) > 200 else (f" (stderr: {stderr})" if stderr else "")
return ProviderResult(
text="",
success=False,
error=f"Provider returned empty output{stderr_hint}.\n\nThis may mean the model is not available. Try a different provider or run: smarttools providers install"
)
return ProviderResult(text=result.stdout, success=True)
except subprocess.TimeoutExpired:
return ProviderResult(
text="",
success=False,
error=f"Provider timed out after {timeout} seconds"
)
except Exception as e:
return ProviderResult(
text="",
success=False,
error=f"Provider error: {str(e)}"
)
def mock_provider(prompt: str) -> ProviderResult:
"""
Return a mock response for testing.
Args:
prompt: The prompt (used for generating mock response)
Returns:
ProviderResult with mock response
"""
lines = prompt.strip().split('\n')
preview = lines[0][:50] + "..." if len(lines[0]) > 50 else lines[0]
return ProviderResult(
text=f"[MOCK RESPONSE]\n"
f"Prompt length: {len(prompt)} chars, {len(lines)} lines\n"
f"First line: {preview}\n"
f"\n"
f"This is a mock response. Use a real provider for actual output.",
success=True
)
+3
View File
@@ -0,0 +1,3 @@
"""Registry API server package."""
__all__ = ["app"]
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
"""Category suggestion helpers for registry tools."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Tuple
import yaml
def load_categories(categories_path: Path) -> List[Dict]:
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
return data.get("categories", [])
def suggest_categories(
name: str,
description: str,
tags: List[str],
categories_path: Path,
) -> List[Tuple[str, float]]:
"""Suggest categories ranked by confidence.
Uses keyword matching against name/description/tags.
Returns a list of (category_name, confidence).
"""
categories = load_categories(categories_path)
text = f"{name} {description} {' '.join(tags)}".lower()
suggestions: List[Tuple[str, float]] = []
for cat in categories:
cat_name = cat.get("name")
keywords = [str(k).lower() for k in cat.get("keywords", []) if k]
if not cat_name or not keywords:
continue
hits = sum(1 for k in keywords if k in text)
confidence = hits / max(len(keywords), 1)
if hits:
suggestions.append((cat_name, round(confidence, 3)))
suggestions.sort(key=lambda item: item[1], reverse=True)
return suggestions
+256
View File
@@ -0,0 +1,256 @@
"""SQLite storage and schema setup for the registry API."""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from typing import Iterable
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS publishers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
bio TEXT,
website TEXT,
verified BOOLEAN DEFAULT FALSE,
locked_until TIMESTAMP,
failed_login_attempts INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
token_hash TEXT NOT NULL,
name TEXT NOT NULL,
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT,
category TEXT,
tags TEXT,
config_yaml TEXT NOT NULL,
readme TEXT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
deprecated BOOLEAN DEFAULT FALSE,
deprecated_message TEXT,
replacement TEXT,
downloads INTEGER DEFAULT 0,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);
CREATE TABLE IF NOT EXISTS download_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id),
client_id TEXT NOT NULL,
downloaded_at DATE NOT NULL,
UNIQUE(tool_id, client_id, downloaded_at)
);
CREATE VIRTUAL TABLE IF NOT EXISTS tools_fts USING fts5(
name, description, tags, readme,
content='tools',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS tools_ai AFTER INSERT ON tools BEGIN
INSERT INTO tools_fts(rowid, name, description, tags, readme)
VALUES (new.id, new.name, new.description, new.tags, new.readme);
END;
CREATE TRIGGER IF NOT EXISTS tools_ad AFTER DELETE ON tools BEGIN
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
END;
CREATE TRIGGER IF NOT EXISTS tools_au AFTER UPDATE ON tools BEGIN
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
INSERT INTO tools_fts(rowid, name, description, tags, readme)
VALUES (new.id, new.name, new.description, new.tags, new.readme);
END;
CREATE TABLE IF NOT EXISTS pending_prs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
owner TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
pr_number INTEGER NOT NULL,
pr_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);
CREATE TABLE IF NOT EXISTS webhook_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
delivery_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS web_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
publisher_id INTEGER REFERENCES publishers(id),
data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name);
CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category);
CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC);
CREATE INDEX IF NOT EXISTS idx_tools_owner_name_version ON tools(owner, name, version);
CREATE INDEX IF NOT EXISTS idx_tools_sort_stable ON tools(downloads DESC, published_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_publishers_slug ON publishers(slug);
CREATE INDEX IF NOT EXISTS idx_publishers_email ON publishers(email);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_publisher ON api_tokens(publisher_id);
CREATE INDEX IF NOT EXISTS idx_web_sessions_id ON web_sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at);
-- Web UI tables (Phase 7)
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMP,
created_by INTEGER REFERENCES publishers(id),
updated_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS featured_tools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id),
placement TEXT NOT NULL DEFAULT 'homepage',
priority INTEGER DEFAULT 0,
start_at TIMESTAMP,
end_at TIMESTAMP,
status TEXT DEFAULT 'active',
created_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS featured_contributors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
bio_override TEXT,
placement TEXT NOT NULL DEFAULT 'homepage',
start_at TIMESTAMP,
end_at TIMESTAMP,
status TEXT DEFAULT 'active',
created_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id),
reporter_id INTEGER REFERENCES publishers(id),
reporter_ip TEXT,
reason TEXT NOT NULL,
details TEXT,
status TEXT DEFAULT 'pending',
resolved_by INTEGER REFERENCES publishers(id),
resolved_at TIMESTAMP,
resolution_note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT,
publisher_id INTEGER REFERENCES publishers(id),
analytics_consent BOOLEAN DEFAULT FALSE,
ads_consent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id),
UNIQUE(publisher_id)
);
CREATE TABLE IF NOT EXISTS content_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
content_type TEXT NOT NULL DEFAULT 'doc',
body TEXT,
published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMP,
created_by INTEGER REFERENCES publishers(id),
updated_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_announcements_published ON announcements(published, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_featured_tools_placement ON featured_tools(placement, status, priority DESC);
CREATE INDEX IF NOT EXISTS idx_featured_contributors_placement ON featured_contributors(placement, status);
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_content_pages_type ON content_pages(content_type, published);
"""
def get_db_path() -> Path:
default_path = Path.home() / ".smarttools" / "registry" / "registry.db"
return Path(os.environ.get("SMARTTOOLS_REGISTRY_DB", default_path))
def ensure_db_directory(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def connect_db(path: Path | None = None) -> sqlite3.Connection:
db_path = path or get_db_path()
ensure_db_directory(db_path)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
return conn
def init_db(conn: sqlite3.Connection) -> None:
conn.executescript(SCHEMA_SQL)
conn.commit()
def query_one(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
cur = conn.execute(sql, params or [])
return cur.fetchone()
def query_all(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
cur = conn.execute(sql, params or [])
return cur.fetchall()
def execute(conn: sqlite3.Connection, sql: str, params: Iterable | None = None) -> None:
conn.execute(sql, params or [])
conn.commit()
+29
View File
@@ -0,0 +1,29 @@
"""In-memory rate limiting for the registry API."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Dict, Tuple
@dataclass
class RateLimitState:
reset_at: float
count: int
class RateLimiter:
def __init__(self) -> None:
self._state: Dict[Tuple[str, str], RateLimitState] = {}
def check(self, scope_key: str, limit: int, window_seconds: int) -> Tuple[bool, RateLimitState]:
now = time.time()
key = (scope_key, str(window_seconds))
state = self._state.get(key)
if not state or now >= state.reset_at:
state = RateLimitState(reset_at=now + window_seconds, count=0)
self._state[key] = state
state.count += 1
allowed = state.count <= limit
return allowed, state
+77
View File
@@ -0,0 +1,77 @@
"""Similarity detection for registry tools."""
from __future__ import annotations
from difflib import SequenceMatcher
from typing import Dict, List, Tuple
def _tokenize(text: str) -> List[str]:
return [t for t in re_split_nonword(text.lower()) if t]
def re_split_nonword(text: str) -> List[str]:
token = ""
tokens = []
for ch in text:
if ch.isalnum():
token += ch
else:
if token:
tokens.append(token)
token = ""
if token:
tokens.append(token)
return tokens
def jaccard(a: List[str], b: List[str]) -> float:
set_a = set(a)
set_b = set(b)
if not set_a and not set_b:
return 0.0
return len(set_a & set_b) / max(len(set_a | set_b), 1)
def name_similarity(name_a: str, name_b: str) -> float:
return SequenceMatcher(None, name_a.lower(), name_b.lower()).ratio()
def description_similarity(desc_a: str, desc_b: str) -> float:
return jaccard(_tokenize(desc_a), _tokenize(desc_b))
def tags_similarity(tags_a: List[str], tags_b: List[str]) -> float:
return jaccard([t.lower() for t in tags_a], [t.lower() for t in tags_b])
def score_similarity(
candidate: Dict,
name: str,
description: str,
tags: List[str],
category: str | None,
) -> float:
name_score = name_similarity(name, candidate.get("name", ""))
desc_score = description_similarity(description, candidate.get("description", ""))
tags_score = tags_similarity(tags, candidate.get("tags", []))
category_bonus = 0.1 if category and candidate.get("category") == category else 0.0
score = (0.5 * name_score) + (0.3 * desc_score) + (0.2 * tags_score) + category_bonus
return min(score, 1.0)
def find_similar_tools(
tools: List[Dict],
name: str,
description: str,
tags: List[str],
category: str | None,
threshold: float = 0.6,
) -> List[Tuple[Dict, float]]:
results = []
for tool in tools:
score = score_similarity(tool, name, description, tags, category)
if score >= threshold:
results.append((tool, round(score, 3)))
results.sort(key=lambda item: item[1], reverse=True)
return results
+268
View File
@@ -0,0 +1,268 @@
"""Registry repository sync and webhook processing."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import shutil
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, Tuple
import yaml
from .db import connect_db, query_one
def get_repo_dir() -> Path:
default_dir = Path.home() / ".smarttools" / "registry" / "repo"
return Path(os.environ.get("SMARTTOOLS_REGISTRY_REPO_DIR", default_dir))
def get_repo_url() -> str:
return os.environ.get("SMARTTOOLS_REGISTRY_REPO_URL", "https://gitea.brrd.tech/rob/SmartTools-Registry.git")
def get_repo_branch() -> str:
return os.environ.get("SMARTTOOLS_REGISTRY_REPO_BRANCH", "main")
def get_categories_cache_path() -> Path:
return Path(os.environ.get(
"SMARTTOOLS_REGISTRY_CATEGORIES_CACHE",
Path.home() / ".smarttools" / "registry" / "categories_cache.json",
))
def verify_hmac(body: bytes, signature: str | None, secret: str) -> bool:
if not signature:
return False
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
def clone_or_update_repo(repo_dir: Path) -> None:
repo_dir.parent.mkdir(parents=True, exist_ok=True)
if not repo_dir.exists():
subprocess.run(
["git", "clone", "--depth", "1", "--branch", get_repo_branch(), get_repo_url(), str(repo_dir)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
subprocess.run(["git", "-C", str(repo_dir), "fetch", "origin"], check=True)
subprocess.run(
["git", "-C", str(repo_dir), "reset", "--hard", f"origin/{get_repo_branch()}"]
, check=True
)
def load_yaml(path: Path) -> Dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
def ensure_publisher(conn, owner: str) -> int:
row = query_one(conn, "SELECT id FROM publishers WHERE slug = ?", [owner])
if row:
return int(row["id"])
placeholder_email = f"{owner}@registry.local"
conn.execute(
"""
INSERT INTO publishers (email, password_hash, slug, display_name, verified)
VALUES (?, ?, ?, ?, ?)
""",
[placeholder_email, "", owner, owner, False],
)
return int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
def normalize_tags(tags_value: Any) -> str:
if not tags_value:
return "[]"
if isinstance(tags_value, list):
return json.dumps(tags_value)
return json.dumps([str(tags_value)])
def upsert_tool(conn, owner: str, name: str, data: Dict[str, Any], config_text: str, readme_text: str | None) -> None:
version = data.get("version")
if not version:
return
publisher_id = ensure_publisher(conn, owner)
registry_meta = data.get("registry", {}) or {}
tags = normalize_tags(data.get("tags"))
description = data.get("description")
category = data.get("category")
deprecated = bool(data.get("deprecated", False))
deprecated_message = data.get("deprecated_message")
replacement = data.get("replacement")
downloads = registry_meta.get("downloads")
published_at = registry_meta.get("published_at")
existing = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
if existing:
conn.execute(
"""
UPDATE tools
SET description = ?, category = ?, tags = ?, config_yaml = ?, readme = ?,
deprecated = ?, deprecated_message = ?, replacement = ?,
downloads = COALESCE(?, downloads), published_at = COALESCE(?, published_at)
WHERE id = ?
""",
[
description,
category,
tags,
config_text,
readme_text,
int(deprecated),
deprecated_message,
replacement,
downloads,
published_at,
existing["id"],
],
)
else:
conn.execute(
"""
INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
name,
version,
description,
category,
tags,
config_text,
readme_text,
publisher_id,
int(deprecated),
deprecated_message,
replacement,
downloads or 0,
published_at,
],
)
def sync_categories(repo_dir: Path) -> None:
categories_path = repo_dir / "categories" / "categories.yaml"
if not categories_path.exists():
return
payload = load_yaml(categories_path)
cache_path = get_categories_cache_path()
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def sync_from_repo() -> Tuple[bool, str]:
repo_dir = get_repo_dir()
clone_or_update_repo(repo_dir)
tools_root = repo_dir / "tools"
if not tools_root.exists():
return False, "missing_tools_dir"
conn = connect_db()
try:
conn.execute("BEGIN")
for config_path in tools_root.glob("*/*/config.yaml"):
owner = config_path.parent.parent.name
name = config_path.parent.name
try:
config_text = config_path.read_text(encoding="utf-8")
data = yaml.safe_load(config_text) or {}
readme_path = config_path.parent / "README.md"
readme_text = readme_path.read_text(encoding="utf-8") if readme_path.exists() else None
upsert_tool(conn, owner, name, data, config_text, readme_text)
except Exception:
continue
conn.commit()
finally:
conn.close()
sync_categories(repo_dir)
return True, "ok"
def record_webhook_delivery(conn, delivery_id: str, event_type: str) -> None:
conn.execute(
"INSERT INTO webhook_log (delivery_id, event_type, processed_at) VALUES (?, ?, ?)",
[delivery_id, event_type, datetime.utcnow().isoformat()],
)
def is_delivery_processed(conn, delivery_id: str) -> bool:
row = query_one(conn, "SELECT 1 FROM webhook_log WHERE delivery_id = ?", [delivery_id])
return bool(row)
def acquire_lock(lock_path: Path, timeout: int) -> bool:
lock_path.parent.mkdir(parents=True, exist_ok=True)
start = time.time()
while time.time() - start < timeout:
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
return True
except FileExistsError:
time.sleep(0.1)
return False
def release_lock(lock_path: Path) -> None:
if lock_path.exists():
lock_path.unlink()
def process_webhook(body: bytes, headers: Dict[str, str], secret: str, timeout: int = 300) -> Tuple[int, Dict[str, Any]]:
delivery_id = headers.get("X-Gitea-Delivery")
signature = headers.get("X-Gitea-Signature")
event_type = headers.get("X-Gitea-Event", "unknown")
if not delivery_id:
return 400, {"error": {"code": "VALIDATION_ERROR", "message": "Missing X-Gitea-Delivery"}}
if not verify_hmac(body, signature, secret):
return 401, {"error": {"code": "UNAUTHORIZED", "message": "Invalid webhook signature"}}
conn = connect_db()
try:
if is_delivery_processed(conn, delivery_id):
return 200, {"data": {"status": "already_processed"}}
lock_path = Path.home() / ".smarttools" / "registry" / "locks" / "webhook.lock"
if not acquire_lock(lock_path, timeout):
return 200, {"data": {"status": "skipped", "reason": "sync_in_progress"}}
try:
if is_delivery_processed(conn, delivery_id):
return 200, {"data": {"status": "already_processed"}}
ok, reason = sync_from_repo()
if ok:
record_webhook_delivery(conn, delivery_id, event_type)
conn.commit()
return 200, {"data": {"status": "processed"}}
return 500, {"error": {"code": "SERVER_ERROR", "message": f"Sync failed: {reason}"}}
finally:
release_lock(lock_path)
finally:
conn.close()
+732
View File
@@ -0,0 +1,732 @@
"""Registry API client for SmartTools.
Handles all HTTP communication with the registry server.
"""
import hashlib
import json
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, List, Dict, Any
from urllib.parse import urljoin, urlencode
import requests
from .config import (
load_config,
get_registry_url,
get_registry_token,
get_client_id,
CONFIG_DIR
)
# Local cache directory
CACHE_DIR = CONFIG_DIR / "registry"
INDEX_CACHE_FILE = CACHE_DIR / "index.json"
INDEX_CACHE_MAX_AGE = timedelta(hours=24)
@dataclass
class RegistryError(Exception):
"""Base exception for registry errors."""
code: str
message: str
details: Optional[Dict] = None
http_status: int = 0
def __str__(self):
return f"{self.code}: {self.message}"
@dataclass
class RateLimitError(RegistryError):
"""Raised when rate limited by the registry."""
retry_after: int = 60
def __init__(self, retry_after: int = 60):
super().__init__(
code="RATE_LIMITED",
message=f"Rate limited. Retry after {retry_after} seconds.",
http_status=429
)
self.retry_after = retry_after
@dataclass
class PaginatedResponse:
"""Paginated API response."""
data: List[Dict]
page: int = 1
per_page: int = 20
total: int = 0
total_pages: int = 0
@dataclass
class ToolInfo:
"""Tool information from the registry."""
owner: str
name: str
version: str
description: str = ""
category: str = ""
tags: List[str] = field(default_factory=list)
downloads: int = 0
deprecated: bool = False
deprecated_message: str = ""
replacement: str = ""
published_at: str = ""
readme: str = ""
@property
def full_name(self) -> str:
return f"{self.owner}/{self.name}"
@classmethod
def from_dict(cls, data: dict) -> "ToolInfo":
return cls(
owner=data.get("owner", ""),
name=data.get("name", ""),
version=data.get("version", ""),
description=data.get("description", ""),
category=data.get("category", ""),
tags=data.get("tags", []),
downloads=data.get("downloads", 0),
deprecated=data.get("deprecated", False),
deprecated_message=data.get("deprecated_message", ""),
replacement=data.get("replacement", ""),
published_at=data.get("published_at", ""),
readme=data.get("readme", "")
)
@dataclass
class DownloadResult:
"""Result of downloading a tool."""
owner: str
name: str
resolved_version: str
config_yaml: str
readme: str = ""
class RegistryClient:
"""Client for interacting with the SmartTools registry API."""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
timeout: int = 30,
max_retries: int = 3
):
"""
Initialize the registry client.
Args:
base_url: Registry API base URL (default: from config)
token: Auth token for authenticated requests (default: from config)
timeout: Request timeout in seconds
max_retries: Maximum number of retries for failed requests
"""
self.base_url = base_url or get_registry_url()
self.token = token or get_registry_token()
self.timeout = timeout
self.max_retries = max_retries
self.client_id = get_client_id()
# Session for connection pooling
self._session = requests.Session()
self._session.headers.update({
"User-Agent": "SmartTools-CLI/1.0",
"X-SmartTools-Client": "cli/1.0.0",
"Accept": "application/json"
})
# Add client ID header
if self.client_id:
self._session.headers["X-Client-ID"] = self.client_id
def _url(self, path: str) -> str:
"""Build full URL from path."""
# Ensure base_url ends without /api/v1 duplication
base = self.base_url.rstrip("/")
if not path.startswith("/"):
path = "/" + path
return base + path
def _auth_headers(self) -> Dict[str, str]:
"""Get authentication headers if token is available."""
if self.token:
return {"Authorization": f"Bearer {self.token}"}
return {}
def _request(
self,
method: str,
path: str,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None,
require_auth: bool = False,
etag: Optional[str] = None
) -> requests.Response:
"""
Make an HTTP request with retry logic.
Args:
method: HTTP method
path: API path
params: Query parameters
json_data: JSON body data
require_auth: Whether auth is required
etag: ETag for conditional requests
Returns:
Response object
Raises:
RegistryError: On API errors
RateLimitError: When rate limited
"""
url = self._url(path)
headers = {}
if require_auth:
if not self.token:
raise RegistryError(
code="UNAUTHORIZED",
message="Authentication required. Set registry token with 'smarttools config set-token'",
http_status=401
)
headers.update(self._auth_headers())
if etag:
headers["If-None-Match"] = etag
last_error = None
for attempt in range(self.max_retries):
try:
response = self._session.request(
method=method,
url=url,
params=params,
json=json_data,
headers=headers,
timeout=self.timeout
)
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
if attempt < self.max_retries - 1:
time.sleep(min(retry_after, 30)) # Cap wait at 30s per attempt
continue
raise RateLimitError(retry_after=retry_after)
# Handle server errors with retry
if response.status_code >= 500:
if attempt < self.max_retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
return response
except requests.exceptions.Timeout:
last_error = RegistryError(
code="TIMEOUT",
message="Request timed out"
)
if attempt < self.max_retries - 1:
time.sleep(2 ** attempt)
continue
except requests.exceptions.ConnectionError:
last_error = RegistryError(
code="CONNECTION_ERROR",
message="Could not connect to registry"
)
if attempt < self.max_retries - 1:
time.sleep(2 ** attempt)
continue
raise last_error or RegistryError(
code="REQUEST_FAILED",
message="Request failed after retries"
)
def _handle_error_response(self, response: requests.Response) -> None:
"""Parse and raise appropriate error from error response."""
try:
data = response.json()
error = data.get("error", {})
raise RegistryError(
code=error.get("code", "UNKNOWN_ERROR"),
message=error.get("message", "Unknown error"),
details=error.get("details"),
http_status=response.status_code
)
except (json.JSONDecodeError, KeyError):
raise RegistryError(
code="UNKNOWN_ERROR",
message=f"HTTP {response.status_code}: {response.text[:200]}",
http_status=response.status_code
)
# -------------------------------------------------------------------------
# Public API Methods
# -------------------------------------------------------------------------
def list_tools(
self,
category: Optional[str] = None,
page: int = 1,
per_page: int = 20,
sort: str = "downloads",
order: str = "desc"
) -> PaginatedResponse:
"""
List tools from the registry.
Args:
category: Filter by category
page: Page number (1-indexed)
per_page: Items per page (max 100)
sort: Sort field (downloads, published_at, name)
order: Sort order (asc, desc)
Returns:
PaginatedResponse with tool data
"""
params = {
"page": page,
"per_page": min(per_page, 100),
"sort": sort,
"order": order
}
if category:
params["category"] = category
response = self._request("GET", "/tools", params=params)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json()
meta = data.get("meta", {})
return PaginatedResponse(
data=data.get("data", []),
page=meta.get("page", page),
per_page=meta.get("per_page", per_page),
total=meta.get("total", 0),
total_pages=meta.get("total_pages", 0)
)
def search_tools(
self,
query: str,
category: Optional[str] = None,
page: int = 1,
per_page: int = 20,
sort: str = "relevance"
) -> PaginatedResponse:
"""
Search for tools in the registry.
Args:
query: Search query
category: Filter by category
page: Page number
per_page: Items per page
sort: Sort field (relevance, downloads, published_at)
Returns:
PaginatedResponse with matching tools
"""
params = {
"q": query,
"page": page,
"per_page": min(per_page, 100),
"sort": sort
}
if category:
params["category"] = category
response = self._request("GET", "/tools/search", params=params)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json()
meta = data.get("meta", {})
return PaginatedResponse(
data=data.get("data", []),
page=meta.get("page", page),
per_page=meta.get("per_page", per_page),
total=meta.get("total", 0),
total_pages=meta.get("total_pages", 0)
)
def get_tool(self, owner: str, name: str) -> ToolInfo:
"""
Get detailed information about a tool.
Args:
owner: Tool owner (namespace)
name: Tool name
Returns:
ToolInfo object
"""
response = self._request("GET", f"/tools/{owner}/{name}")
if response.status_code == 404:
raise RegistryError(
code="TOOL_NOT_FOUND",
message=f"Tool '{owner}/{name}' not found",
http_status=404
)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json().get("data", {})
return ToolInfo.from_dict(data)
def get_tool_versions(self, owner: str, name: str) -> List[str]:
"""
Get all versions of a tool.
Args:
owner: Tool owner
name: Tool name
Returns:
List of version strings (sorted newest first)
"""
response = self._request("GET", f"/tools/{owner}/{name}/versions")
if response.status_code == 404:
raise RegistryError(
code="TOOL_NOT_FOUND",
message=f"Tool '{owner}/{name}' not found",
http_status=404
)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json()
return data.get("data", {}).get("versions", [])
def download_tool(
self,
owner: str,
name: str,
version: Optional[str] = None,
install: bool = True
) -> DownloadResult:
"""
Download a tool's configuration.
Args:
owner: Tool owner
name: Tool name
version: Version or constraint (default: latest)
install: Whether to count as install for stats
Returns:
DownloadResult with config YAML
"""
params = {"install": str(install).lower()}
if version:
params["version"] = version
response = self._request(
"GET",
f"/tools/{owner}/{name}/download",
params=params
)
if response.status_code == 404:
error_data = {}
try:
error_data = response.json().get("error", {})
except json.JSONDecodeError:
pass
code = error_data.get("code", "TOOL_NOT_FOUND")
message = error_data.get("message", f"Tool '{owner}/{name}' not found")
raise RegistryError(
code=code,
message=message,
details=error_data.get("details"),
http_status=404
)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json().get("data", {})
return DownloadResult(
owner=data.get("owner", owner),
name=data.get("name", name),
resolved_version=data.get("resolved_version", ""),
config_yaml=data.get("config", ""),
readme=data.get("readme", "")
)
def get_categories(self) -> List[Dict[str, Any]]:
"""
Get list of tool categories.
Returns:
List of category dicts with name, description, icon
"""
response = self._request("GET", "/categories")
if response.status_code != 200:
self._handle_error_response(response)
return response.json().get("data", [])
def publish_tool(
self,
config_yaml: str,
readme: str = "",
dry_run: bool = False
) -> Dict[str, Any]:
"""
Publish a tool to the registry.
Args:
config_yaml: Tool configuration YAML content
readme: README.md content
dry_run: If True, validate without publishing
Returns:
Dict with PR URL or validation results
"""
payload = {
"config": config_yaml,
"readme": readme,
"dry_run": dry_run
}
response = self._request(
"POST",
"/tools",
json_data=payload,
require_auth=True
)
if response.status_code == 409:
# Version already exists
self._handle_error_response(response)
if response.status_code not in (200, 201):
self._handle_error_response(response)
return response.json().get("data", {})
def get_my_tools(self) -> List[ToolInfo]:
"""
Get tools published by the authenticated user.
Returns:
List of ToolInfo objects
"""
response = self._request("GET", "/me/tools", require_auth=True)
if response.status_code != 200:
self._handle_error_response(response)
tools = response.json().get("data", [])
return [ToolInfo.from_dict(t) for t in tools]
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
"""
Get most popular tools.
Args:
limit: Maximum number of tools to return
Returns:
List of ToolInfo objects
"""
response = self._request(
"GET",
"/stats/popular",
params={"limit": limit}
)
if response.status_code != 200:
self._handle_error_response(response)
tools = response.json().get("data", [])
return [ToolInfo.from_dict(t) for t in tools]
# -------------------------------------------------------------------------
# Index Caching
# -------------------------------------------------------------------------
def get_index(self, force_refresh: bool = False) -> Dict[str, Any]:
"""
Get the full tool index, using cache when possible.
Args:
force_refresh: Force refresh from server
Returns:
Index dict with tools list
"""
# Check cache first
if not force_refresh:
cached = self._load_cached_index()
if cached:
return cached
# Fetch from server
etag = self._get_cached_etag()
response = self._request("GET", "/index.json", etag=etag)
if response.status_code == 304:
# Not modified, use cache
cached = self._load_cached_index()
if cached:
return cached
if response.status_code != 200:
# Try to use stale cache on error
cached = self._load_cached_index(ignore_age=True)
if cached:
return cached
self._handle_error_response(response)
data = response.json()
# Cache the response
new_etag = response.headers.get("ETag")
self._save_cached_index(data, new_etag)
return data
def _load_cached_index(self, ignore_age: bool = False) -> Optional[Dict]:
"""Load cached index if valid."""
if not INDEX_CACHE_FILE.exists():
return None
try:
cache_data = json.loads(INDEX_CACHE_FILE.read_text())
# Check age
if not ignore_age:
cached_at = datetime.fromisoformat(cache_data.get("_cached_at", ""))
if datetime.now() - cached_at > INDEX_CACHE_MAX_AGE:
return None
# Verify checksum
if not self._verify_index_checksum(cache_data):
return None
return cache_data
except (json.JSONDecodeError, KeyError, ValueError):
return None
def _save_cached_index(self, data: Dict, etag: Optional[str] = None) -> None:
"""Save index to cache."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
data["_cached_at"] = datetime.now().isoformat()
if etag:
data["_etag"] = etag
INDEX_CACHE_FILE.write_text(json.dumps(data, indent=2))
def _get_cached_etag(self) -> Optional[str]:
"""Get ETag from cached index."""
if not INDEX_CACHE_FILE.exists():
return None
try:
cache_data = json.loads(INDEX_CACHE_FILE.read_text())
return cache_data.get("_etag")
except (json.JSONDecodeError, KeyError):
return None
def _verify_index_checksum(self, data: Dict) -> bool:
"""Verify cached index integrity."""
checksum = data.get("checksum", "")
if not checksum:
return True # No checksum to verify
# Compute checksum of tools list
tools = data.get("tools", [])
content = json.dumps(tools, sort_keys=True)
computed = "sha256:" + hashlib.sha256(content.encode()).hexdigest()
return computed == checksum
def clear_cache(self) -> None:
"""Clear the local index cache."""
if INDEX_CACHE_FILE.exists():
INDEX_CACHE_FILE.unlink()
# -------------------------------------------------------------------------
# Convenience functions
# -------------------------------------------------------------------------
def get_client() -> RegistryClient:
"""Get a configured registry client instance."""
return RegistryClient()
def search(query: str, **kwargs) -> PaginatedResponse:
"""Search the registry for tools."""
return get_client().search_tools(query, **kwargs)
def install_tool(tool_spec: str, version: Optional[str] = None) -> DownloadResult:
"""
Download a tool for installation.
Args:
tool_spec: Tool specification (owner/name or just name)
version: Version constraint
Returns:
DownloadResult with config YAML
"""
client = get_client()
# Parse tool spec
if "/" in tool_spec:
owner, name = tool_spec.split("/", 1)
else:
# Shorthand - try official namespace first
owner = "official"
name = tool_spec
try:
return client.download_tool(owner, name, version=version, install=True)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND" and owner == "official":
# Fall back to searching for most popular tool with this name
results = client.search_tools(name, per_page=1)
if results.data:
first = results.data[0]
return client.download_tool(
first["owner"],
first["name"],
version=version,
install=True
)
raise
+668
View File
@@ -0,0 +1,668 @@
"""Tool resolution with proper search order.
Implements the tool resolution order:
1. Local project: ./.smarttools/<owner>/<name>/config.yaml
2. Global user: ~/.smarttools/<owner>/<name>/config.yaml
3. Registry: Fetch from API, install to global, then run
4. Error if not found
"""
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
import yaml
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
# Local project tools directories (support both legacy and documented paths)
LOCAL_TOOLS_DIRS = [Path(".smarttools"), Path("smarttools")]
@dataclass
class ToolSpec:
"""Parsed tool specification."""
owner: Optional[str] # None for unqualified names like "summarize"
name: str
version: Optional[str] = None # Version constraint
@property
def full_name(self) -> str:
"""Get full owner/name format."""
if self.owner:
return f"{self.owner}/{self.name}"
return self.name
@property
def is_qualified(self) -> bool:
"""Check if this is a fully qualified name (owner/name)."""
return self.owner is not None
@classmethod
def parse(cls, spec: str) -> "ToolSpec":
"""
Parse a tool specification string.
Formats:
- "summarize" -> owner=None, name="summarize"
- "rob/summarize" -> owner="rob", name="summarize"
- "[email protected]" -> name="summarize", version="1.0.0"
- "rob/summarize@^1.0.0" -> owner="rob", name="summarize", version="^1.0.0"
"""
version = None
# Extract version if present
if "@" in spec:
spec, version = spec.rsplit("@", 1)
# Extract owner if present
if "/" in spec:
owner, name = spec.split("/", 1)
else:
owner = None
name = spec
return cls(owner=owner, name=name, version=version)
@dataclass
class ResolvedTool:
"""Result of tool resolution."""
tool: Tool
source: str # "local", "global", "registry"
path: Path
owner: Optional[str] = None
version: Optional[str] = None
@property
def full_name(self) -> str:
if self.owner:
return f"{self.owner}/{self.tool.name}"
return self.tool.name
class ToolNotFoundError(Exception):
"""Raised when a tool cannot be found."""
def __init__(self, spec: ToolSpec, searched_paths: list):
self.spec = spec
self.searched_paths = searched_paths
super().__init__(f"Tool '{spec.full_name}' not found")
class ToolResolver:
"""Resolves tool specifications to actual tool configs."""
def __init__(
self,
project_dir: Optional[Path] = None,
auto_fetch: Optional[bool] = None,
verbose: bool = False
):
"""
Initialize the resolver.
Args:
project_dir: Project root directory (default: cwd)
auto_fetch: Override auto-fetch setting
verbose: Print debug info
"""
self.project_dir = project_dir or Path.cwd()
self.verbose = verbose
# Determine auto-fetch setting
if auto_fetch is not None:
self.auto_fetch = auto_fetch
else:
self.auto_fetch = is_auto_fetch_enabled()
# Load project manifest if present
self.manifest = load_manifest()
def resolve(self, spec: str | ToolSpec) -> ResolvedTool:
"""
Resolve a tool specification to an actual tool.
Args:
spec: Tool specification (string or ToolSpec)
Returns:
ResolvedTool with loaded tool and metadata
Raises:
ToolNotFoundError: If tool cannot be found
"""
if isinstance(spec, str):
spec = ToolSpec.parse(spec)
searched_paths = []
# Get version constraint from manifest if available
version = spec.version
if not version and self.manifest:
for dep in self.manifest.dependencies:
if dep.tool_name == spec.name:
version = dep.version
if not spec.owner and dep.owner:
spec = ToolSpec(owner=dep.owner, name=spec.name, version=version)
break
# 1. Check local project directory
result = self._find_in_local(spec, searched_paths)
if result:
return result
# 2. Check global user directory
result = self._find_in_global(spec, searched_paths)
if result:
return result
# 3. Try fetching from registry
if self.auto_fetch:
result = self._fetch_from_registry(spec, version)
if result:
return result
# Not found
raise ToolNotFoundError(spec, searched_paths)
def _find_in_local(
self,
spec: ToolSpec,
searched_paths: list
) -> Optional[ResolvedTool]:
"""Search for tool in local project directory."""
local_dirs = [self.project_dir / path for path in LOCAL_TOOLS_DIRS]
local_dirs = [path for path in local_dirs if path.exists()]
if not local_dirs:
return None
for local_dir in local_dirs:
# Try qualified path first if owner is specified
if spec.owner:
path = local_dir / spec.owner / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="local",
path=path.parent,
owner=spec.owner
)
# Try unqualified path
path = local_dir / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="local",
path=path.parent
)
# Search all owner directories for this tool name
# Priority: official first, then alphabetical for deterministic resolution
owner_dirs = [d for d in local_dir.iterdir() if d.is_dir() and not d.name.startswith(".")]
def owner_priority(d: Path) -> tuple:
if d.name == "official":
return (0, d.name)
return (1, d.name)
owner_dirs.sort(key=owner_priority)
for owner_dir in owner_dirs:
tool_dir = owner_dir / spec.name
config_path = tool_dir / "config.yaml"
if config_path.exists():
tool = self._load_tool_from_path(config_path)
if tool:
return ResolvedTool(
tool=tool,
source="local",
path=tool_dir,
owner=owner_dir.name
)
return None
def _find_in_global(
self,
spec: ToolSpec,
searched_paths: list
) -> Optional[ResolvedTool]:
"""Search for tool in global user directory."""
global_dir = TOOLS_DIR
if not global_dir.exists():
return None
# Try qualified path first if owner is specified
if spec.owner:
path = global_dir / spec.owner / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="global",
path=path.parent,
owner=spec.owner
)
# Try unqualified path (old-style tools without owner)
path = global_dir / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="global",
path=path.parent
)
# Search all owner directories for this tool name
# Priority: official first, then alphabetical for deterministic resolution
owner_dirs = [
d for d in global_dir.iterdir()
if d.is_dir() and not d.name.startswith(".") and d.name not in ("registry",)
]
# Sort with official first, then alphabetical
def owner_priority(d: Path) -> tuple:
if d.name == "official":
return (0, d.name)
return (1, d.name)
owner_dirs.sort(key=owner_priority)
for owner_dir in owner_dirs:
tool_dir = owner_dir / spec.name
config_path = tool_dir / "config.yaml"
if config_path.exists():
tool = self._load_tool_from_path(config_path)
if tool:
return ResolvedTool(
tool=tool,
source="global",
path=tool_dir,
owner=owner_dir.name
)
return None
def _fetch_from_registry(
self,
spec: ToolSpec,
version: Optional[str] = None
) -> Optional[ResolvedTool]:
"""Fetch and install tool from registry."""
try:
# Import here to avoid circular imports
from .registry_client import get_client, RegistryError
if self.verbose:
print(f"Fetching '{spec.full_name}' from registry...", file=sys.stderr)
client = get_client()
# Determine owner for registry lookup
owner = spec.owner or "official"
try:
result = client.download_tool(
owner=owner,
name=spec.name,
version=version,
install=True
)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND" and not spec.owner:
# Try searching for most popular tool with this name
results = client.search_tools(spec.name, per_page=1)
if results.data:
first = results.data[0]
result = client.download_tool(
owner=first["owner"],
name=first["name"],
version=version,
install=True
)
else:
return None
else:
raise
# Install the tool locally
resolved = self._install_from_registry(
owner=result.owner,
name=result.name,
version=result.resolved_version,
config_yaml=result.config_yaml,
readme=result.readme
)
if self.verbose:
print(
f"Installed: {result.owner}/{result.name}@{result.resolved_version}",
file=sys.stderr
)
return resolved
except ImportError:
# Registry client not available
return None
except Exception as e:
if self.verbose:
print(f"Registry fetch failed: {e}", file=sys.stderr)
return None
def _install_from_registry(
self,
owner: str,
name: str,
version: str,
config_yaml: str,
readme: str = ""
) -> ResolvedTool:
"""Install a tool fetched from registry to global directory."""
# Create directory structure
tool_dir = TOOLS_DIR / owner / name
tool_dir.mkdir(parents=True, exist_ok=True)
# Write config
config_path = tool_dir / "config.yaml"
config_path.write_text(config_yaml)
# Write README if present
if readme:
readme_path = tool_dir / "README.md"
readme_path.write_text(readme)
# Load the tool
tool = self._load_tool_from_path(config_path)
# Create wrapper script (handling collisions)
self._create_wrapper_script(owner, name)
return ResolvedTool(
tool=tool,
source="registry",
path=tool_dir,
owner=owner,
version=version
)
def _create_wrapper_script(self, owner: str, name: str) -> Path:
"""Create wrapper script with collision handling."""
import stat
bin_dir = get_bin_dir()
# Check if short name wrapper exists
short_wrapper = bin_dir / name
if short_wrapper.exists():
# Check if it belongs to the same owner
existing_owner = self._get_wrapper_owner(short_wrapper)
if existing_owner and existing_owner != owner:
# Collision - use owner-name format
wrapper_name = f"{owner}-{name}"
else:
wrapper_name = name
else:
wrapper_name = name
wrapper_path = bin_dir / wrapper_name
# Generate wrapper script
import sys
python_path = sys.executable
script = f"""#!/bin/bash
# SmartTools wrapper for '{owner}/{name}'
# Auto-generated - do not edit
exec {python_path} -m smarttools.runner {owner}/{name} "$@"
"""
wrapper_path.write_text(script)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
def _get_wrapper_owner(self, wrapper_path: Path) -> Optional[str]:
"""Extract owner from existing wrapper script."""
try:
content = wrapper_path.read_text()
# Look for pattern: smarttools.runner owner/name
# Owner slugs can contain lowercase alphanumeric and hyphens
match = re.search(r'smarttools\.runner\s+([a-z0-9][a-z0-9-]*)/([a-zA-Z0-9_-]+)', content)
if match:
return match.group(1)
return None
except Exception:
return None
def _load_tool_from_path(self, config_path: Path) -> Optional[Tool]:
"""Load a tool from a specific config path."""
try:
data = yaml.safe_load(config_path.read_text())
# Handle legacy format
if "prompt" in data and "steps" not in data:
data = self._convert_legacy_format(data)
return Tool.from_dict(data)
except Exception as e:
if self.verbose:
print(f"Error loading tool from {config_path}: {e}", file=sys.stderr)
return None
def _convert_legacy_format(self, data: dict) -> dict:
"""Convert legacy tool format to new format."""
steps = []
if data.get("prompt"):
steps.append({
"type": "prompt",
"prompt": data["prompt"],
"provider": data.get("provider", "mock"),
"output_var": "response"
})
arguments = []
for inp in data.get("inputs", []):
arguments.append({
"flag": inp.get("flag", f"--{inp['name']}"),
"variable": inp["name"],
"default": inp.get("default"),
"description": inp.get("description", "")
})
return {
"name": data["name"],
"description": data.get("description", ""),
"arguments": arguments,
"steps": steps,
"output": "{response}" if steps else "{input}"
}
# -------------------------------------------------------------------------
# Convenience functions
# -------------------------------------------------------------------------
def resolve_tool(spec: str, auto_fetch: Optional[bool] = None) -> ResolvedTool:
"""
Resolve a tool specification to an actual tool.
Args:
spec: Tool specification (e.g., "summarize", "rob/[email protected]")
auto_fetch: Override auto-fetch setting
Returns:
ResolvedTool with loaded tool and metadata
"""
resolver = ToolResolver(auto_fetch=auto_fetch)
return resolver.resolve(spec)
def find_tool(name: str) -> Optional[ResolvedTool]:
"""
Find a tool by name without auto-fetching.
Args:
name: Tool name or owner/name
Returns:
ResolvedTool if found, None otherwise
"""
try:
resolver = ToolResolver(auto_fetch=False)
return resolver.resolve(name)
except ToolNotFoundError:
return None
def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedTool:
"""
Install a tool from the registry.
Args:
spec: Tool specification
version: Version constraint
Returns:
ResolvedTool for installed tool
"""
from .registry_client import get_client
parsed = ToolSpec.parse(spec)
if version:
parsed.version = version
client = get_client()
owner = parsed.owner or "official"
result = client.download_tool(
owner=owner,
name=parsed.name,
version=parsed.version,
install=True
)
resolver = ToolResolver(auto_fetch=False)
return resolver._install_from_registry(
owner=result.owner,
name=result.name,
version=result.resolved_version,
config_yaml=result.config_yaml,
readme=result.readme
)
def uninstall_tool(spec: str) -> bool:
"""
Uninstall a tool.
Args:
spec: Tool specification
Returns:
True if tool was uninstalled
"""
import shutil
parsed = ToolSpec.parse(spec)
# Find the tool first
resolved = find_tool(spec)
if not resolved:
return False
# Remove tool directory
if resolved.path.exists():
shutil.rmtree(resolved.path)
# Remove wrapper script(s)
bin_dir = get_bin_dir()
# Remove short name wrapper if it belongs to this tool
short_wrapper = bin_dir / parsed.name
if short_wrapper.exists():
resolver = ToolResolver(auto_fetch=False)
wrapper_owner = resolver._get_wrapper_owner(short_wrapper)
if wrapper_owner == resolved.owner or wrapper_owner is None:
short_wrapper.unlink()
# Remove owner-name wrapper
if resolved.owner:
long_wrapper = bin_dir / f"{resolved.owner}-{parsed.name}"
if long_wrapper.exists():
long_wrapper.unlink()
return True
def list_installed_tools() -> list[ResolvedTool]:
"""
List all installed tools (global only).
Returns:
List of ResolvedTool objects
"""
tools = []
if not TOOLS_DIR.exists():
return tools
# Check owner directories
for item in TOOLS_DIR.iterdir():
if item.is_dir() and not item.name.startswith("."):
# Skip non-owner directories
if item.name in ("registry",):
continue
# Check if this is an owner directory (contains tool subdirectories)
has_subtools = False
for subitem in item.iterdir():
if subitem.is_dir():
config = subitem / "config.yaml"
if config.exists():
has_subtools = True
try:
tool = Tool.from_dict(yaml.safe_load(config.read_text()))
tools.append(ResolvedTool(
tool=tool,
source="global",
path=subitem,
owner=item.name
))
except Exception:
pass
# If no subtools, this might be an old-style tool directory
if not has_subtools:
config = item / "config.yaml"
if config.exists():
try:
tool = Tool.from_dict(yaml.safe_load(config.read_text()))
tools.append(ResolvedTool(
tool=tool,
source="global",
path=item
))
except Exception:
pass
return sorted(tools, key=lambda t: t.full_name)
+305
View File
@@ -0,0 +1,305 @@
"""Tool execution engine."""
import argparse
import sys
from pathlib import Path
from typing import Optional
from .tool import Tool, PromptStep, CodeStep
from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec
from .manifest import load_manifest
def substitute_variables(template: str, variables: dict) -> str:
"""
Substitute {variable} placeholders in a template.
Args:
template: String with {var} placeholders
variables: Dict of variable name -> value
Returns:
String with placeholders replaced
"""
result = template
for name, value in variables.items():
result = result.replace(f"{{{name}}}", str(value) if value else "")
return result
def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]:
"""
Execute a prompt step.
Args:
step: The prompt step to execute
variables: Current variable values
provider_override: Override the step's provider
Returns:
Tuple of (output_value, success)
"""
# Build prompt with variable substitution
prompt = substitute_variables(step.prompt, variables)
# Call provider
provider = provider_override or step.provider
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, prompt)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
return "", False
return result.text, True
def execute_code_step(step: CodeStep, variables: dict) -> tuple[dict, bool]:
"""
Execute a code step.
Args:
step: The code step to execute
variables: Current variable values (available in code)
Returns:
Tuple of (output_vars_dict, success)
"""
# Substitute variables in code (like {outputfile} -> actual value)
code = substitute_variables(step.code, variables)
# Create execution environment with variables
local_vars = dict(variables)
try:
# Execute the code with substituted variables
exec(code, {"__builtins__": __builtins__}, local_vars)
# Support comma-separated output vars (e.g., "a, b, c")
output_vars = [v.strip() for v in step.output_var.split(',')]
outputs = {}
for var in output_vars:
outputs[var] = str(local_vars.get(var, ""))
return outputs, True
except Exception as e:
print(f"Error in code step: {e}", file=sys.stderr)
return {}, False
def run_tool(
tool: Tool,
input_text: str,
custom_args: dict,
provider_override: Optional[str] = None,
dry_run: bool = False,
show_prompt: bool = False,
verbose: bool = False
) -> tuple[str, int]:
"""
Execute a tool.
Args:
tool: Tool definition
input_text: Input content
custom_args: Custom argument values
provider_override: Override all providers
dry_run: Just show what would happen
show_prompt: Show prompts in addition to output
verbose: Show debug info
Returns:
Tuple of (output_text, exit_code)
"""
# Initialize variables with input and arguments
variables = {"input": input_text}
# Add argument values (with defaults)
for arg in tool.arguments:
value = custom_args.get(arg.variable, arg.default)
variables[arg.variable] = value
if verbose:
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
# If no steps, just substitute output template
if not tool.steps:
output = substitute_variables(tool.output, variables)
return output, 0
# Execute each step
for i, step in enumerate(tool.steps):
if verbose:
step_type = "PROMPT" if isinstance(step, PromptStep) else "CODE"
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
if isinstance(step, PromptStep):
# Show prompt if requested
if show_prompt or dry_run:
prompt = substitute_variables(step.prompt, variables)
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr)
if dry_run:
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
else:
output, success = execute_prompt_step(step, variables, provider_override)
if not success:
return "", 2
variables[step.output_var] = output
elif isinstance(step, CodeStep):
if verbose or dry_run:
print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(step.code, file=sys.stderr)
print("=== END CODE ===", file=sys.stderr)
if dry_run:
# Handle comma-separated output vars for dry run
for var in [v.strip() for v in step.output_var.split(',')]:
variables[var] = "[DRY RUN - would execute code]"
else:
outputs, success = execute_code_step(step, variables)
if not success:
return "", 1
# Merge all output vars into variables
variables.update(outputs)
# Generate final output
output = substitute_variables(tool.output, variables)
return output, 0
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
"""
Create an argument parser for a tool.
Args:
tool: Tool definition
Returns:
Configured ArgumentParser
"""
parser = argparse.ArgumentParser(
prog=tool.name,
description=tool.description or f"SmartTools: {tool.name}"
)
# Universal flags
parser.add_argument("-i", "--input", dest="input_file",
help="Input file (reads from stdin if piped)")
parser.add_argument("--stdin", action="store_true",
help="Read input interactively from stdin (type then Ctrl+D)")
parser.add_argument("-o", "--output", dest="output_file",
help="Output file (writes to stdout if omitted)")
parser.add_argument("--dry-run", action="store_true",
help="Show what would happen without executing")
parser.add_argument("--show-prompt", action="store_true",
help="Show prompts in addition to output")
parser.add_argument("-p", "--provider",
help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information")
# Tool-specific flags from arguments
for arg in tool.arguments:
parser.add_argument(
arg.flag,
dest=arg.variable,
default=arg.default,
help=arg.description or f"{arg.variable} (default: {arg.default})"
)
return parser
def main():
"""Entry point for tool execution via wrapper script."""
if len(sys.argv) < 2:
print("Usage: python -m smarttools.runner <tool_name> [args...]", file=sys.stderr)
sys.exit(1)
tool_spec = sys.argv[1]
# Resolve tool using new resolution order
try:
resolved = resolve_tool(tool_spec)
tool = resolved.tool
except ToolNotFoundError as e:
print(f"Error: Tool '{tool_spec}' not found", file=sys.stderr)
print(f"Searched: {', '.join(e.searched_paths[:3])}", file=sys.stderr)
sys.exit(1)
# Check for manifest overrides
manifest = load_manifest()
provider_override_from_manifest = None
if manifest:
override = manifest.get_override(tool_spec)
if override and override.provider:
provider_override_from_manifest = override.provider
# Parse remaining arguments
parser = create_argument_parser(tool)
args = parser.parse_args(sys.argv[2:])
# Read input
if args.input_file:
# Read from file
input_path = Path(args.input_file)
if not input_path.exists():
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args
custom_args = {}
for arg in tool.arguments:
value = getattr(args, arg.variable, None)
if value is not None:
custom_args[arg.variable] = value
# Determine provider override (CLI flag takes precedence over manifest)
effective_provider = args.provider or provider_override_from_manifest
# Run tool
output, exit_code = run_tool(
tool=tool,
input_text=input_text,
custom_args=custom_args,
provider_override=effective_provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose
)
# Write output
if exit_code == 0 and output:
if args.output_file:
Path(args.output_file).write_text(output)
else:
print(output)
sys.exit(exit_code)
if __name__ == "__main__":
main()
+326
View File
@@ -0,0 +1,326 @@
"""Tool loading, saving, and management."""
import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List, Literal
import yaml
# Default tools directory
TOOLS_DIR = Path.home() / ".smarttools"
# Default bin directory for wrapper scripts
BIN_DIR = Path.home() / ".local" / "bin"
@dataclass
class ToolArgument:
"""Definition of a custom input argument."""
flag: str # e.g., "--max-size"
variable: str # e.g., "max_size"
default: Optional[str] = None
description: str = ""
def to_dict(self) -> dict:
d = {"flag": self.flag, "variable": self.variable}
if self.default:
d["default"] = self.default
if self.description:
d["description"] = self.description
return d
@classmethod
def from_dict(cls, data: dict) -> "ToolArgument":
return cls(
flag=data["flag"],
variable=data["variable"],
default=data.get("default"),
description=data.get("description", "")
)
@dataclass
class PromptStep:
"""A prompt step that calls an AI provider."""
prompt: str # The prompt template
provider: str # Provider name
output_var: str # Variable to store output
prompt_file: Optional[str] = None # Optional filename for external prompt
def to_dict(self) -> dict:
d = {
"type": "prompt",
"prompt": self.prompt,
"provider": self.provider,
"output_var": self.output_var
}
if self.prompt_file:
d["prompt_file"] = self.prompt_file
return d
@classmethod
def from_dict(cls, data: dict) -> "PromptStep":
return cls(
prompt=data["prompt"],
provider=data["provider"],
output_var=data["output_var"],
prompt_file=data.get("prompt_file")
)
@dataclass
class CodeStep:
"""A code step that runs Python code."""
code: str # Python code (inline or loaded from file)
output_var: str # Variable name(s) to capture (comma-separated for multiple)
code_file: Optional[str] = None # Optional filename for external code
def to_dict(self) -> dict:
d = {
"type": "code",
"code": self.code,
"output_var": self.output_var
}
if self.code_file:
d["code_file"] = self.code_file
return d
@classmethod
def from_dict(cls, data: dict) -> "CodeStep":
return cls(
code=data.get("code", ""),
output_var=data["output_var"],
code_file=data.get("code_file")
)
Step = PromptStep | CodeStep
# Default categories for organizing tools
DEFAULT_CATEGORIES = ["Text", "Developer", "Data", "Other"]
@dataclass
class Tool:
"""A SmartTools tool definition."""
name: str
description: str = ""
category: str = "Other" # Tool category for organization
arguments: List[ToolArgument] = field(default_factory=list)
steps: List[Step] = field(default_factory=list)
output: str = "{input}" # Output template
@classmethod
def from_dict(cls, data: dict) -> "Tool":
arguments = []
for arg in data.get("arguments", []):
arguments.append(ToolArgument.from_dict(arg))
steps = []
for step in data.get("steps", []):
if step.get("type") == "prompt":
steps.append(PromptStep.from_dict(step))
elif step.get("type") == "code":
steps.append(CodeStep.from_dict(step))
return cls(
name=data["name"],
description=data.get("description", ""),
category=data.get("category", "Other"),
arguments=arguments,
steps=steps,
output=data.get("output", "{input}")
)
def to_dict(self) -> dict:
d = {
"name": self.name,
"description": self.description,
}
# Only include category if it's not the default
if self.category and self.category != "Other":
d["category"] = self.category
d["arguments"] = [arg.to_dict() for arg in self.arguments]
d["steps"] = [step.to_dict() for step in self.steps]
d["output"] = self.output
return d
def get_available_variables(self) -> List[str]:
"""Get all variables available for use in templates."""
variables = ["input"] # Always available
# Add argument variables
for arg in self.arguments:
variables.append(arg.variable)
# Add step output variables
for step in self.steps:
variables.append(step.output_var)
return variables
def get_tools_dir() -> Path:
"""Get the tools directory, creating it if needed."""
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
return TOOLS_DIR
def get_bin_dir() -> Path:
"""Get the bin directory for wrapper scripts, creating it if needed."""
BIN_DIR.mkdir(parents=True, exist_ok=True)
return BIN_DIR
def list_tools() -> list[str]:
"""List all available tools."""
tools_dir = get_tools_dir()
tools = []
for item in tools_dir.iterdir():
if item.is_dir():
config = item / "config.yaml"
if config.exists():
tools.append(item.name)
return sorted(tools)
def load_tool(name: str) -> Optional[Tool]:
"""Load a tool by name."""
config_path = get_tools_dir() / name / "config.yaml"
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text())
# Handle legacy format (prompt/provider/provider_args/inputs)
if "prompt" in data and "steps" not in data:
# Convert to new format
steps = []
if data.get("prompt"):
steps.append({
"type": "prompt",
"prompt": data["prompt"],
"provider": data.get("provider", "mock"),
"output_var": "response"
})
arguments = []
for inp in data.get("inputs", []):
arguments.append({
"flag": inp.get("flag", f"--{inp['name']}"),
"variable": inp["name"],
"default": inp.get("default"),
"description": inp.get("description", "")
})
data = {
"name": data["name"],
"description": data.get("description", ""),
"arguments": arguments,
"steps": steps,
"output": "{response}" if steps else "{input}"
}
return Tool.from_dict(data)
except Exception as e:
print(f"Error loading tool {name}: {e}")
return None
def save_tool(tool: Tool) -> Path:
"""Save a tool to disk."""
tool_dir = get_tools_dir() / tool.name
tool_dir.mkdir(parents=True, exist_ok=True)
config_path = tool_dir / "config.yaml"
config_path.write_text(yaml.dump(tool.to_dict(), default_flow_style=False, sort_keys=False))
# Create wrapper script
create_wrapper_script(tool.name)
return config_path
def delete_tool(name: str) -> bool:
"""Delete a tool."""
tool_dir = get_tools_dir() / name
if not tool_dir.exists():
return False
# Remove wrapper script
wrapper = get_bin_dir() / name
if wrapper.exists():
wrapper.unlink()
# Remove tool directory
import shutil
shutil.rmtree(tool_dir)
return True
def create_wrapper_script(name: str) -> Path:
"""Create a wrapper script for a tool in ~/.local/bin."""
import sys
bin_dir = get_bin_dir()
wrapper_path = bin_dir / name
# Use the current Python interpreter to ensure smarttools is available
python_path = sys.executable
script = f"""#!/bin/bash
# SmartTools wrapper for '{name}'
# Auto-generated - do not edit
exec {python_path} -m smarttools.runner {name} "$@"
"""
wrapper_path.write_text(script)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
def tool_exists(name: str) -> bool:
"""Check if a tool exists."""
return (get_tools_dir() / name / "config.yaml").exists()
def validate_tool_name(name: str) -> tuple[bool, str]:
"""
Validate a tool name.
Returns:
(is_valid, error_message) - error_message is empty if valid
"""
if not name:
return False, "Tool name cannot be empty"
if ' ' in name:
return False, "Tool name cannot contain spaces"
# Check for shell-problematic characters
bad_chars = set('/\\|&;$`"\'<>(){}[]!?*#~')
found = [c for c in name if c in bad_chars]
if found:
return False, f"Tool name cannot contain: {' '.join(found)}"
# Must start with letter or underscore
if not (name[0].isalpha() or name[0] == '_'):
return False, "Tool name must start with a letter or underscore"
# Check it's a valid identifier-ish (alphanumeric, underscore, dash)
for c in name:
if not (c.isalnum() or c in '_-'):
return False, f"Tool name can only contain letters, numbers, underscore, and dash"
return True, ""
+828
View File
@@ -0,0 +1,828 @@
"""Dialog-based UI for managing SmartTools."""
import subprocess
import sys
import tempfile
from typing import Optional, Tuple, List
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
def _check_urwid() -> bool:
"""Check if urwid is available (preferred - has mouse support)."""
try:
import urwid
return True
except ImportError:
return False
def _check_snack() -> bool:
"""Check if snack (python3-newt) is available."""
try:
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
return True
except ImportError:
return False
def check_dialog() -> str:
"""Check for available dialog program. Returns 'dialog', 'whiptail', or None."""
for prog in ["dialog", "whiptail"]:
try:
subprocess.run([prog, "--version"], capture_output=True, check=False)
return prog
except FileNotFoundError:
continue
return None
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
"""Run a dialog command and return (exit_code, output)."""
try:
if dialog_prog == "whiptail":
result = subprocess.run(
[dialog_prog] + args,
stderr=subprocess.PIPE,
text=True
)
return result.returncode, result.stderr.strip()
else:
cmd_with_stdout = [dialog_prog, "--stdout"] + args
result = subprocess.run(
cmd_with_stdout,
stdout=subprocess.PIPE,
text=True
)
return result.returncode, result.stdout.strip()
except Exception as e:
return 1, ""
def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]:
"""Show a menu and return the selected item."""
args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))]
for tag, desc in choices:
args.extend([tag, desc])
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]:
"""Show an input box and return the entered text."""
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
"""Show a text editor for multi-line input."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(text)
temp_path = f.name
try:
args = ["--title", title, "--editbox", temp_path, "20", "75"]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
finally:
import os
os.unlink(temp_path)
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
"""Show a yes/no dialog."""
args = ["--title", title, "--yesno", text, "10", "60"]
code, _ = run_dialog(args, dialog_prog)
return code == 0
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
"""Show a message box."""
args = ["--title", title, "--msgbox", text, "15", "70"]
run_dialog(args, dialog_prog)
def show_mixed_form(title: str, fields: dict, dialog_prog: str, height: int = 20) -> Optional[dict]:
"""Show a form with multiple fields using --mixedform."""
args = ["--title", title, "--mixedform",
"Tab: next field | Enter: submit | Esc: cancel",
str(height), "75", "0"]
field_names = list(fields.keys())
y = 1
for name in field_names:
label, initial, field_type = fields[name]
args.extend([
label, str(y), "1",
initial, str(y), "18",
"52", "256", str(field_type)
])
y += 1
code, output = run_dialog(args, dialog_prog)
if code != 0:
return None
values = output.split('\n')
result = {}
for i, name in enumerate(field_names):
result[name] = values[i] if i < len(values) else ""
return result
# ============ Provider Management ============
def select_provider(dialog_prog: str) -> Optional[str]:
"""Show provider selection menu with option to create new."""
providers = load_providers()
choices = [(p.name, f"{p.description} ({p.command})") for p in providers]
choices.append(("__new__", "[ + Add New Provider ]"))
selected = show_menu("Select Provider", choices, dialog_prog)
if selected == "__new__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
return provider.name
return None
return selected
def create_provider_form(dialog_prog: str, existing: Optional[Provider] = None) -> Optional[Provider]:
"""Show form for creating/editing a provider."""
title = f"Edit Provider: {existing.name}" if existing else "Add New Provider"
fields = {
"name": (
"Name:",
existing.name if existing else "",
2 if existing else 0 # readonly if editing
),
"command": (
"Command:",
existing.command if existing else "",
0
),
"description": (
"Description:",
existing.description if existing else "",
0
),
}
result = show_mixed_form(title, fields, dialog_prog, height=12)
if not result:
return None
name = result["name"].strip()
command = result["command"].strip()
if not name:
show_message("Error", "Provider name is required.", dialog_prog)
return None
if not command:
show_message("Error", "Command is required.", dialog_prog)
return None
return Provider(
name=name,
command=command,
description=result["description"].strip()
)
def ui_manage_providers(dialog_prog: str):
"""Manage providers menu."""
while True:
providers = load_providers()
choices = [(p.name, f"{p.command}") for p in providers]
choices.append(("__add__", "[ + Add New Provider ]"))
choices.append(("__back__", "[ <- Back to Main Menu ]"))
selected = show_menu("Manage Providers", choices, dialog_prog)
if selected is None or selected == "__back__":
break
elif selected == "__add__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
show_message("Success", f"Provider '{provider.name}' added.", dialog_prog)
else:
# Edit or delete existing provider
provider = get_provider(selected)
if provider:
action = show_menu(
f"Provider: {selected}",
[
("edit", "Edit provider"),
("delete", "Delete provider"),
("back", "Back"),
],
dialog_prog
)
if action == "edit":
updated = create_provider_form(dialog_prog, provider)
if updated:
add_provider(updated)
show_message("Success", f"Provider '{updated.name}' updated.", dialog_prog)
elif action == "delete":
if show_yesno("Confirm", f"Delete provider '{selected}'?", dialog_prog):
delete_provider(selected)
show_message("Deleted", f"Provider '{selected}' deleted.", dialog_prog)
# ============ Tool Builder UI ============
def format_tool_summary(tool: Tool) -> str:
"""Format a summary of the tool's components."""
lines = []
lines.append(f"Name: {tool.name}")
lines.append(f"Description: {tool.description or '(none)'}")
lines.append("")
if tool.arguments:
lines.append("Arguments:")
for arg in tool.arguments:
default = f" = {arg.default}" if arg.default else ""
lines.append(f" {arg.flag} -> {{{arg.variable}}}{default}")
lines.append("")
if tool.steps:
lines.append("Steps:")
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
preview = step.prompt[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}")
lines.append(f" {preview}")
elif isinstance(step, CodeStep):
preview = step.code[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. CODE -> {{{step.output_var}}}")
lines.append(f" {preview}")
lines.append("")
lines.append(f"Output: {tool.output}")
return "\n".join(lines)
def get_available_variables(tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get list of available variables at a given point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def edit_argument(dialog_prog: str, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Edit or create an argument."""
title = f"Edit Argument: {existing.flag}" if existing else "Add Argument"
fields = {
"flag": ("Flag:", existing.flag if existing else "--", 0),
"variable": ("Variable:", existing.variable if existing else "", 0),
"default": ("Default:", existing.default or "" if existing else "", 0),
"description": ("Description:", existing.description if existing else "", 0),
}
result = show_mixed_form(title, fields, dialog_prog, height=14)
if not result:
return None
flag = result["flag"].strip()
variable = result["variable"].strip()
if not flag:
show_message("Error", "Flag is required (e.g., --max-size).", dialog_prog)
return None
if not variable:
# Auto-generate variable name from flag
variable = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=variable,
default=result["default"].strip() or None,
description=result["description"].strip()
)
def edit_prompt_step(dialog_prog: str, existing: Optional[PromptStep] = None,
available_vars: List[str] = None) -> Optional[PromptStep]:
"""Edit or create a prompt step."""
title = "Edit Prompt Step" if existing else "Add Prompt Step"
# First, select provider
provider = select_provider(dialog_prog)
if not provider:
provider = existing.provider if existing else "mock"
# Show variable help
var_help = "Available: " + ", ".join(f"{{{v}}}" for v in (available_vars or ["input"]))
# Edit prompt text
default_prompt = existing.prompt if existing else f"Process this input:\n\n{{input}}"
prompt = show_textbox(f"Prompt Template\n{var_help}", default_prompt, dialog_prog)
if prompt is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "result",
dialog_prog
)
if not output_var:
return None
return PromptStep(
prompt=prompt,
provider=provider,
output_var=output_var.strip()
)
def edit_code_step(dialog_prog: str, existing: Optional[CodeStep] = None,
available_vars: List[str] = None) -> Optional[CodeStep]:
"""Edit or create a code step."""
title = "Edit Code Step" if existing else "Add Code Step"
# Show variable help
var_help = "Variables: " + ", ".join(available_vars or ["input"])
var_help += "\nSet 'result' variable for output"
# Edit code
default_code = existing.code if existing else "# Available variables: " + ", ".join(available_vars or ["input"]) + "\n# Set 'result' for output\nresult = input.upper()"
code = show_textbox(f"Python Code\n{var_help}", default_code, dialog_prog)
if code is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "processed",
dialog_prog
)
if not output_var:
return None
return CodeStep(
code=code,
output_var=output_var.strip()
)
def edit_tool_info(tool: Tool, is_edit: bool, dialog_prog: str) -> None:
"""Edit basic tool info (name, description, output)."""
while True:
# Build info section menu
output_preview = tool.output[:35] + "..." if len(tool.output) > 35 else tool.output
args_count = len(tool.arguments)
args_summary = f"({args_count} defined)" if args_count else "(none)"
choices = [
("name", f"Name: {tool.name or '(not set)'}"),
("desc", f"Description: {tool.description[:35] + '...' if len(tool.description) > 35 else tool.description or '(none)'}"),
("---1", "─" * 40),
]
# Show arguments
if tool.arguments:
for i, arg in enumerate(tool.arguments):
default = f" = {arg.default}" if arg.default else ""
choices.append((f"arg_{i}", f" {arg.flag} -> {{{arg.variable}}}{default}"))
choices.append(("add_arg", " [ + Add Argument ]"))
choices.append(("---2", "─" * 40))
choices.append(("output", f"Output Template: {output_preview}"))
choices.append(("---3", "─" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Tool Info & Arguments", choices, dialog_prog)
if selected is None or selected == "back":
break
elif selected == "name":
if is_edit:
show_message("Info", "Cannot change tool name after creation.", dialog_prog)
else:
new_name = show_input("Tool Name", "Enter tool name:", tool.name, dialog_prog)
if new_name:
tool.name = new_name.strip()
elif selected == "desc":
new_desc = show_input("Description", "Enter tool description:", tool.description, dialog_prog)
if new_desc is not None:
tool.description = new_desc.strip()
elif selected == "add_arg":
arg = edit_argument(dialog_prog)
if arg:
tool.arguments.append(arg)
elif selected.startswith("arg_"):
idx = int(selected[4:])
arg = tool.arguments[idx]
action = show_menu(
f"Argument: {arg.flag}",
[("edit", "Edit"), ("delete", "Delete"), ("back", "Back")],
dialog_prog
)
if action == "edit":
updated = edit_argument(dialog_prog, arg)
if updated:
tool.arguments[idx] = updated
elif action == "delete":
if show_yesno("Delete", f"Delete argument {arg.flag}?", dialog_prog):
tool.arguments.pop(idx)
elif selected == "output":
available = get_available_variables(tool)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
new_output = show_textbox(f"Output Template\n{var_help}", tool.output, dialog_prog)
if new_output is not None:
tool.output = new_output
def edit_tool_steps(tool: Tool, dialog_prog: str) -> None:
"""Edit tool processing steps."""
while True:
# Build steps section menu
choices = []
if tool.steps:
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
choices.append((f"step_{i}", f"{i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}"))
elif isinstance(step, CodeStep):
choices.append((f"step_{i}", f"{i+1}. CODE -> {{{step.output_var}}}"))
else:
choices.append(("none", "(no steps defined)"))
choices.append(("---1", "─" * 40))
choices.append(("add_prompt", "[ + Add Prompt Step ]"))
choices.append(("add_code", "[ + Add Code Step ]"))
choices.append(("---2", "─" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Processing Steps", choices, dialog_prog)
if selected is None or selected == "back" or selected == "none":
if selected == "none":
continue
break
elif selected == "add_prompt":
available = get_available_variables(tool)
step = edit_prompt_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected == "add_code":
available = get_available_variables(tool)
step = edit_code_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected.startswith("step_"):
idx = int(selected[5:])
step = tool.steps[idx]
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
move_choices = [("edit", "Edit"), ("delete", "Delete")]
if idx > 0:
move_choices.insert(1, ("move_up", "Move Up"))
if idx < len(tool.steps) - 1:
move_choices.insert(2 if idx > 0 else 1, ("move_down", "Move Down"))
move_choices.append(("back", "Back"))
action = show_menu(f"Step {idx+1}: {step_type}", move_choices, dialog_prog)
if action == "edit":
available = get_available_variables(tool, idx)
if isinstance(step, PromptStep):
updated = edit_prompt_step(dialog_prog, step, available)
else:
updated = edit_code_step(dialog_prog, step, available)
if updated:
tool.steps[idx] = updated
elif action == "move_up" and idx > 0:
tool.steps[idx], tool.steps[idx-1] = tool.steps[idx-1], tool.steps[idx]
elif action == "move_down" and idx < len(tool.steps) - 1:
tool.steps[idx], tool.steps[idx+1] = tool.steps[idx+1], tool.steps[idx]
elif action == "delete":
if show_yesno("Delete", f"Delete step {idx+1}?", dialog_prog):
tool.steps.pop(idx)
def tool_builder(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder interface with tabbed sections."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Build main menu with section summaries
args_count = len(tool.arguments)
steps_count = len(tool.steps)
# Info summary
name_display = tool.name or "(not set)"
info_summary = f"{name_display}"
if tool.arguments:
info_summary += f" | {args_count} arg{'s' if args_count != 1 else ''}"
# Steps summary
if tool.steps:
step_types = []
for s in tool.steps:
if isinstance(s, PromptStep):
step_types.append(f"P:{s.provider}")
else:
step_types.append("C")
steps_summary = " -> ".join(step_types)
else:
steps_summary = "(none)"
choices = [
("info", f"[1] Info & Args : {info_summary}"),
("steps", f"[2] Steps : {steps_summary}"),
("---", "─" * 50),
("preview", "Preview Full Summary"),
("save", "Save Tool"),
("cancel", "Cancel"),
]
title = f"Tool Builder: {tool.name}" if tool.name else "Tool Builder: New Tool"
selected = show_menu(title, choices, dialog_prog)
if selected is None or selected == "cancel":
if show_yesno("Cancel", "Discard changes?", dialog_prog):
return None
continue
elif selected == "info":
edit_tool_info(tool, is_edit, dialog_prog)
elif selected == "steps":
edit_tool_steps(tool, dialog_prog)
elif selected == "preview":
summary = format_tool_summary(tool)
show_message("Tool Summary", summary, dialog_prog)
elif selected == "save":
if not tool.name:
show_message("Error", "Tool name is required. Go to Info & Args to set it.", dialog_prog)
continue
if not is_edit and tool_exists(tool.name):
if not show_yesno("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", dialog_prog):
continue
return tool
# ============ Main Menu Functions ============
def ui_list_tools(dialog_prog: str):
"""Show list of tools."""
tools = list_tools()
if not tools:
show_message("Tools", "No tools found.\n\nCreate your first tool from the main menu.", dialog_prog)
return
text = "Available tools:\n\n"
for name in tools:
tool = load_tool(name)
if tool:
text += f" {name}: {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(arg.flag for arg in tool.arguments)
text += f" Arguments: {args}\n"
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
else:
step_info.append("CODE")
text += f" Steps: {' -> '.join(step_info)}\n"
text += "\n"
show_message("Tools", text, dialog_prog)
def ui_create_tool(dialog_prog: str):
"""Create a new tool."""
tool = tool_builder(dialog_prog)
if tool:
path = save_tool(tool)
# Build usage example
usage = f"{tool.name}"
for arg in tool.arguments:
if arg.default:
usage += f" [{arg.flag} <{arg.variable}>]"
else:
usage += f" {arg.flag} <{arg.variable}>"
usage += " < input.txt"
show_message("Success",
f"Tool '{tool.name}' created!\n\n"
f"Config: {path}\n\n"
f"Usage: {usage}",
dialog_prog)
def ui_edit_tool(dialog_prog: str):
"""Edit an existing tool."""
tools = list_tools()
if not tools:
show_message("Edit Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Edit", choices, dialog_prog)
if selected:
existing = load_tool(selected)
if existing:
tool = tool_builder(dialog_prog, existing)
if tool:
save_tool(tool)
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
def ui_delete_tool(dialog_prog: str):
"""Delete a tool."""
tools = list_tools()
if not tools:
show_message("Delete Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Delete", choices, dialog_prog)
if selected:
if show_yesno("Confirm Delete", f"Delete tool '{selected}'?\n\nThis cannot be undone.", dialog_prog):
if delete_tool(selected):
show_message("Deleted", f"Tool '{selected}' deleted.", dialog_prog)
else:
show_message("Error", f"Failed to delete '{selected}'.", dialog_prog)
def ui_test_tool(dialog_prog: str):
"""Test a tool with mock provider."""
tools = list_tools()
if not tools:
show_message("Test Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Test", choices, dialog_prog)
if selected:
tool = load_tool(selected)
if tool:
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}"
if len(output) > 1000:
result_text += "\n... (truncated)"
show_message("Test Result", result_text, dialog_prog)
def main_menu(dialog_prog: str):
"""Show the main menu."""
while True:
choice = show_menu(
"SmartTools Manager",
[
("list", "List all tools"),
("create", "Create new tool"),
("edit", "Edit existing tool"),
("delete", "Delete tool"),
("test", "Test tool (mock provider)"),
("providers", "Manage providers"),
("exit", "Exit"),
],
dialog_prog
)
if choice is None or choice == "exit":
break
elif choice == "list":
ui_list_tools(dialog_prog)
elif choice == "create":
ui_create_tool(dialog_prog)
elif choice == "edit":
ui_edit_tool(dialog_prog)
elif choice == "delete":
ui_delete_tool(dialog_prog)
elif choice == "test":
ui_test_tool(dialog_prog)
elif choice == "providers":
ui_manage_providers(dialog_prog)
def run_ui():
"""Entry point for the UI."""
# Prefer urwid (has mouse support)
if _check_urwid():
from .ui_urwid import run_ui as run_urwid_ui
run_urwid_ui()
return
# Fallback to snack (BIOS-style)
if _check_snack():
from .ui_snack import run_ui as run_snack_ui
run_snack_ui()
return
# Fallback to dialog/whiptail
dialog_prog = check_dialog()
if not dialog_prog:
print("Error: No TUI library found.", file=sys.stderr)
print("Install one of:", file=sys.stderr)
print(" pip install urwid (recommended - has mouse support)", file=sys.stderr)
print(" sudo apt install python3-newt", file=sys.stderr)
print(" sudo apt install dialog", file=sys.stderr)
sys.exit(1)
try:
main_menu(dialog_prog)
except KeyboardInterrupt:
pass
finally:
subprocess.run(["clear"], check=False)
if __name__ == "__main__":
run_ui()
+496
View File
@@ -0,0 +1,496 @@
"""TUI for browsing the SmartTools Registry using urwid.
Uses threading for non-blocking network operations.
"""
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, List, Dict, Any, Callable
import urwid
from .registry_client import (
RegistryClient, RegistryError, ToolInfo,
get_client, PaginatedResponse
)
from .resolver import install_from_registry
# Color palette - matching the main UI style
PALETTE = [
('body', 'white', 'dark blue'),
('header', 'white', 'dark red', 'bold'),
('footer', 'black', 'light gray'),
('button', 'black', 'light gray'),
('button_focus', 'white', 'dark red', 'bold'),
('edit', 'black', 'light gray'),
('edit_focus', 'black', 'yellow'),
('listbox', 'black', 'light gray'),
('listbox_focus', 'white', 'dark red'),
('dialog', 'black', 'light gray'),
('label', 'yellow', 'dark blue', 'bold'),
('error', 'white', 'dark red', 'bold'),
('success', 'light green', 'dark blue', 'bold'),
('info', 'light cyan', 'dark blue'),
('downloads', 'light green', 'light gray'),
('category', 'dark cyan', 'light gray'),
('version', 'brown', 'light gray'),
('loading', 'yellow', 'dark blue'),
]
class ToolListItem(urwid.WidgetWrap):
"""A selectable tool item in the browse list."""
def __init__(self, tool_data: Dict[str, Any], on_select=None, on_install=None):
self.tool_data = tool_data
self.on_select = on_select
self.on_install = on_install
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "")[:50]
downloads = tool_data.get("downloads", 0)
category = tool_data.get("category", "")
# Format: owner/name v1.0.0 [category] ↓123
main_line = urwid.Text([
('listbox', f" {owner}/"),
('listbox', f"{name} "),
('version', f"v{version}"),
])
desc_line = urwid.Text([
('listbox', f" {description}{'...' if len(tool_data.get('description', '')) > 50 else ''}"),
])
meta_line = urwid.Text([
('category', f" [{category}]" if category else ""),
('downloads', f" ↓{downloads}"),
])
pile = urwid.Pile([main_line, desc_line, meta_line])
self.attr_map = urwid.AttrMap(pile, 'listbox', 'listbox_focus')
super().__init__(self.attr_map)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.tool_data)
return None
if key == 'i' and self.on_install:
self.on_install(self.tool_data)
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.tool_data)
return True
return False
class SearchEdit(urwid.Edit):
"""Search box that triggers callback on enter."""
def __init__(self, on_search=None):
self.on_search = on_search
super().__init__(caption="Search: ", edit_text="")
def keypress(self, size, key):
if key == 'enter' and self.on_search:
self.on_search(self.edit_text)
return None
return super().keypress(size, key)
class AsyncOperation:
"""Manages async operations with UI callbacks."""
def __init__(self, executor: ThreadPoolExecutor):
self.executor = executor
self._write_fd: Optional[int] = None
self._read_fd: Optional[int] = None
self._pending_callbacks: List[Callable] = []
self._lock = threading.Lock()
def setup_pipe(self, loop: urwid.MainLoop):
"""Setup a pipe for thread-safe UI updates."""
self._read_fd, self._write_fd = os.pipe()
loop.watch_file(self._read_fd, self._handle_callback)
def cleanup(self):
"""Cleanup pipe file descriptors."""
if self._read_fd is not None:
os.close(self._read_fd)
if self._write_fd is not None:
os.close(self._write_fd)
def _handle_callback(self):
"""Handle pending callbacks from worker threads."""
# Read and discard the notification byte
os.read(self._read_fd, 1)
# Process pending callbacks
with self._lock:
callbacks = self._pending_callbacks[:]
self._pending_callbacks.clear()
for callback in callbacks:
callback()
def _schedule_callback(self, callback: Callable):
"""Schedule a callback to run on the main thread."""
with self._lock:
self._pending_callbacks.append(callback)
# Wake up the main loop
if self._write_fd is not None:
os.write(self._write_fd, b'x')
def run_async(
self,
operation: Callable,
on_success: Callable[[Any], None],
on_error: Callable[[Exception], None]
):
"""Run an operation asynchronously."""
def worker():
try:
result = operation()
self._schedule_callback(lambda: on_success(result))
except Exception as e:
self._schedule_callback(lambda: on_error(e))
self.executor.submit(worker)
class RegistryBrowser:
"""TUI browser for the SmartTools Registry."""
def __init__(self):
self.client = get_client()
self.tools: List[Dict] = []
self.categories: List[Dict] = []
self.current_category: Optional[str] = None
self.current_query: str = ""
self.current_page: int = 1
self.total_pages: int = 1
self.status_message: str = ""
self.loop: Optional[urwid.MainLoop] = None
self.loading: bool = False
# Thread pool for async operations
self.executor = ThreadPoolExecutor(max_workers=2)
self.async_ops = AsyncOperation(self.executor)
# Build UI
self._build_ui()
def _build_ui(self):
"""Build the main UI layout."""
# Header
self.header = urwid.AttrMap(
urwid.Text(" SmartTools Registry Browser ", align='center'),
'header'
)
# Search box
self.search_edit = SearchEdit(on_search=self._do_search)
search_widget = urwid.AttrMap(self.search_edit, 'edit', 'edit_focus')
# Category selector
self.category_text = urwid.Text("Category: All")
category_widget = urwid.AttrMap(self.category_text, 'info')
# Top bar with search and category
top_bar = urwid.Columns([
('weight', 2, search_widget),
('weight', 1, category_widget),
], dividechars=2)
# Tools list
self.list_walker = urwid.SimpleFocusListWalker([])
self.listbox = urwid.ListBox(self.list_walker)
list_frame = urwid.LineBox(self.listbox, title="Tools")
# Detail panel (right side)
self.detail_text = urwid.Text("Select a tool to view details\n\nPress 'i' to install")
self.detail_box = urwid.LineBox(
urwid.Filler(self.detail_text, valign='top'),
title="Details"
)
# Main content area with list and details
self.main_columns = urwid.Columns([
('weight', 2, list_frame),
('weight', 1, self.detail_box),
], dividechars=1)
# Status bar
self.status_text = urwid.Text(" Loading...")
self.footer = urwid.AttrMap(
urwid.Columns([
self.status_text,
urwid.Text("↑↓:Navigate Enter:Details i:Install /:Search c:Category q:Quit", align='right'),
]),
'footer'
)
# Main frame
body = urwid.Pile([
('pack', urwid.AttrMap(top_bar, 'body')),
('pack', urwid.Divider()),
self.main_columns,
])
self.frame = urwid.Frame(
urwid.AttrMap(body, 'body'),
header=self.header,
footer=self.footer
)
def _load_tools(self, query: str = "", category: str = None, page: int = 1):
"""Load tools from the registry asynchronously."""
if self.loading:
return
self.loading = True
self._set_status("Loading...", loading=True)
def fetch():
if query:
return self.client.search_tools(
query=query,
category=category,
page=page,
per_page=20
)
else:
return self.client.list_tools(
category=category,
page=page,
per_page=20
)
def on_success(result: PaginatedResponse):
self.loading = False
self.tools = result.data
self.current_page = result.page
self.total_pages = result.total_pages
self._update_list()
self._set_status(f"Found {result.total} tools (page {result.page}/{result.total_pages})")
def on_error(e: Exception):
self.loading = False
if isinstance(e, RegistryError):
self._set_status(f"Error: {e.message}")
else:
self._set_status(f"Error: {e}")
self.async_ops.run_async(fetch, on_success, on_error)
def _load_categories(self):
"""Load categories from the registry asynchronously."""
def fetch():
return self.client.get_categories()
def on_success(categories):
self.categories = categories
def on_error(e):
self.categories = []
self.async_ops.run_async(fetch, on_success, on_error)
def _update_list(self):
"""Update the tool list display."""
self.list_walker.clear()
if not self.tools:
self.list_walker.append(urwid.Text(" No tools found"))
return
for tool in self.tools:
item = ToolListItem(
tool,
on_select=self._show_detail,
on_install=self._install_tool
)
self.list_walker.append(item)
self.list_walker.append(urwid.Divider('─'))
def _show_detail(self, tool_data: Dict):
"""Show tool details in the detail panel."""
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "No description")
category = tool_data.get("category", "")
tags = tool_data.get("tags", [])
downloads = tool_data.get("downloads", 0)
detail = f"""{owner}/{name}
Version: {version}
Category: {category}
Downloads: {downloads}
{description}
Tags: {', '.join(tags) if tags else 'None'}
Install command:
smarttools registry install {owner}/{name}
Press 'i' to install this tool
"""
self.detail_text.set_text(detail)
def _install_tool(self, tool_data: Dict):
"""Install the selected tool asynchronously."""
if self.loading:
return
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
self.loading = True
self._set_status(f"Installing {owner}/{name}...", loading=True)
def install():
return install_from_registry(f"{owner}/{name}")
def on_success(resolved):
self.loading = False
self._set_status(f"Installed: {resolved.full_name}@{resolved.version}")
def on_error(e):
self.loading = False
self._set_status(f"Install failed: {e}")
self.async_ops.run_async(install, on_success, on_error)
def _do_search(self, query: str):
"""Perform search."""
self.current_query = query
self.current_page = 1
self._load_tools(query=query, category=self.current_category)
def _cycle_category(self):
"""Cycle through categories."""
if not self.categories:
self._load_categories()
# Schedule the cycle after categories load
return
if not self.categories:
return
cat_names = [None] + [c.get("name") for c in self.categories]
try:
idx = cat_names.index(self.current_category)
idx = (idx + 1) % len(cat_names)
except ValueError:
idx = 0
self.current_category = cat_names[idx]
cat_display = self.current_category or "All"
self.category_text.set_text(f"Category: {cat_display}")
self._load_tools(query=self.current_query, category=self.current_category)
def _next_page(self):
"""Go to next page."""
if self.current_page < self.total_pages:
self.current_page += 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _prev_page(self):
"""Go to previous page."""
if self.current_page > 1:
self.current_page -= 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _set_status(self, message: str, loading: bool = False):
"""Update status bar message."""
if loading:
self.status_text.set_text(('loading', f" ⟳ {message}"))
else:
self.status_text.set_text(f" {message}")
def _handle_input(self, key):
"""Handle global key input."""
if key in ('q', 'Q'):
raise urwid.ExitMainLoop()
elif key == '/':
# Focus search box
self.frame.body.base_widget.set_focus(0)
return None
elif key == 'c':
self._cycle_category()
return None
elif key == 'n':
self._next_page()
return None
elif key == 'p':
self._prev_page()
return None
elif key == 'r':
# Refresh current view
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
return None
return key
def run(self):
"""Run the TUI browser."""
# Create main loop
self.loop = urwid.MainLoop(
self.frame,
palette=PALETTE,
unhandled_input=self._handle_input,
handle_mouse=True
)
# Setup async pipe for thread-safe callbacks
self.async_ops.setup_pipe(self.loop)
try:
# Initial load (async)
self._load_categories()
self._load_tools()
# Run main loop
self.loop.run()
finally:
# Cleanup
self.async_ops.cleanup()
self.executor.shutdown(wait=False)
def run_registry_browser():
"""Entry point for the registry browser TUI."""
try:
browser = RegistryBrowser()
browser.run()
except RegistryError as e:
print(f"Error connecting to registry: {e.message}")
return 1
except Exception as e:
print(f"Error: {e}")
return 1
return 0
+706
View File
@@ -0,0 +1,706 @@
"""BIOS-style TUI for SmartTools using snack (python3-newt)."""
import sys
# Ensure system packages are accessible
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
from typing import Optional, List, Tuple
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
class SmartToolsUI:
"""BIOS-style UI for SmartTools."""
def __init__(self):
self.screen = None
def run(self):
"""Run the UI."""
self.screen = snack.SnackScreen()
# Enable mouse support
self.screen.pushHelpLine(" Tab/Arrow:Navigate | Enter:Select | Mouse:Click | Esc:Back ")
try:
# Enable mouse - newt supports GPM and xterm mouse
import os
os.environ.setdefault('NEWT_MONO', '0')
self.main_menu()
finally:
self.screen.finish()
def main_menu(self):
"""Show the main menu."""
while True:
items = [
("Create New Tool", "create"),
("Edit Tool", "edit"),
("Delete Tool", "delete"),
("List Tools", "list"),
("Test Tool", "test"),
("Manage Providers", "providers"),
("Exit", "exit"),
]
listbox = snack.Listbox(height=7, width=30, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, "SmartTools Manager", 1, 1)
grid.add(listbox, 0, 0)
result = grid.runOnce()
selected = listbox.current()
if selected == "exit" or result == "ESC":
break
elif selected == "create":
self.tool_builder(None)
elif selected == "edit":
self.select_and_edit_tool()
elif selected == "delete":
self.select_and_delete_tool()
elif selected == "list":
self.show_tools_list()
elif selected == "test":
self.select_and_test_tool()
elif selected == "providers":
self.manage_providers()
def message_box(self, title: str, message: str):
"""Show a message box."""
snack.ButtonChoiceWindow(self.screen, title, message, ["OK"])
def yes_no(self, title: str, message: str) -> bool:
"""Show a yes/no dialog."""
result = snack.ButtonChoiceWindow(self.screen, title, message, ["Yes", "No"])
return result == "yes"
def input_box(self, title: str, prompt: str, initial: str = "", width: int = 40) -> Optional[str]:
"""Show an input dialog."""
entry = snack.Entry(width, initial)
grid = snack.GridForm(self.screen, title, 1, 3)
grid.add(snack.Label(prompt), 0, 0)
grid.add(entry, 0, 1, padding=(0, 1, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def text_edit(self, title: str, initial: str = "", width: int = 60, height: int = 10) -> Optional[str]:
"""Show a multi-line text editor."""
text = snack.Textbox(width, height, initial, scroll=1, wrap=1)
# snack doesn't have a true multi-line editor, so we use Entry for now
# For multi-line, we'll use a workaround with a simple entry
entry = snack.Entry(width, initial, scroll=1)
grid = snack.GridForm(self.screen, title, 1, 2)
grid.add(entry, 0, 0, padding=(0, 0, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def select_provider(self) -> Optional[str]:
"""Show provider selection dialog."""
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 1, 8), width=50, returnExit=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add New Provider ]", "__new__")
grid = snack.GridForm(self.screen, "Select Provider", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
selected = listbox.current()
if selected == "__new__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
return provider.name
return None
return selected
def add_provider_dialog(self) -> Optional[Provider]:
"""Dialog to add a new provider."""
name_entry = snack.Entry(30, "")
cmd_entry = snack.Entry(40, "")
desc_entry = snack.Entry(40, "")
grid = snack.GridForm(self.screen, "Add Provider", 2, 4)
grid.add(snack.Label("Name:"), 0, 0, anchorLeft=1)
grid.add(name_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Command:"), 0, 1, anchorLeft=1)
grid.add(cmd_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 2, anchorLeft=1)
grid.add(desc_entry, 1, 2, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 3, growx=1, growy=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
name = name_entry.value().strip()
cmd = cmd_entry.value().strip()
if name and cmd:
return Provider(name=name, command=cmd, description=desc_entry.value().strip())
self.message_box("Error", "Name and command are required.")
return None
def add_argument_dialog(self, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Dialog to add/edit an argument."""
flag_entry = snack.Entry(20, existing.flag if existing else "--")
var_entry = snack.Entry(20, existing.variable if existing else "")
default_entry = snack.Entry(20, existing.default or "" if existing else "")
desc_entry = snack.Entry(40, existing.description if existing else "")
title = "Edit Argument" if existing else "Add Argument"
grid = snack.GridForm(self.screen, title, 2, 5)
grid.add(snack.Label("Flag:"), 0, 0, anchorLeft=1)
grid.add(flag_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Variable:"), 0, 1, anchorLeft=1)
grid.add(var_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Default:"), 0, 2, anchorLeft=1)
grid.add(default_entry, 1, 2, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 3, anchorLeft=1)
grid.add(desc_entry, 1, 3, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 4, growx=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
flag = flag_entry.value().strip()
var = var_entry.value().strip()
if not flag:
self.message_box("Error", "Flag is required.")
return None
if not var:
var = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=var,
default=default_entry.value().strip() or None,
description=desc_entry.value().strip()
)
return None
def add_step_dialog(self, tool: Tool, existing_step: Optional[Step] = None, step_idx: int = -1) -> Optional[Step]:
"""Dialog to choose and add a step."""
if existing_step:
# Edit existing step
if isinstance(existing_step, PromptStep):
return self.add_prompt_dialog(tool, existing_step, step_idx)
else:
return self.add_code_dialog(tool, existing_step, step_idx)
# Choose step type
listbox = snack.Listbox(height=2, width=30, returnExit=1)
listbox.append("Prompt (AI call)", "prompt")
listbox.append("Code (Python)", "code")
grid = snack.GridForm(self.screen, "Add Step", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
step_type = listbox.current()
if step_type == "prompt":
return self.add_prompt_dialog(tool, None, step_idx)
else:
return self.add_code_dialog(tool, None, step_idx)
def get_available_variables(self, tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get available variables at a point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def add_prompt_dialog(self, tool: Tool, existing: Optional[PromptStep] = None, step_idx: int = -1) -> Optional[PromptStep]:
"""Dialog to add/edit a prompt step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
# Provider selection first
provider = self.select_provider()
if not provider:
provider = existing.provider if existing else "mock"
prompt_entry = snack.Entry(60, existing.prompt if existing else f"Process this:\n\n{{input}}", scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "result")
title = "Edit Prompt Step" if existing else "Add Prompt Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Provider: {provider}"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 0)
grid.add(snack.Label(f"Prompt ({var_help}):"), 0, 1, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 1)
grid.add(prompt_entry, 0, 2, growx=1)
grid.add(snack.Label(""), 1, 2)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 3, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 3)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
prompt = prompt_entry.value().strip()
output_var = output_entry.value().strip()
if not prompt:
self.message_box("Error", "Prompt is required.")
return None
if not output_var:
output_var = "result"
return PromptStep(prompt=prompt, provider=provider, output_var=output_var)
return None
def add_code_dialog(self, tool: Tool, existing: Optional[CodeStep] = None, step_idx: int = -1) -> Optional[CodeStep]:
"""Dialog to add/edit a code step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(available)
default_code = existing.code if existing else "# Set 'result' for output\nresult = input.upper()"
code_entry = snack.Entry(60, default_code, scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "processed")
title = "Edit Code Step" if existing else "Add Code Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Python Code ({var_help}):"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label("Set 'result' variable for output"), 1, 0)
grid.add(code_entry, 0, 1, growx=1)
grid.add(snack.Label(""), 1, 1)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 2, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
code = code_entry.value().strip()
output_var = output_entry.value().strip()
if not code:
self.message_box("Error", "Code is required.")
return None
if not output_var:
output_var = "processed"
return CodeStep(code=code, output_var=output_var)
return None
def tool_builder(self, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder - BIOS-style unified form."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Create form elements
name_entry = snack.Entry(25, tool.name, scroll=0)
desc_entry = snack.Entry(25, tool.description, scroll=1)
output_entry = snack.Entry(25, tool.output, scroll=1)
# Arguments listbox
args_listbox = snack.Listbox(height=4, width=35, returnExit=0, scroll=1)
for i, arg in enumerate(tool.arguments):
args_listbox.append(f"{arg.flag} -> {{{arg.variable}}}", i)
args_listbox.append("[ + Add ]", "add")
# Steps listbox
steps_listbox = snack.Listbox(height=5, width=35, returnExit=0, scroll=1)
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
steps_listbox.append(f"P:{step.provider} -> {{{step.output_var}}}", i)
else:
steps_listbox.append(f"C: -> {{{step.output_var}}}", i)
steps_listbox.append("[ + Add ]", "add")
# Build the grid layout using nested grids for better control
title = f"Edit Tool: {tool.name}" if is_edit and tool.name else "New Tool"
# Left column grid: Name, Description, Output
left_grid = snack.Grid(1, 6)
left_grid.setField(snack.Label("Name:"), 0, 0, anchorLeft=1)
left_grid.setField(name_entry, 0, 1, anchorLeft=1)
left_grid.setField(snack.Label("Description:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(desc_entry, 0, 3, anchorLeft=1)
left_grid.setField(snack.Label("Output:"), 0, 4, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(output_entry, 0, 5, anchorLeft=1)
# Right column grid: Arguments and Steps
right_grid = snack.Grid(1, 4)
right_grid.setField(snack.Label("Arguments:"), 0, 0, anchorLeft=1)
right_grid.setField(args_listbox, 0, 1, anchorLeft=1)
right_grid.setField(snack.Label("Execution Steps:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
right_grid.setField(steps_listbox, 0, 3, anchorLeft=1)
# Main grid
grid = snack.GridForm(self.screen, title, 2, 2)
grid.add(left_grid, 0, 0, anchorTop=1, padding=(0, 0, 2, 0))
grid.add(right_grid, 1, 0, anchorTop=1)
# Buttons at bottom
buttons = snack.ButtonBar(self.screen, [("Save", "save"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1, growx=1)
# Handle hotkeys for listbox interaction
form = grid.form
while True:
result = form.run()
# Update tool from entries
if not is_edit:
tool.name = name_entry.value().strip()
tool.description = desc_entry.value().strip()
tool.output = output_entry.value().strip()
# Check what was activated
if result == args_listbox:
selected = args_listbox.current()
if selected == "add":
new_arg = self.add_argument_dialog()
if new_arg:
tool.arguments.append(new_arg)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing argument
action = self.arg_action_menu(tool.arguments[selected])
if action == "edit":
updated = self.add_argument_dialog(tool.arguments[selected])
if updated:
tool.arguments[selected] = updated
elif action == "delete":
tool.arguments.pop(selected)
break # Refresh form
elif result == steps_listbox:
selected = steps_listbox.current()
if selected == "add":
new_step = self.add_step_dialog(tool)
if new_step:
tool.steps.append(new_step)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing step
action = self.step_action_menu(tool.steps[selected], selected, len(tool.steps))
if action == "edit":
updated = self.add_step_dialog(tool, tool.steps[selected], selected)
if updated:
tool.steps[selected] = updated
elif action == "delete":
tool.steps.pop(selected)
elif action == "move_up" and selected > 0:
tool.steps[selected], tool.steps[selected-1] = tool.steps[selected-1], tool.steps[selected]
elif action == "move_down" and selected < len(tool.steps) - 1:
tool.steps[selected], tool.steps[selected+1] = tool.steps[selected+1], tool.steps[selected]
break # Refresh form
elif buttons.buttonPressed(result) == "save":
if not tool.name:
self.message_box("Error", "Tool name is required.")
break
if not is_edit and tool_exists(tool.name):
if not self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?"):
break
self.screen.popWindow()
save_tool(tool)
self.message_box("Success", f"Tool '{tool.name}' saved!")
return tool
elif buttons.buttonPressed(result) == "cancel" or result == "ESC":
self.screen.popWindow()
return None
else:
# Tab between fields, continue
continue
self.screen.popWindow()
def arg_action_menu(self, arg: ToolArgument) -> Optional[str]:
"""Show action menu for an argument."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Argument: {arg.flag}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def step_action_menu(self, step: Step, idx: int, total: int) -> Optional[str]:
"""Show action menu for a step."""
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
items = [("Edit", "edit")]
if idx > 0:
items.append(("Move Up", "move_up"))
if idx < total - 1:
items.append(("Move Down", "move_down"))
items.append(("Delete", "delete"))
items.append(("Cancel", "cancel"))
listbox = snack.Listbox(height=len(items), width=20, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, f"Step {idx+1}: {step_type}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def select_and_edit_tool(self):
"""Select a tool to edit."""
tools = list_tools()
if not tools:
self.message_box("Edit Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
tool = load_tool(name)
desc = tool.description[:30] if tool and tool.description else "No description"
listbox.append(f"{name}: {desc}", name)
grid = snack.GridForm(self.screen, "Select Tool to Edit", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
self.tool_builder(tool)
def select_and_delete_tool(self):
"""Select a tool to delete."""
tools = list_tools()
if not tools:
self.message_box("Delete Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Delete", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Delete", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
if self.yes_no("Confirm", f"Delete tool '{selected}'?"):
if delete_tool(selected):
self.message_box("Deleted", f"Tool '{selected}' deleted.")
def show_tools_list(self):
"""Show list of all tools."""
tools = list_tools()
if not tools:
self.message_box("Tools", "No tools found.\n\nCreate one from the main menu.")
return
text = ""
for name in tools:
tool = load_tool(name)
if tool:
text += f"{name}\n"
text += f" {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(a.flag for a in tool.arguments)
text += f" Args: {args}\n"
if tool.steps:
steps = []
for s in tool.steps:
if isinstance(s, PromptStep):
steps.append(f"P:{s.provider}")
else:
steps.append("C")
text += f" Steps: {' -> '.join(steps)}\n"
text += "\n"
snack.ButtonChoiceWindow(self.screen, "Available Tools", text.strip(), ["OK"])
def select_and_test_tool(self):
"""Select a tool to test."""
tools = list_tools()
if not tools:
self.message_box("Test Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Test", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Test", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
test_input = self.input_box("Test Input", "Enter test input:", "Hello world")
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\nOutput:\n{output[:500]}"
self.message_box("Test Result", result_text)
def manage_providers(self):
"""Manage providers menu."""
while True:
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 2, 10), width=50, returnExit=1, scroll=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add Provider ]", "__add__")
listbox.append("[ <- Back ]", "__back__")
grid = snack.GridForm(self.screen, "Manage Providers", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
selected = listbox.current()
if selected == "__back__":
break
elif selected == "__add__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
self.message_box("Success", f"Provider '{provider.name}' added.")
else:
# Edit or delete
provider = get_provider(selected)
if provider:
action = self.provider_action_menu(provider)
if action == "edit":
updated = self.add_provider_dialog() # TODO: pass existing
if updated:
add_provider(updated)
elif action == "delete":
if self.yes_no("Confirm", f"Delete provider '{selected}'?"):
delete_provider(selected)
def provider_action_menu(self, provider: Provider) -> Optional[str]:
"""Show action menu for a provider."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Provider: {provider.name}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def run_ui():
"""Entry point for the snack UI."""
ui = SmartToolsUI()
ui.run()
if __name__ == "__main__":
run_ui()
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
"""Web UI blueprint for SmartTools."""
import os
from flask import Blueprint
# Use absolute paths for templates and static folders
_pkg_dir = os.path.dirname(os.path.abspath(__file__))
web_bp = Blueprint(
"web",
__name__,
template_folder=os.path.join(_pkg_dir, "templates"),
static_folder=os.path.join(_pkg_dir, "static"),
static_url_path="/web-static",
)
+66
View File
@@ -0,0 +1,66 @@
"""Web app factory for SmartTools UI."""
from __future__ import annotations
import os
import secrets
from flask import Flask, render_template, session
from smarttools.registry import app as registry_app
from . import web_bp
from .auth import login, register, logout
from .filters import register_filters
from .seo import sitemap_response, robots_txt
from .sessions import SQLiteSessionInterface, cleanup_expired_sessions
def create_web_app() -> Flask:
app = registry_app.create_app()
# Import routes BEFORE registering blueprint
from . import routes # noqa: F401
app.register_blueprint(web_bp)
# Session configuration
app.session_interface = SQLiteSessionInterface(cookie_name="smarttools_session")
app.config["SESSION_COOKIE_NAME"] = "smarttools_session"
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("SMARTTOOLS_ENV") == "production"
app.config["SHOW_ADS"] = os.environ.get("SMARTTOOLS_SHOW_ADS", "").lower() == "true"
# CSRF token generator
app.config["CSRF_GENERATOR"] = lambda: secrets.token_urlsafe(32)
# Jinja globals
def _csrf_token():
token = session.get("csrf_token")
if not token:
token = app.config["CSRF_GENERATOR"]()
session["csrf_token"] = token
return token
app.jinja_env.globals["csrf_token"] = _csrf_token
register_filters(app)
cleanup_expired_sessions()
# SEO routes
app.add_url_rule("/sitemap.xml", endpoint="web.sitemap", view_func=sitemap_response)
app.add_url_rule("/robots.txt", endpoint="web.robots", view_func=robots_txt)
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
return render_template("errors/404.html"), 404
@app.errorhandler(500)
def internal_error(error):
return render_template("errors/500.html"), 500
return app
if __name__ == "__main__":
create_web_app().run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))
+103
View File
@@ -0,0 +1,103 @@
"""Web UI authentication routes."""
from __future__ import annotations
from typing import Dict
from flask import current_app, redirect, render_template, request, session, url_for
from . import web_bp
def _api_post(path: str, payload: Dict) -> Dict:
client = current_app.test_client()
response = client.post(path, json=payload)
return {
"status": response.status_code,
"data": response.get_json(silent=True) or {},
}
def _csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = current_app.config["CSRF_GENERATOR"]()
session["csrf_token"] = token
return token
def _validate_csrf() -> bool:
form_token = request.form.get("csrf_token", "")
session_token = session.get("csrf_token", "")
return bool(form_token and session_token and form_token == session_token)
@web_bp.route("/login", methods=["GET", "POST"])
def login():
next_url = request.args.get("next") or request.form.get("next")
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/login.html",
errors=["Invalid CSRF token"],
next_url=next_url,
)
email = request.form.get("email", "").strip()
password = request.form.get("password", "")
result = _api_post("/api/v1/login", {"email": email, "password": password})
if result["status"] == 200:
data = result["data"].get("data", {})
session.clear()
session["auth_token"] = data.get("token")
session["publisher"] = data.get("publisher", {})
session["user"] = data.get("publisher", {})
current_app.session_interface.rotate_session(session)
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect(url_for("web.dashboard"))
error = result["data"].get("error", {}).get("message", "Login failed")
return render_template(
"pages/login.html",
errors=[error],
email=email,
next_url=next_url,
)
return render_template("pages/login.html", next_url=next_url)
@web_bp.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/register.html",
errors=["Invalid CSRF token"],
)
payload = {
"email": request.form.get("email", "").strip(),
"password": request.form.get("password", ""),
"slug": request.form.get("slug", "").strip(),
"display_name": request.form.get("display_name", "").strip(),
}
result = _api_post("/api/v1/register", payload)
if result["status"] == 201:
return redirect(url_for("web.login"))
error = result["data"].get("error", {}).get("message", "Registration failed")
return render_template(
"pages/register.html",
errors=[error],
email=payload["email"],
slug=payload["slug"],
display_name=payload["display_name"],
)
return render_template("pages/register.html")
@web_bp.route("/logout", methods=["POST"])
def logout():
if not _validate_csrf():
return redirect(url_for("web.login"))
session.clear()
return redirect(url_for("web.login"))
+140
View File
@@ -0,0 +1,140 @@
"""Jinja2 template filters for the web UI."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from flask import Flask
def timeago(dt: datetime | str | None) -> str:
"""Convert a datetime to a human-readable 'time ago' string.
Examples:
- "just now"
- "2 minutes ago"
- "3 hours ago"
- "yesterday"
- "5 days ago"
- "2 weeks ago"
- "3 months ago"
- "1 year ago"
"""
if dt is None:
return "unknown"
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt.replace("Z", "+00:00"))
except ValueError:
return dt
now = datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
diff = now - dt
seconds = diff.total_seconds()
if seconds < 60:
return "just now"
elif seconds < 3600:
minutes = int(seconds / 60)
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
elif seconds < 86400:
hours = int(seconds / 3600)
return f"{hours} hour{'s' if hours != 1 else ''} ago"
elif seconds < 172800:
return "yesterday"
elif seconds < 604800:
days = int(seconds / 86400)
return f"{days} days ago"
elif seconds < 2592000:
weeks = int(seconds / 604800)
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
elif seconds < 31536000:
months = int(seconds / 2592000)
return f"{months} month{'s' if months != 1 else ''} ago"
else:
years = int(seconds / 31536000)
return f"{years} year{'s' if years != 1 else ''} ago"
def format_number(num: int | float | None) -> str:
"""Format a number with K/M suffixes for readability.
Examples:
- 123 → "123"
- 1234 → "1.2K"
- 12345 → "12.3K"
- 1234567 → "1.2M"
"""
if num is None:
return "0"
num = float(num)
if num >= 1_000_000:
formatted = num / 1_000_000
result = f"{formatted:.1f}".rstrip("0").rstrip(".")
return f"{result}M"
elif num >= 1_000:
formatted = num / 1_000
result = f"{formatted:.1f}".rstrip("0").rstrip(".")
return f"{result}K"
else:
return str(int(num))
def date_format(dt: datetime | str | None, fmt: str = "%b %d, %Y") -> str:
"""Format a datetime as a readable date string.
Args:
dt: The datetime to format
fmt: strftime format string (default: "Jan 01, 2025")
Examples:
- "Jan 15, 2025"
- "Dec 31, 2024"
"""
if dt is None:
return ""
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt.replace("Z", "+00:00"))
except ValueError:
return dt
return dt.strftime(fmt)
def truncate_words(text: str | None, length: int = 20, suffix: str = "...") -> str:
"""Truncate text to a maximum number of words.
Args:
text: The text to truncate
length: Maximum number of words
suffix: String to append if truncated
"""
if not text:
return ""
words = text.split()
if len(words) <= length:
return text
return " ".join(words[:length]) + suffix
def register_filters(app: Flask) -> None:
"""Register all custom filters with a Flask app."""
app.jinja_env.filters["timeago"] = timeago
app.jinja_env.filters["format_number"] = format_number
app.jinja_env.filters["date_format"] = date_format
app.jinja_env.filters["truncate_words"] = truncate_words
# Global functions
app.jinja_env.globals["now"] = datetime.utcnow
+540
View File
@@ -0,0 +1,540 @@
"""Public web routes for the SmartTools UI."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
from markupsafe import Markup, escape
from flask import current_app, redirect, render_template, request, session, url_for
from smarttools.registry.db import connect_db, query_all, query_one
from . import web_bp
def _api_get(path: str, params: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]:
client = current_app.test_client()
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
response = client.get(path, query_string=params or {}, headers=headers)
return response.status_code, response.get_json(silent=True) or {}
def _title_case(value: str) -> str:
return value.replace("-", " ").title()
def _build_pagination(meta: Dict[str, Any]) -> SimpleNamespace:
page = int(meta.get("page", 1))
total_pages = int(meta.get("total_pages", 1))
return SimpleNamespace(
page=page,
pages=total_pages,
has_prev=page > 1,
has_next=page < total_pages,
prev_num=page - 1,
next_num=page + 1,
)
def _render_readme(readme: Optional[str]) -> str:
if not readme:
return ""
escaped = escape(readme)
return Markup("<pre class=\"whitespace-pre-wrap\">") + escaped + Markup("</pre>")
def _load_categories() -> List[SimpleNamespace]:
status, payload = _api_get("/api/v1/categories", params={"per_page": 100})
if status != 200:
return []
categories = []
for item in payload.get("data", []):
name = item.get("name")
if not name:
continue
categories.append(SimpleNamespace(
name=name,
display_name=_title_case(name),
count=item.get("tool_count", 0),
description=item.get("description"),
icon=item.get("icon"),
))
return categories
def _load_publisher(slug: str) -> Optional[Dict[str, Any]]:
conn = connect_db()
try:
row = query_one(
conn,
"SELECT slug, display_name, bio, website, verified, created_at FROM publishers WHERE slug = ?",
[slug],
)
return dict(row) if row else None
finally:
conn.close()
def _load_publisher_tools(slug: str) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"""
WITH latest AS (
SELECT owner, name, MAX(id) AS max_id
FROM tools
WHERE owner = ? AND version NOT LIKE '%-%'
GROUP BY owner, name
)
SELECT t.owner, t.name, t.version, t.description, t.category, t.downloads, t.published_at
FROM tools t
JOIN latest ON t.owner = latest.owner AND t.name = latest.name AND t.id = latest.max_id
ORDER BY t.downloads DESC, t.published_at DESC
""",
[slug],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _load_tool_versions(owner: str, name: str) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"SELECT version, published_at FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC",
[owner, name],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _load_tool_id(owner: str, name: str, version: str) -> Optional[int]:
conn = connect_db()
try:
row = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
return int(row["id"]) if row else None
finally:
conn.close()
def _load_current_publisher() -> Optional[Dict[str, Any]]:
slug = session.get("user", {}).get("slug")
if not slug:
return None
conn = connect_db()
try:
row = query_one(
conn,
"""
SELECT id, slug, display_name, email, verified, bio, website, created_at
FROM publishers
WHERE slug = ?
""",
[slug],
)
return dict(row) if row else None
finally:
conn.close()
def _load_pending_prs(publisher_id: int) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"""
SELECT owner, name, version, pr_url, status, created_at
FROM pending_prs
WHERE publisher_id = ?
ORDER BY created_at DESC
""",
[publisher_id],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _require_login() -> Optional[Any]:
if not session.get("auth_token"):
next_url = request.path
if request.query_string:
next_url = f"{next_url}?{request.query_string.decode('utf-8')}"
return redirect(url_for("web.login", next=next_url))
return None
@web_bp.route("/", endpoint="home")
def home():
status, payload = _api_get("/api/v1/stats/popular", params={"limit": 6})
featured_tools = payload.get("data", []) if status == 200 else []
show_ads = current_app.config.get("SHOW_ADS", False)
return render_template(
"pages/index.html",
featured_tools=featured_tools,
featured_contributor=None,
show_ads=show_ads,
)
def _home_alias():
return home()
web_bp.add_url_rule("/", endpoint="index", view_func=_home_alias)
@web_bp.route("/tools", endpoint="tools")
def tools():
return _render_tools()
def _render_tools(category_override: Optional[str] = None):
page = request.args.get("page", 1)
sort = request.args.get("sort", "downloads")
category = category_override or request.args.get("category")
query = request.args.get("q")
params = {"page": page, "per_page": 12, "sort": sort}
if category:
params["category"] = category
status, payload = _api_get("/api/v1/tools", params=params)
if status != 200:
return render_template("errors/500.html"), 500
meta = payload.get("meta", {})
categories = _load_categories()
return render_template(
"pages/tools.html",
tools=payload.get("data", []),
categories=categories,
current_category=category,
total_count=meta.get("total", 0),
sort=sort,
query=query,
pagination=_build_pagination(meta),
)
def _tools_alias():
return tools()
web_bp.add_url_rule("/tools", endpoint="tools_browse", view_func=_tools_alias)
@web_bp.route("/category/<name>", endpoint="category")
def category(name: str):
query = request.args.get("q")
if query:
return redirect(url_for("web.search", q=query, category=name))
return _render_tools(category_override=name)
@web_bp.route("/search", endpoint="search")
def search():
query = request.args.get("q", "").strip()
page = request.args.get("page", 1)
category = request.args.get("category")
if query:
params = {"q": query, "page": page, "per_page": 12}
if category:
params["category"] = category
status, payload = _api_get("/api/v1/tools/search", params=params)
if status != 200:
return render_template("errors/500.html"), 500
meta = payload.get("meta", {})
return render_template(
"pages/search.html",
query=query,
results=payload.get("data", []),
pagination=_build_pagination(meta),
popular_categories=[],
)
categories = sorted(_load_categories(), key=lambda c: c.count, reverse=True)[:8]
return render_template(
"pages/search.html",
query="",
results=[],
pagination=None,
popular_categories=categories,
)
@web_bp.route("/tools/<owner>/<name>", endpoint="tool_detail")
def tool_detail(owner: str, name: str):
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
tool["tags"] = ", ".join(tool.get("tags", [])) if isinstance(tool.get("tags"), list) else tool.get("tags")
tool["readme_html"] = _render_readme(tool.get("readme"))
tool_id = _load_tool_id(owner, name, tool.get("version", ""))
tool["id"] = tool_id
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
)
@web_bp.route("/tools/<owner>/<name>/versions/<version>", endpoint="tool_version")
def tool_version(owner: str, name: str, version: str):
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}", params={"version": version})
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
tool["tags"] = ", ".join(tool.get("tags", [])) if isinstance(tool.get("tags"), list) else tool.get("tags")
tool["readme_html"] = _render_readme(tool.get("readme"))
tool["id"] = _load_tool_id(owner, name, tool.get("version", version))
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
)
@web_bp.route("/publishers/<slug>", endpoint="publisher")
def publisher(slug: str):
publisher_row = _load_publisher(slug)
if not publisher_row:
return render_template("errors/404.html"), 404
tools = _load_publisher_tools(slug)
return render_template(
"pages/publisher.html",
publisher=publisher_row,
tools=tools,
)
def _publisher_alias(slug: str):
return publisher(slug)
web_bp.add_url_rule("/publishers/<slug>", endpoint="publisher_profile", view_func=_publisher_alias)
def _render_dashboard_overview():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
status, payload = _api_get("/api/v1/me/tools", token=token)
tools = payload.get("data", []) if status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/index.html",
user=user,
tools=tools,
stats=stats,
)
@web_bp.route("/dashboard", endpoint="dashboard")
def dashboard():
return _render_dashboard_overview()
@web_bp.route("/dashboard/tools", endpoint="dashboard_tools")
def dashboard_tools():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
status, payload = _api_get("/api/v1/me/tools", token=token)
tools = payload.get("data", []) if status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
pending_prs = []
if user and user.get("id"):
pending_prs = _load_pending_prs(int(user["id"]))
return render_template(
"dashboard/tools.html",
user=user,
tools=tools,
stats=stats,
pending_prs=pending_prs,
)
@web_bp.route("/dashboard/tokens", endpoint="dashboard_tokens")
def dashboard_tokens():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
tools = tools_payload.get("data", []) if tools_status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
for item in tokens:
token_id = str(item.get("id", ""))
item["token_suffix"] = token_id[-6:] if token_id else ""
item["revoked_at"] = item.get("revoked_at")
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/tokens.html",
user=user,
tools=tools,
stats=stats,
tokens=tokens,
)
@web_bp.route("/dashboard/settings", endpoint="dashboard_settings")
def dashboard_settings():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
tools = tools_payload.get("data", []) if tools_status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/settings.html",
user=user,
tools=tools,
stats=stats,
tokens=tokens,
errors=[],
success_message=None,
)
@web_bp.route("/docs", defaults={"path": ""}, endpoint="docs")
@web_bp.route("/docs/<path:path>", endpoint="docs")
def docs(path: str):
toc = [
SimpleNamespace(slug="getting-started", title="Getting Started", children=[
SimpleNamespace(slug="installation", title="Installation"),
SimpleNamespace(slug="first-tool", title="Your First Tool"),
]),
SimpleNamespace(slug="publishing", title="Publishing", children=[]),
SimpleNamespace(slug="providers", title="Providers", children=[]),
]
current = path or "getting-started"
page = SimpleNamespace(
title=_title_case(current),
description="SmartTools documentation",
content_html=f"<p>Documentation for <strong>{escape(current)}</strong> is coming soon.</p>",
headings=[],
parent=None,
)
show_ads = current_app.config.get("SHOW_ADS", False)
return render_template(
"pages/docs.html",
page=page,
toc=toc,
current_path=current,
prev_page=None,
next_page=None,
show_ads=show_ads,
)
@web_bp.route("/tutorials", endpoint="tutorials")
def tutorials():
core_tutorials = []
video_tutorials = []
return render_template(
"pages/tutorials.html",
core_tutorials=core_tutorials,
video_tutorials=video_tutorials,
)
@web_bp.route("/tutorials/<path:path>", endpoint="tutorials_path")
def tutorials_path(path: str):
return render_template(
"pages/content.html",
title=_title_case(path),
body="Tutorial content for this topic is coming soon.",
)
web_bp.add_url_rule("/tutorials/<path:path>", endpoint="tutorial", view_func=tutorials_path)
@web_bp.route("/community", endpoint="community")
def community():
return render_template("pages/community.html")
@web_bp.route("/about", endpoint="about")
def about():
return render_template("pages/about.html")
@web_bp.route("/donate", endpoint="donate")
def donate():
return render_template("pages/donate.html")
@web_bp.route("/privacy", endpoint="privacy")
def privacy():
return render_template("pages/privacy.html")
@web_bp.route("/terms", endpoint="terms")
def terms():
return render_template("pages/terms.html")
@web_bp.route("/forgot-password", endpoint="forgot_password")
def forgot_password():
return render_template(
"pages/content.html",
title="Reset Password",
body="Password resets are not available yet. Please contact support if needed.",
)
+78
View File
@@ -0,0 +1,78 @@
"""SEO helpers for sitemap and robots.txt."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import List
from flask import Response, current_app, url_for
from smarttools.registry.db import connect_db, query_all
SITEMAP_TTL = timedelta(hours=6)
_sitemap_cache = {"generated_at": None, "xml": ""}
def generate_sitemap() -> str:
now = datetime.utcnow()
cached_at = _sitemap_cache.get("generated_at")
if cached_at and now - cached_at < SITEMAP_TTL:
return _sitemap_cache["xml"]
urls: List[str] = []
static_paths = ["/", "/tools", "/docs", "/tutorials", "/about"]
for path in static_paths:
urls.append(_url_entry(path, "daily", "1.0"))
conn = connect_db()
try:
rows = query_all(conn, "SELECT DISTINCT owner, name FROM tools")
for row in rows:
tool_path = url_for("web.tool_detail", owner=row["owner"], name=row["name"], _external=True)
urls.append(_url_entry(tool_path, "daily", "0.9"))
categories = query_all(conn, "SELECT DISTINCT category FROM tools WHERE category IS NOT NULL")
for row in categories:
cat_path = url_for("web.category", name=row["category"], _external=True)
urls.append(_url_entry(cat_path, "weekly", "0.7"))
finally:
conn.close()
xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
xml += "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"
xml += "\n".join(urls)
xml += "\n</urlset>\n"
_sitemap_cache["generated_at"] = now
_sitemap_cache["xml"] = xml
return xml
def sitemap_response() -> Response:
xml = generate_sitemap()
return Response(xml, mimetype="application/xml")
def robots_txt() -> Response:
lines = [
"User-agent: *",
"Allow: /",
"Disallow: /login",
"Disallow: /register",
"Disallow: /dashboard",
"Disallow: /api/",
f"Sitemap: {url_for('web.sitemap', _external=True)}",
]
return Response("\n".join(lines) + "\n", mimetype="text/plain")
def _url_entry(loc: str, changefreq: str, priority: str) -> str:
if not loc.startswith("http"):
loc = url_for("web.index", _external=True).rstrip("/") + loc
return "\n".join([
" <url>",
f" <loc>{loc}</loc>",
f" <changefreq>{changefreq}</changefreq>",
f" <priority>{priority}</priority>",
" </url>",
])
+109
View File
@@ -0,0 +1,109 @@
"""SQLite-backed server-side sessions for the web UI."""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timedelta
from typing import Optional
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
from smarttools.registry.db import connect_db
SESSION_TTL = timedelta(days=7)
class SQLiteSession(CallbackDict, SessionMixin):
def __init__(self, initial=None, session_id: Optional[str] = None):
super().__init__(initial or {})
self.session_id = session_id
class SQLiteSessionInterface(SessionInterface):
def __init__(self, cookie_name: str = "smarttools_session"):
self.cookie_name = cookie_name
def open_session(self, app, request):
session_id = request.cookies.get(self.cookie_name)
if not session_id:
return SQLiteSession(session_id=self._new_session_id())
conn = connect_db()
try:
row = conn.execute(
"SELECT data, expires_at FROM web_sessions WHERE session_id = ?",
[session_id],
).fetchone()
if not row:
return SQLiteSession(session_id=self._new_session_id())
expires_at = self._parse_dt(row["expires_at"])
if expires_at and expires_at < datetime.utcnow():
conn.execute("DELETE FROM web_sessions WHERE session_id = ?", [session_id])
conn.commit()
return SQLiteSession(session_id=self._new_session_id())
data = json.loads(row["data"] or "{}")
return SQLiteSession(initial=data, session_id=session_id)
finally:
conn.close()
def save_session(self, app, session, response):
if session is None:
return
session_id = session.session_id or self._new_session_id()
expires_at = datetime.utcnow() + SESSION_TTL
data = json.dumps(dict(session))
conn = connect_db()
try:
conn.execute(
"""
INSERT INTO web_sessions (session_id, data, created_at, expires_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET data = excluded.data, expires_at = excluded.expires_at
""",
[session_id, data, datetime.utcnow().isoformat(), expires_at.isoformat()],
)
conn.commit()
finally:
conn.close()
response.set_cookie(
self.cookie_name,
session_id,
httponly=True,
samesite="Lax",
secure=app.config.get("SESSION_COOKIE_SECURE", False),
max_age=int(SESSION_TTL.total_seconds()),
)
def rotate_session(self, session) -> None:
session.session_id = self._new_session_id()
@staticmethod
def _new_session_id() -> str:
return uuid.uuid4().hex
@staticmethod
def _parse_dt(value: str | None) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def cleanup_expired_sessions() -> int:
"""Remove expired sessions from the database."""
conn = connect_db()
try:
now = datetime.utcnow().isoformat()
cursor = conn.execute("DELETE FROM web_sessions WHERE expires_at < ?", [now])
conn.commit()
return cursor.rowcount or 0
finally:
conn.close()
File diff suppressed because one or more lines are too long
+430
View File
@@ -0,0 +1,430 @@
/**
* SmartTools Web UI - Main JavaScript
*/
// Copy to clipboard utility
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
if (button) {
const originalText = button.textContent;
button.textContent = 'Copied!';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}
}).catch(err => {
console.error('Failed to copy:', err);
});
}
// Copy code block handler
function copyCode(button) {
const code = button.dataset.code;
navigator.clipboard.writeText(code).then(() => {
const copyIcon = button.querySelector('.copy-icon');
const checkIcon = button.querySelector('.check-icon');
if (copyIcon && checkIcon) {
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
}
});
}
// Format numbers with K/M suffixes
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
// Debounce utility
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Mobile menu toggle
function toggleMobileMenu() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-icon-open');
const closeIcon = document.getElementById('menu-icon-close');
if (menu) {
const isHidden = menu.classList.contains('hidden');
menu.classList.toggle('hidden');
// Toggle icons
if (openIcon && closeIcon) {
if (isHidden) {
openIcon.classList.add('hidden');
closeIcon.classList.remove('hidden');
} else {
openIcon.classList.remove('hidden');
closeIcon.classList.add('hidden');
}
}
}
}
function closeMobileMenu() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-icon-open');
const closeIcon = document.getElementById('menu-icon-close');
if (menu) {
menu.classList.add('hidden');
if (openIcon) openIcon.classList.remove('hidden');
if (closeIcon) closeIcon.classList.add('hidden');
}
}
// Mobile filters toggle (tools page)
function toggleMobileFilters() {
const filters = document.getElementById('mobile-filters');
if (filters) {
filters.classList.toggle('hidden');
}
}
// Mobile TOC toggle (docs page)
function toggleMobileToc() {
const toc = document.getElementById('mobile-toc');
if (toc) {
toc.classList.toggle('hidden');
}
}
// Search modal
function openSearchModal() {
const modal = document.getElementById('search-modal');
if (modal) {
modal.classList.remove('hidden');
const input = modal.querySelector('input[type="text"]');
if (input) input.focus();
}
}
function closeSearchModal() {
const modal = document.getElementById('search-modal');
if (modal) {
modal.classList.add('hidden');
}
}
// Legacy aliases used by templates
function openSearch() {
openSearchModal();
}
function closeSearch() {
closeSearchModal();
}
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// ESC to close modals
if (e.key === 'Escape') {
closeSearchModal();
closeMobileMenu();
}
// Cmd/Ctrl + K to open search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
openSearchModal();
}
});
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
// Add smooth scroll behavior
document.documentElement.style.scrollBehavior = 'smooth';
// Initialize any dynamic components
initializeDropdowns();
});
// Dropdown initialization
function initializeDropdowns() {
document.querySelectorAll('[data-dropdown]').forEach(dropdown => {
const toggle = dropdown.querySelector('[data-dropdown-toggle]');
const menu = dropdown.querySelector('[data-dropdown-menu]');
if (toggle && menu) {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', () => {
menu.classList.add('hidden');
});
}
});
}
// Intersection Observer for lazy loading
function initLazyLoad() {
const lazyImages = document.querySelectorAll('[data-lazy]');
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.lazy;
img.removeAttribute('data-lazy');
observer.unobserve(img);
}
});
});
lazyImages.forEach(img => imageObserver.observe(img));
} else {
// Fallback for browsers without IntersectionObserver
lazyImages.forEach(img => {
img.src = img.dataset.lazy;
});
}
}
// Toast notifications
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container') || createToastContainer();
const toast = document.createElement('div');
toast.className = `p-4 rounded-lg shadow-lg text-white mb-2 transform transition-all duration-300 translate-x-full`;
const colors = {
'success': 'bg-green-600',
'error': 'bg-red-600',
'warning': 'bg-amber-600',
'info': 'bg-indigo-600'
};
toast.classList.add(colors[type] || colors.info);
toast.textContent = message;
container.appendChild(toast);
// Animate in
requestAnimationFrame(() => {
toast.classList.remove('translate-x-full');
});
// Auto dismiss
setTimeout(() => {
toast.classList.add('translate-x-full', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed top-4 right-4 z-50 space-y-2';
document.body.appendChild(container);
return container;
}
// ============================================
// Tool Detail Page Functions
// ============================================
function copyInstall() {
const command = document.getElementById('install-command');
if (command) {
const text = command.textContent || command.innerText;
navigator.clipboard.writeText(text.trim()).then(() => {
showToast('Install command copied!', 'success');
});
}
}
function openReportModal() {
const modal = document.getElementById('report-modal');
if (modal) modal.classList.remove('hidden');
}
function closeReportModal() {
const modal = document.getElementById('report-modal');
if (modal) modal.classList.add('hidden');
}
// ============================================
// Dashboard - Token Management
// ============================================
function openCreateTokenModal() {
const modal = document.getElementById('create-token-modal');
if (modal) modal.classList.remove('hidden');
}
function closeCreateTokenModal() {
const modal = document.getElementById('create-token-modal');
if (modal) modal.classList.add('hidden');
}
function closeTokenCreatedModal() {
const modal = document.getElementById('token-created-modal');
if (modal) modal.classList.add('hidden');
// Reload to show new token in list
window.location.reload();
}
function copyNewToken() {
const tokenEl = document.getElementById('new-token-value');
if (tokenEl) {
navigator.clipboard.writeText(tokenEl.textContent.trim()).then(() => {
showToast('Token copied to clipboard!', 'success');
});
}
}
function revokeToken(tokenId, tokenName) {
if (confirm(`Are you sure you want to revoke the token "${tokenName}"? This cannot be undone.`)) {
fetch(`/api/v1/tokens/${tokenId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
showToast('Token revoked successfully', 'success');
window.location.reload();
} else {
showToast('Failed to revoke token', 'error');
}
}).catch(() => {
showToast('Failed to revoke token', 'error');
});
}
}
// ============================================
// Dashboard - Tool Management
// ============================================
let deprecateToolOwner = '';
let deprecateToolName = '';
function openDeprecateModal(owner, name, isDeprecated) {
deprecateToolOwner = owner;
deprecateToolName = name;
const modal = document.getElementById('deprecate-modal');
const title = document.getElementById('deprecate-modal-title');
const btn = document.getElementById('deprecate-submit-btn');
if (modal) {
if (title) {
title.textContent = isDeprecated ? `Restore ${name}` : `Deprecate ${name}`;
}
if (btn) {
btn.textContent = isDeprecated ? 'Restore Tool' : 'Deprecate Tool';
btn.classList.toggle('bg-red-600', !isDeprecated);
btn.classList.toggle('bg-green-600', isDeprecated);
}
modal.classList.remove('hidden');
}
}
function closeDeprecateModal() {
const modal = document.getElementById('deprecate-modal');
if (modal) modal.classList.add('hidden');
deprecateToolOwner = '';
deprecateToolName = '';
}
// ============================================
// Dashboard - Settings
// ============================================
function resendVerification() {
fetch('/api/v1/me/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
showToast('Verification email sent!', 'success');
} else {
showToast('Failed to send verification email', 'error');
}
}).catch(() => {
showToast('Failed to send verification email', 'error');
});
}
function confirmDeleteAccount() {
if (confirm('Are you absolutely sure you want to delete your account? This will permanently delete all your tools and cannot be undone.')) {
const confirmInput = prompt('Type "DELETE" to confirm:');
if (confirmInput === 'DELETE') {
fetch('/api/v1/me', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
window.location.href = '/';
} else {
showToast('Failed to delete account', 'error');
}
}).catch(() => {
showToast('Failed to delete account', 'error');
});
}
}
}
// ============================================
// Search Functionality
// ============================================
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
if (searchInput) {
searchInput.addEventListener('input', debounce(function() {
const query = this.value.trim();
if (query.length < 2) {
searchResults.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">Start typing to search...</p>';
return;
}
fetch(`/api/v1/tools/search?q=${encodeURIComponent(query)}&limit=10`)
.then(response => response.json())
.then(data => {
if (data.data && data.data.length > 0) {
searchResults.innerHTML = data.data.map(tool => `
<a href="/tools/${tool.owner}/${tool.name}"
class="block p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div class="font-medium text-gray-900">${tool.owner}/${tool.name}</div>
<div class="text-sm text-gray-500 truncate">${tool.description || 'No description'}</div>
</a>
`).join('');
} else {
searchResults.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">No tools found</p>';
}
})
.catch(() => {
searchResults.innerHTML = '<p class="text-sm text-red-500 text-center py-8">Search failed</p>';
});
}, 300));
}
+16
View File
@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom component classes */
.btn-primary {
@apply bg-primary text-white font-semibold px-4 py-2 rounded-md shadow-sm hover:opacity-90;
}
.btn-secondary {
@apply border border-secondary text-secondary font-semibold px-4 py-2 rounded-md hover:bg-secondary hover:text-white;
}
.card {
@apply bg-white border border-gray-200 rounded-md shadow-sm p-4;
}
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SmartTools{% endblock %} - Build Custom AI Commands</title>
<!-- Meta tags -->
<meta name="description" content="{% block meta_description %}Create Unix-style pipeable tools that work with any AI provider. Provider-agnostic, composable, and community-driven.{% endblock %}">
{% block meta_extra %}{% endblock %}
<!-- Open Graph -->
<meta property="og:title" content="{% block og_title %}SmartTools{% endblock %}">
<meta property="og:description" content="{% block og_description %}Build custom AI commands in YAML{% endblock %}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ request.url }}">
{% block og_extra %}{% endblock %}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{% block twitter_title %}SmartTools{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}Build custom AI commands in YAML{% endblock %}">
{% block twitter_extra %}{% endblock %}
<!-- Canonical URL -->
<link rel="canonical" href="{% block canonical %}{{ request.url }}{% endblock %}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="{{ url_for('web.static', filename='css/main.css') }}">
{% block styles %}{% endblock %}
<!-- Schema.org -->
{% block schema %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "SmartTools",
"url": "{{ request.host_url }}",
"description": "Build custom AI commands in YAML"
}
</script>
{% endblock %}
</head>
<body class="min-h-screen bg-gray-50 text-gray-900 font-sans antialiased">
<!-- Skip to content link for accessibility -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-indigo-600 text-white px-4 py-2 rounded-md z-50">
Skip to content
</a>
<!-- Header -->
{% include "components/header.html" %}
<!-- Main content -->
<main id="main-content" class="{% block main_class %}{% endblock %}">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
{% include "components/footer.html" %}
<!-- Cookie consent banner (if not consented) -->
{% if not session.get('consent_given') %}
{% include "components/consent_banner.html" %}
{% endif %}
<!-- Scripts -->
<script src="{{ url_for('web.static', filename='js/main.js') }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
@@ -0,0 +1,61 @@
{# Callout/alert macros for documentation and content pages #}
{# Info callout #}
{% macro info(title='Note', content='') %}
<div class="my-4 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-blue-800">{{ title }}</h4>
<p class="mt-1 text-sm text-blue-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Warning callout #}
{% macro warning(title='Warning', content='') %}
<div class="my-4 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-amber-800">{{ title }}</h4>
<p class="mt-1 text-sm text-amber-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Error callout #}
{% macro error(title='Important', content='') %}
<div class="my-4 p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-red-800">{{ title }}</h4>
<p class="mt-1 text-sm text-red-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Tip callout #}
{% macro tip(title='Tip', content='') %}
<div class="my-4 p-4 bg-green-50 border-l-4 border-green-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-green-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-green-800">{{ title }}</h4>
<p class="mt-1 text-sm text-green-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,47 @@
{# Code block macro with syntax highlighting and copy button #}
{% macro code(content, language='', filename='', show_line_numbers=false) %}
<div class="my-4 relative group">
{% if filename %}
<div class="bg-gray-800 text-gray-400 text-xs px-4 py-2 rounded-t-lg border-b border-gray-700">
{{ filename }}
</div>
{% endif %}
<div class="relative">
<pre class="{% if filename %}rounded-t-none{% endif %} bg-gray-900 text-gray-100 text-sm p-4 rounded-lg overflow-x-auto"><code class="language-{{ language }}">{{ content }}</code></pre>
<button type="button"
onclick="copyCode(this)"
data-code="{{ content|e }}"
class="absolute top-2 right-2 p-2 text-gray-400 hover:text-white bg-gray-800 rounded opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy code">
<svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
{% endmacro %}
{# Inline code #}
{% macro inline(content) %}
<code class="px-1.5 py-0.5 bg-gray-100 text-gray-800 text-sm font-mono rounded">{{ content }}</code>
{% endmacro %}
{# Install command (special styling) #}
{% macro install(command) %}
<div class="my-4 flex items-center bg-gray-100 rounded-lg p-4 group">
<span class="text-gray-400 mr-2 select-none">$</span>
<code class="flex-1 text-gray-800 font-mono text-sm">{{ command }}</code>
<button type="button"
onclick="copyToClipboard('{{ command }}')"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy command">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
{% endmacro %}
@@ -0,0 +1,148 @@
{# Cookie consent banner #}
<div id="consent-banner"
class="fixed bottom-0 inset-x-0 z-50 bg-white border-t border-gray-200 shadow-lg transform transition-transform duration-300"
role="dialog"
aria-labelledby="consent-title"
aria-describedby="consent-description">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="flex-1">
<h3 id="consent-title" class="text-sm font-semibold text-gray-900">
We value your privacy
</h3>
<p id="consent-description" class="mt-1 text-sm text-gray-600">
We use cookies to improve your experience and for analytics.
You can choose to accept all cookies or manage your preferences.
<a href="{{ url_for('web.privacy') }}" class="text-indigo-600 hover:text-indigo-800 underline">Learn more</a>
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<button type="button"
onclick="openConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Manage preferences
</button>
<button type="button"
onclick="rejectConsent()"
class="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
Reject all
</button>
<button type="button"
onclick="acceptAllConsent()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Accept all
</button>
</div>
</div>
</div>
</div>
<!-- Consent preferences modal -->
<div id="consent-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-modal="true"
role="dialog"
aria-labelledby="consent-modal-title">
<div class="min-h-screen px-4 text-center">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeConsentPreferences()"></div>
<!-- Modal -->
<div class="inline-block w-full max-w-lg my-8 text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="p-6">
<h3 id="consent-modal-title" class="text-lg font-semibold text-gray-900">
Cookie Preferences
</h3>
<p class="mt-2 text-sm text-gray-600">
Choose which cookies you want to accept. Essential cookies cannot be disabled as they are required for the site to function.
</p>
<div class="mt-6 space-y-4">
<!-- Essential cookies (always on) -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h4 class="text-sm font-medium text-gray-900">Essential cookies</h4>
<p class="mt-1 text-sm text-gray-500">Required for the website to function properly.</p>
</div>
<input type="checkbox" checked disabled class="mt-1 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<!-- Analytics cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-analytics" class="text-sm font-medium text-gray-900 cursor-pointer">Analytics cookies</label>
<p class="mt-1 text-sm text-gray-500">Help us understand how visitors use our site.</p>
</div>
<input type="checkbox"
id="consent-analytics"
name="analytics"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
<!-- Advertising cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-ads" class="text-sm font-medium text-gray-900 cursor-pointer">Advertising cookies</label>
<p class="mt-1 text-sm text-gray-500">Used to show relevant ads and support the project.</p>
</div>
<input type="checkbox"
id="consent-ads"
name="ads"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Cancel
</button>
<button type="button"
onclick="saveConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Save preferences
</button>
</div>
</div>
</div>
</div>
<script>
function acceptAllConsent() {
saveConsent(true, true);
}
function rejectConsent() {
saveConsent(false, false);
}
function openConsentPreferences() {
document.getElementById('consent-modal').classList.remove('hidden');
}
function closeConsentPreferences() {
document.getElementById('consent-modal').classList.add('hidden');
}
function saveConsentPreferences() {
const analytics = document.getElementById('consent-analytics').checked;
const ads = document.getElementById('consent-ads').checked;
saveConsent(analytics, ads);
closeConsentPreferences();
}
function saveConsent(analytics, ads) {
fetch('/api/v1/consent', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({analytics: analytics, ads: ads})
}).then(() => {
document.getElementById('consent-banner').style.transform = 'translateY(100%)';
setTimeout(() => {
document.getElementById('consent-banner').remove();
}, 300);
});
}
</script>
@@ -0,0 +1,58 @@
{# Contributor card macro #}
{% macro contributor_card(contributor=none, display_name=none, slug=none, bio=none, website=none, verified=none, featured=false) %}
{% if contributor is none %}
{% set contributor = {
"display_name": display_name,
"slug": slug,
"bio": bio,
"website": website,
"verified": verified
} %}
{% endif %}
{% if featured %}
<!-- Featured contributor (horizontal layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 flex items-center space-x-6">
<!-- Avatar -->
<div class="flex-shrink-0 w-16 h-16 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center">
<span class="text-white text-2xl font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
<p class="mt-2 text-gray-600 line-clamp-2">{{ contributor.bio or 'Active community member and tool creator.' }}</p>
</div>
<!-- Actions -->
<div class="flex-shrink-0">
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="inline-flex items-center px-4 py-2 border border-indigo-600 text-sm font-medium rounded-md text-indigo-600 hover:bg-indigo-50 transition-colors">
View Profile
</a>
</div>
</article>
{% else %}
<!-- Regular contributor card (vertical layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-4 text-center">
<!-- Avatar -->
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center mx-auto">
<span class="text-white text-lg font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<h3 class="mt-3 font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
{% if contributor.bio %}
<p class="mt-2 text-sm text-gray-600 line-clamp-2">{{ contributor.bio }}</p>
{% endif %}
<!-- Link -->
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="mt-3 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
View Profile
</a>
</article>
{% endif %}
{% endmacro %}
@@ -0,0 +1,59 @@
<footer class="bg-slate-800 text-white mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="{{ url_for('web.index') }}" class="text-xl font-bold">SmartTools</a>
<p class="mt-2 text-gray-400 text-sm max-w-md">
Build custom AI commands in YAML. Create Unix-style pipeable tools that work with any AI provider.
Provider-agnostic, composable, and community-driven.
</p>
<div class="mt-4 flex space-x-4">
<a href="https://github.com/rob/smarttools" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-white transition-colors" aria-label="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
</a>
<a href="https://twitter.com/smarttools" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-white transition-colors" aria-label="Twitter">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
</svg>
</a>
</div>
</div>
<!-- Resources -->
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider">Resources</h3>
<ul class="mt-4 space-y-2">
<li><a href="{{ url_for('web.docs') }}" class="text-gray-400 hover:text-white text-sm">Documentation</a></li>
<li><a href="{{ url_for('web.tutorials') }}" class="text-gray-400 hover:text-white text-sm">Tutorials</a></li>
<li><a href="{{ url_for('web.tools_browse') }}" class="text-gray-400 hover:text-white text-sm">Registry</a></li>
<li><a href="{{ url_for('web.community') }}" class="text-gray-400 hover:text-white text-sm">Community</a></li>
</ul>
</div>
<!-- Legal -->
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider">Legal</h3>
<ul class="mt-4 space-y-2">
<li><a href="{{ url_for('web.about') }}" class="text-gray-400 hover:text-white text-sm">About</a></li>
<li><a href="{{ url_for('web.privacy') }}" class="text-gray-400 hover:text-white text-sm">Privacy Policy</a></li>
<li><a href="{{ url_for('web.terms') }}" class="text-gray-400 hover:text-white text-sm">Terms of Service</a></li>
<li><a href="{{ url_for('web.donate') }}" class="text-gray-400 hover:text-white text-sm">Donate</a></li>
</ul>
</div>
</div>
<div class="mt-8 pt-8 border-t border-slate-700 flex flex-col sm:flex-row justify-between items-center">
<p class="text-gray-400 text-sm">
&copy; {{ now().year }} SmartTools. Open source under MIT License.
</p>
<p class="text-gray-500 text-xs mt-2 sm:mt-0">
Made with care for the developer community.
</p>
</div>
</div>
</footer>
@@ -0,0 +1,169 @@
{# Reusable form macros #}
{# Text input field #}
{% macro text_input(name, label, type='text', placeholder='', value='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<input type="{{ type }}"
name="{{ name }}"
id="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Textarea field #}
{% macro textarea(name, label, placeholder='', value='', required=false, rows=4, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<textarea name="{{ name }}"
id="{{ name }}"
rows="{{ rows }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-y
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">{{ value }}</textarea>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Select dropdown #}
{% macro select(name, label, options, selected='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<select name="{{ name }}"
id="{{ name }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors bg-white
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% for value, label in options %}
<option value="{{ value }}" {% if value == selected %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Checkbox #}
{% macro checkbox(name, label, checked=false, help=none) %}
<div class="mb-4 flex items-start">
<input type="checkbox"
name="{{ name }}"
id="{{ name }}"
{% if checked %}checked{% endif %}
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<div class="ml-3">
<label for="{{ name }}" class="text-sm font-medium text-gray-700">{{ label }}</label>
{% if help %}
<p class="text-sm text-gray-500">{{ help }}</p>
{% endif %}
</div>
</div>
{% endmacro %}
{# Primary button #}
{% macro button_primary(text, type='submit', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Secondary button #}
{% macro button_secondary(text, type='button', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border-2 border-cyan-500 text-base font-medium rounded-md text-cyan-600 bg-transparent hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Danger button #}
{% macro button_danger(text, type='button', name=none, disabled=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Ghost/link button #}
{% macro button_ghost(text, href='#') %}
<a href="{{ href }}"
class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
{{ text }}
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% endmacro %}
{# Form error alert #}
{% macro form_errors(errors) %}
{% if errors %}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">There were errors with your submission</h3>
<ul class="mt-2 text-sm text-red-700 list-disc list-inside">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% endmacro %}
{# Success alert #}
{% macro success_alert(message) %}
{% if message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg" role="alert">
<div class="flex items-center">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="ml-3 text-sm font-medium text-green-800">{{ message }}</p>
</div>
</div>
{% endif %}
{% endmacro %}
@@ -0,0 +1,183 @@
<header class="bg-slate-800 text-white sticky top-0 z-40">
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" aria-label="Main navigation">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex-shrink-0">
<a href="{{ url_for('web.index') }}" class="text-xl font-bold hover:text-indigo-400 transition-colors">
SmartTools
</a>
</div>
<!-- Desktop navigation -->
<div class="hidden md:flex items-center space-x-1">
<a href="{{ url_for('web.docs') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/docs') %}bg-slate-700{% endif %}">
Docs
</a>
<a href="{{ url_for('web.tutorials') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/tutorials') %}bg-slate-700{% endif %}">
Tutorials
</a>
<a href="{{ url_for('web.tools_browse') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}">
Registry
</a>
<a href="{{ url_for('web.community') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
</a>
<a href="{{ url_for('web.about') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path == '/about' %}bg-slate-700{% endif %}">
About
</a>
</div>
<!-- Right side: Search + Auth -->
<div class="hidden md:flex items-center space-x-4">
<!-- Search button -->
<button type="button"
onclick="openSearch()"
class="p-2 rounded-md hover:bg-slate-700 transition-colors"
aria-label="Search">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
<!-- Donate button -->
<a href="{{ url_for('web.donate') }}"
class="px-3 py-2 rounded-md text-sm font-medium bg-indigo-600 hover:bg-indigo-700 transition-colors">
Donate
</a>
<!-- Auth links -->
{% if session.get('user') %}
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-slate-700 transition-colors">
<span class="text-sm font-medium">{{ session.user.display_name }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div x-show="open"
@click.away="open = false"
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<a href="{{ url_for('web.dashboard') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
<a href="{{ url_for('web.dashboard_settings') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a>
<hr class="my-1">
<form action="{{ url_for('web.logout') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Sign out
</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('web.login') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors">
Sign in
</a>
{% endif %}
</div>
<!-- Mobile menu button -->
<div class="md:hidden">
<button type="button"
onclick="toggleMobileMenu()"
class="p-2 rounded-md hover:bg-slate-700 transition-colors"
aria-expanded="false"
aria-controls="mobile-menu"
aria-label="Toggle menu">
<svg id="menu-icon-open" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg id="menu-icon-close" class="w-6 h-6 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden pb-4">
<div class="space-y-1">
<a href="{{ url_for('web.docs') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/docs') %}bg-slate-700{% endif %}">
Docs
</a>
<a href="{{ url_for('web.tutorials') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/tutorials') %}bg-slate-700{% endif %}">
Tutorials
</a>
<a href="{{ url_for('web.tools_browse') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}">
Registry
</a>
<a href="{{ url_for('web.community') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
</a>
<a href="{{ url_for('web.about') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path == '/about' %}bg-slate-700{% endif %}">
About
</a>
<hr class="border-slate-600 my-2">
<a href="{{ url_for('web.donate') }}"
class="block px-3 py-2 rounded-md text-base font-medium bg-indigo-600 hover:bg-indigo-700 mx-3">
Donate
</a>
{% if session.get('user') %}
<a href="{{ url_for('web.dashboard') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700">
Dashboard
</a>
<form action="{{ url_for('web.logout') }}" method="POST" class="px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-base font-medium hover:text-indigo-400">
Sign out
</button>
</form>
{% else %}
<a href="{{ url_for('web.login') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700">
Sign in
</a>
{% endif %}
</div>
</div>
</nav>
</header>
<!-- Search modal (hidden by default) -->
<div id="search-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true">
<div class="min-h-screen px-4 text-center">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeSearch()"></div>
<!-- Modal content -->
<div class="inline-block w-full max-w-2xl my-8 text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="p-4">
<div class="relative">
<svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text"
id="search-input"
placeholder="Search tools, docs, tutorials..."
class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autocomplete="off">
</div>
<div id="search-results" class="mt-4 max-h-96 overflow-y-auto">
<!-- Search results will be inserted here -->
<p class="text-sm text-gray-500 text-center py-8">Start typing to search...</p>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 rounded-b-lg flex justify-between items-center text-sm text-gray-500">
<span>Press <kbd class="px-2 py-1 bg-gray-200 rounded">ESC</kbd> to close</span>
<button onclick="closeSearch()" class="text-gray-500 hover:text-gray-700">Close</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,70 @@
{# Tool card macro #}
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none) %}
{% if tool is none %}
{% set tool = {
"owner": owner,
"name": name,
"description": description,
"category": category,
"downloads": downloads,
"version": version
} %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-4 relative">
<!-- Category badge -->
{% if tool.category %}
<span class="absolute top-3 right-3 px-2 py-1 bg-cyan-500 text-white text-xs font-medium rounded">
{{ tool.category }}
</span>
{% endif %}
<!-- Tool icon and name -->
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold text-sm">{{ tool.name[0]|upper }}</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="hover:text-indigo-600 transition-colors">
{{ tool.name }}
</a>
</h3>
<p class="text-sm text-gray-500">by {{ tool.owner }}</p>
</div>
</div>
<!-- Description -->
<p class="mt-3 text-sm text-gray-600 line-clamp-2">
{{ tool.description or 'No description available.' }}
</p>
<!-- Meta info -->
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ tool.downloads|default(0) }} downloads
</span>
<span class="text-xs text-gray-400">v{{ tool.version }}</span>
</div>
<!-- Install command -->
<div class="mt-4">
<div class="flex items-center bg-gray-100 rounded px-3 py-2 group">
<code class="text-xs text-gray-700 flex-1 truncate font-mono">
smarttools install {{ tool.owner }}/{{ tool.name }}
</code>
<button type="button"
onclick="copyToClipboard('smarttools install {{ tool.owner }}/{{ tool.name }}')"
class="ml-2 p-1 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy install command">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
</article>
{% endmacro %}
@@ -0,0 +1,53 @@
{# Tutorial card macro #}
{% macro tutorial_card(tutorial=none, title=none, description=none, href=none, slug=none, thumbnail=none, step_number=none) %}
{% if tutorial is none %}
{% set tutorial = {
"slug": slug,
"title": title,
"description": description,
"thumbnail": thumbnail
} %}
{% endif %}
{% if href is none and tutorial.slug %}
{% set href = url_for('web.tutorial', slug=tutorial.slug) %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<!-- Thumbnail (optional) -->
{% if tutorial.thumbnail %}
<div class="aspect-video bg-gray-100 relative">
<img src="{{ tutorial.thumbnail }}"
alt="{{ tutorial.title }}"
class="w-full h-full object-cover"
loading="lazy">
</div>
{% else %}
<div class="aspect-video bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center">
<svg class="w-12 h-12 text-white opacity-75" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
{% endif %}
<div class="p-4">
{% if step_number %}
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-500 mb-2">Step {{ step_number }}</p>
{% endif %}
<h3 class="text-lg font-semibold text-gray-900">
<a href="{{ href or '#' }}"
class="hover:text-indigo-600 transition-colors">
{{ tutorial.title }}
</a>
</h3>
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
{{ tutorial.description or 'Learn how to use SmartTools effectively.' }}
</p>
<a href="{{ href or '#' }}"
class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
Read More
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</article>
{% endmacro %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Dashboard Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-1 text-gray-600">Welcome back, {{ user.display_name }}</p>
{% endblock %}
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Sidebar Navigation -->
<aside>
<nav class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<a href="{{ url_for('web.dashboard') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'overview' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
Overview
</a>
<a href="{{ url_for('web.dashboard_tools') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tools' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
My Tools
</a>
<a href="{{ url_for('web.dashboard_tokens') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tokens' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
API Tokens
</a>
<a href="{{ url_for('web.dashboard_settings') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'settings' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Settings
</a>
</nav>
<!-- Quick Stats (mobile hidden) -->
<div class="hidden lg:block mt-6 bg-white rounded-lg border border-gray-200 p-4">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Quick Stats</h3>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Tools</dt>
<dd class="text-sm font-medium text-gray-900">{{ stats.tools_count if stats else 0 }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Downloads</dt>
<dd class="text-sm font-medium text-gray-900">{{ (stats.total_downloads if stats else 0)|format_number }}</dd>
</div>
</dl>
</div>
</aside>
<!-- Main Content -->
<main class="lg:col-span-3">
{% block dashboard_content %}{% endblock %}
</main>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,135 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'overview' %}
{% block title %}Dashboard - SmartTools{% endblock %}
{% block dashboard_content %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-indigo-100 rounded-lg">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Published Tools</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tools_count }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Downloads</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.total_downloads|format_number }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-cyan-100 rounded-lg">
<svg class="w-6 h-6 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">API Tokens</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tokens_count }}</p>
</div>
</div>
</div>
</div>
<!-- Recent Tools -->
<div class="bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Your Tools</h2>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
+ Publish New Tool
</a>
</div>
{% if tools %}
<ul class="divide-y divide-gray-200">
{% for tool in tools[:5] %}
<li class="px-6 py-4 flex items-center justify-between">
<div>
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="font-medium text-gray-900 hover:text-indigo-600">
{{ tool.name }}
</a>
<p class="text-sm text-gray-500">v{{ tool.version }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-900">{{ tool.downloads|format_number }} downloads</p>
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
{% if tools|length > 5 %}
<div class="px-6 py-4 border-t border-gray-200">
<a href="{{ url_for('web.dashboard_tools') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
View all {{ tools|length }} tools
</a>
</div>
{% endif %}
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<p class="mt-4 text-gray-600">You haven't published any tools yet.</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Publish Your First Tool
</a>
</div>
{% endif %}
</div>
<!-- Recent Activity -->
{% if recent_activity %}
<div class="mt-6 bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Recent Activity</h2>
</div>
<ul class="divide-y divide-gray-200">
{% for activity in recent_activity[:5] %}
<li class="px-6 py-4 flex items-start">
<div class="flex-shrink-0">
{% if activity.type == 'download' %}
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
{% elif activity.type == 'publish' %}
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-4">
<p class="text-sm text-gray-900">{{ activity.description }}</p>
<p class="text-xs text-gray-500">{{ activity.timestamp|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,254 @@
{% extends "dashboard/base.html" %}
{% from "components/forms.html" import text_input, textarea, button_primary, form_errors, success_alert %}
{% set active_page = 'settings' %}
{% block title %}Settings - SmartTools Dashboard{% endblock %}
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
<p class="mt-1 text-gray-600">Manage your profile and account settings</p>
{% endblock %}
{% block dashboard_content %}
{{ success_alert(success_message) }}
{{ form_errors(errors) }}
<!-- Profile Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Profile</h2>
<p class="text-sm text-gray-600">This information will be displayed on your public profile.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="profile">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">
Display name
</label>
<input type="text"
name="display_name"
id="display_name"
value="{{ user.display_name }}"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<div class="flex">
<span class="inline-flex items-center px-3 text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-lg">
@
</span>
<input type="text"
name="slug"
id="slug"
value="{{ user.slug }}"
disabled
class="flex-1 px-4 py-2 border border-gray-300 rounded-r-lg bg-gray-50 text-gray-500">
</div>
<p class="mt-1 text-xs text-gray-500">Username cannot be changed</p>
</div>
</div>
<div class="mt-6">
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">
Bio
</label>
<textarea name="bio"
id="bio"
rows="3"
placeholder="Tell others a bit about yourself..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y">{{ user.bio or '' }}</textarea>
<p class="mt-1 text-xs text-gray-500">Brief description for your profile. Max 500 characters.</p>
</div>
<div class="mt-6">
<label for="website" class="block text-sm font-medium text-gray-700 mb-1">
Website
</label>
<input type="url"
name="website"
id="website"
value="{{ user.website or '' }}"
placeholder="https://example.com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Save Profile
</button>
</div>
</form>
</div>
<!-- Email Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Email</h2>
<p class="text-sm text-gray-600">Your email address for account notifications.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ user.email }}</p>
{% if user.verified %}
<p class="text-xs text-green-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
Verified
</p>
{% else %}
<p class="text-xs text-amber-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Not verified
</p>
{% endif %}
</div>
{% if not user.verified %}
<button type="button"
onclick="resendVerification()"
class="text-sm text-indigo-600 hover:text-indigo-800">
Resend verification
</button>
{% endif %}
</div>
</div>
</div>
<!-- Password Change -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Change Password</h2>
<p class="text-sm text-gray-600">Update your password to keep your account secure.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="password">
<div class="space-y-4 max-w-md">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-1">
Current password
</label>
<input type="password"
name="current_password"
id="current_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-1">
New password
</label>
<input type="password"
name="new_password"
id="new_password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<p class="mt-1 text-xs text-gray-500">Minimum 8 characters</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">
Confirm new password
</label>
<input type="password"
name="confirm_password"
id="confirm_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Update Password
</button>
</div>
</form>
</div>
<!-- Danger Zone -->
<div class="bg-white rounded-lg border border-red-200 overflow-hidden">
<div class="px-6 py-4 border-b border-red-200 bg-red-50">
<h2 class="text-lg font-semibold text-red-800">Danger Zone</h2>
<p class="text-sm text-red-600">Irreversible actions that affect your account.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">Delete Account</p>
<p class="text-sm text-gray-500">Permanently delete your account and all associated data.</p>
</div>
<button type="button"
onclick="confirmDeleteAccount()"
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-md hover:bg-red-50">
Delete Account
</button>
</div>
</div>
</div>
<script>
async function resendVerification() {
try {
const response = await fetch('/api/v1/me/resend-verification', {method: 'POST'});
if (response.ok) {
alert('Verification email sent! Please check your inbox.');
} else {
alert('Failed to send verification email. Please try again later.');
}
} catch (err) {
alert('Failed to send verification email. Please try again later.');
}
}
function confirmDeleteAccount() {
const confirmed = confirm(
'Are you absolutely sure you want to delete your account?\n\n' +
'This will permanently delete:\n' +
'- Your profile\n' +
'- All your published tools\n' +
'- All your API tokens\n\n' +
'This action cannot be undone.'
);
if (confirmed) {
const doubleConfirm = prompt('Type "DELETE" to confirm account deletion:');
if (doubleConfirm === 'DELETE') {
deleteAccount();
}
}
}
async function deleteAccount() {
try {
const response = await fetch('/api/v1/me', {method: 'DELETE'});
if (response.ok) {
window.location.href = '/?deleted=1';
} else {
alert('Failed to delete account. Please try again or contact support.');
}
} catch (err) {
alert('Failed to delete account. Please try again or contact support.');
}
}
</script>
{% endblock %}
@@ -0,0 +1,312 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tokens' %}
{% block title %}API Tokens - SmartTools Dashboard{% endblock %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">API Tokens</h1>
<p class="mt-1 text-gray-600">Manage tokens for CLI and API access</p>
</div>
<button type="button"
onclick="openCreateTokenModal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Create Token
</button>
</div>
{% endblock %}
{% block dashboard_content %}
<!-- Info Banner -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-blue-800">About API Tokens</h4>
<p class="mt-1 text-sm text-blue-700">
API tokens are used to authenticate with the SmartTools registry from the CLI.
Use <code class="px-1 bg-blue-100 rounded">smarttools auth login</code> to authenticate,
or set the <code class="px-1 bg-blue-100 rounded">SMARTTOOLS_TOKEN</code> environment variable.
</p>
</div>
</div>
</div>
{% if tokens %}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Used
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for token in tokens %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-10 h-10 bg-cyan-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">{{ token.name }}</p>
<p class="text-xs text-gray-500 font-mono">st_...{{ token.token_suffix }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ token.created_at|date_format }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ token.last_used_at|timeago if token.last_used_at else 'Never' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if token.revoked_at %}
<span class="px-2 py-1 text-xs font-medium text-red-800 bg-red-100 rounded-full">
Revoked
</span>
{% else %}
<span class="px-2 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Active
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{% if not token.revoked_at %}
<button type="button"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
class="text-red-600 hover:text-red-900">
Revoke
</button>
{% else %}
<span class="text-gray-400">Revoked {{ token.revoked_at|timeago }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<!-- Empty State -->
<div class="bg-white rounded-lg border border-gray-200 text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No API tokens</h3>
<p class="mt-2 text-gray-600 max-w-sm mx-auto">
Create an API token to authenticate with the registry from the command line.
</p>
<button type="button"
onclick="openCreateTokenModal()"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Create Your First Token
</button>
</div>
{% endif %}
<!-- Create Token Modal -->
<div id="create-token-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeCreateTokenModal()"></div>
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<form id="create-token-form" onsubmit="createToken(event)">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900">Create API Token</h3>
<p class="mt-2 text-sm text-gray-600">
Give your token a descriptive name so you can identify it later.
</p>
<div class="mt-4">
<label for="token-name" class="block text-sm font-medium text-gray-700 mb-1">
Token name
</label>
<input type="text"
name="name"
id="token-name"
required
placeholder="e.g., Laptop CLI, CI/CD Pipeline"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeCreateTokenModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Create Token
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Token Created Modal (shows the token once) -->
<div id="token-created-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50"></div>
<div class="inline-block w-full max-w-lg my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<div class="p-6">
<div class="flex items-center mb-4">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h3 class="ml-3 text-lg font-semibold text-gray-900">Token Created</h3>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="ml-3 text-sm text-amber-800">
<strong>Copy your token now.</strong> You won't be able to see it again!
</p>
</div>
</div>
<div class="bg-gray-100 rounded-lg p-4">
<div class="flex items-center justify-between">
<code id="new-token-value" class="text-sm font-mono text-gray-800 break-all"></code>
<button type="button"
onclick="copyNewToken()"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 flex-shrink-0">
<svg id="copy-token-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg id="check-token-icon" class="w-5 h-5 hidden text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
<p class="mt-4 text-sm text-gray-600">
To use this token, run:
</p>
<pre class="mt-2 bg-gray-900 text-gray-100 text-sm p-3 rounded-lg overflow-x-auto"><code>export SMARTTOOLS_TOKEN="<span id="token-in-export"></span>"</code></pre>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end">
<button type="button"
onclick="closeTokenCreatedModal()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Done
</button>
</div>
</div>
</div>
</div>
<script>
function openCreateTokenModal() {
document.getElementById('create-token-modal').classList.remove('hidden');
document.getElementById('token-name').focus();
}
function closeCreateTokenModal() {
document.getElementById('create-token-modal').classList.add('hidden');
document.getElementById('token-name').value = '';
}
async function createToken(event) {
event.preventDefault();
const form = event.target;
const name = form.querySelector('[name="name"]').value;
try {
const response = await fetch('/api/v1/tokens', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (response.ok) {
const data = await response.json();
closeCreateTokenModal();
showNewToken(data.token);
} else {
const error = await response.json();
alert(error.error || 'Failed to create token');
}
} catch (err) {
alert('Failed to create token. Please try again.');
}
}
function showNewToken(token) {
document.getElementById('new-token-value').textContent = token;
document.getElementById('token-in-export').textContent = token;
document.getElementById('token-created-modal').classList.remove('hidden');
}
function closeTokenCreatedModal() {
document.getElementById('token-created-modal').classList.add('hidden');
window.location.reload();
}
function copyNewToken() {
const token = document.getElementById('new-token-value').textContent;
navigator.clipboard.writeText(token).then(() => {
document.getElementById('copy-token-icon').classList.add('hidden');
document.getElementById('check-token-icon').classList.remove('hidden');
setTimeout(() => {
document.getElementById('copy-token-icon').classList.remove('hidden');
document.getElementById('check-token-icon').classList.add('hidden');
}, 2000);
});
}
async function revokeToken(tokenId, tokenName) {
if (!confirm(`Are you sure you want to revoke "${tokenName}"? This cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert(error.error || 'Failed to revoke token');
}
} catch (err) {
alert('Failed to revoke token. Please try again.');
}
}
</script>
{% endblock %}
@@ -0,0 +1,234 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tools' %}
{% block title %}My Tools - SmartTools Dashboard{% endblock %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">My Tools</h1>
<p class="mt-1 text-gray-600">Manage your published tools</p>
</div>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Publish New Tool
</a>
</div>
{% endblock %}
{% block dashboard_content %}
{% if tools %}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tool
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Version
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Downloads
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for tool in tools %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div class="ml-4">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="text-sm font-medium text-gray-900 hover:text-indigo-600">
{{ tool.name }}
</a>
<p class="text-sm text-gray-500 truncate max-w-xs">{{ tool.description or 'No description' }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-900">v{{ tool.version }}</span>
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ tool.downloads|format_number }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if tool.deprecated %}
<span class="px-2 py-1 text-xs font-medium text-amber-800 bg-amber-100 rounded-full">
Deprecated
</span>
{% else %}
<span class="px-2 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Active
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="text-indigo-600 hover:text-indigo-900">View</a>
<button type="button"
onclick="openDeprecateModal('{{ tool.owner }}', '{{ tool.name }}', {{ 'true' if tool.deprecated else 'false' }})"
class="text-gray-400 hover:text-amber-600">
{% if tool.deprecated %}Restore{% else %}Deprecate{% endif %}
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pending PRs Section -->
{% if pending_prs %}
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Pending Publications</h2>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<ul class="divide-y divide-gray-200">
{% for pr in pending_prs %}
<li class="px-6 py-4 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ pr.owner }}/{{ pr.name }} v{{ pr.version }}</p>
<p class="text-xs text-gray-500">Submitted {{ pr.created_at|timeago }}</p>
</div>
<div class="flex items-center gap-3">
<span class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
{{ pr.status }}
</span>
<a href="{{ pr.pr_url }}"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-indigo-600 hover:text-indigo-800">
View PR
</a>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% else %}
<!-- Empty State -->
<div class="bg-white rounded-lg border border-gray-200 text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No tools yet</h3>
<p class="mt-2 text-gray-600 max-w-sm mx-auto">
You haven't published any tools. Create your first tool and share it with the community.
</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Learn How to Publish
</a>
</div>
{% endif %}
<!-- Deprecate Modal -->
<div id="deprecate-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeDeprecateModal()"></div>
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<form id="deprecate-form" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="owner" id="deprecate-owner">
<input type="hidden" name="name" id="deprecate-name">
<div class="p-6">
<h3 id="deprecate-title" class="text-lg font-semibold text-gray-900">Deprecate Tool</h3>
<p id="deprecate-desc" class="mt-2 text-sm text-gray-600">
Mark this tool as deprecated. Users will see a warning when viewing or installing it.
</p>
<div id="deprecate-fields" class="mt-4 space-y-4">
<div>
<label for="deprecated_message" class="block text-sm font-medium text-gray-700 mb-1">
Deprecation message (optional)
</label>
<textarea name="deprecated_message"
id="deprecated_message"
rows="2"
placeholder="e.g., This tool has been superseded by..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"></textarea>
</div>
<div>
<label for="replacement" class="block text-sm font-medium text-gray-700 mb-1">
Replacement tool (optional)
</label>
<input type="text"
name="replacement"
id="replacement"
placeholder="owner/tool-name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeDeprecateModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="submit"
id="deprecate-submit"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-md hover:bg-amber-700">
Deprecate
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openDeprecateModal(owner, name, isDeprecated) {
document.getElementById('deprecate-owner').value = owner;
document.getElementById('deprecate-name').value = name;
if (isDeprecated) {
document.getElementById('deprecate-title').textContent = 'Restore Tool';
document.getElementById('deprecate-desc').textContent = 'Remove the deprecation notice from this tool.';
document.getElementById('deprecate-fields').classList.add('hidden');
document.getElementById('deprecate-submit').textContent = 'Restore';
document.getElementById('deprecate-submit').classList.remove('bg-amber-600', 'hover:bg-amber-700');
document.getElementById('deprecate-submit').classList.add('bg-green-600', 'hover:bg-green-700');
document.getElementById('deprecate-form').action = `/api/v1/tools/${owner}/${name}/undeprecate`;
} else {
document.getElementById('deprecate-title').textContent = 'Deprecate Tool';
document.getElementById('deprecate-desc').textContent = 'Mark this tool as deprecated. Users will see a warning.';
document.getElementById('deprecate-fields').classList.remove('hidden');
document.getElementById('deprecate-submit').textContent = 'Deprecate';
document.getElementById('deprecate-submit').classList.remove('bg-green-600', 'hover:bg-green-700');
document.getElementById('deprecate-submit').classList.add('bg-amber-600', 'hover:bg-amber-700');
document.getElementById('deprecate-form').action = `/api/v1/tools/${owner}/${name}/deprecate`;
}
document.getElementById('deprecate-modal').classList.remove('hidden');
}
function closeDeprecateModal() {
document.getElementById('deprecate-modal').classList.add('hidden');
}
</script>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Page Not Found - SmartTools{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center px-4">
<div class="text-center max-w-md">
<p class="text-6xl font-bold text-indigo-600">404</p>
<h1 class="mt-4 text-3xl font-bold text-gray-900">Page not found</h1>
<p class="mt-4 text-gray-600">
Sorry, we couldn't find the page you're looking for. It may have been moved or deleted.
</p>
<div class="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.home') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Go Home
</a>
<a href="{{ url_for('web.tools') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Browse Tools
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Server Error - SmartTools{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center px-4">
<div class="text-center max-w-md">
<p class="text-6xl font-bold text-red-600">500</p>
<h1 class="mt-4 text-3xl font-bold text-gray-900">Something went wrong</h1>
<p class="mt-4 text-gray-600">
We're sorry, but something went wrong on our end. Please try again later or contact support if the problem persists.
</p>
<div class="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.home') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Go Home
</a>
<a href="https://github.com/your-org/smarttools/issues"
target="_blank"
rel="noopener noreferrer"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Report Issue
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}About - SmartTools{% endblock %}
{% block meta_description %}Learn about SmartTools, the open-source platform for building custom AI-powered command-line tools.{% endblock %}
{% block content %}
<div class="bg-white">
<!-- Hero -->
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<h1 class="text-4xl font-bold text-gray-900">About SmartTools</h1>
<p class="mt-6 text-xl text-gray-600">
An open-source platform for building and sharing AI-powered command-line tools.
</p>
</div>
<!-- Mission -->
<div class="bg-gray-50 py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Our Mission</h2>
<div class="prose prose-lg prose-indigo max-w-none">
<p>
SmartTools was created with a simple belief: powerful AI tools should be accessible to everyone,
not just those with extensive programming knowledge or expensive API subscriptions.
</p>
<p>
We're building a <strong>universally accessible development ecosystem</strong> that empowers
regular people to collaborate and build upon each other's progress rather than compete.
</p>
<p>
Our platform follows the Unix philosophy: simple, composable tools that do one thing well.
With SmartTools, you can create custom AI commands using simple YAML configuration,
chain them together, and share them with the community.
</p>
</div>
</div>
</div>
<!-- Values -->
<div class="py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-2xl font-bold text-gray-900 mb-8">Our Values</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Open Source</h3>
<p class="text-gray-600">
SmartTools is MIT licensed and open source. We believe in transparency and community ownership.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-cyan-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Community First</h3>
<p class="text-gray-600">
We prioritize collaboration over competition. Share tools, learn from others, build together.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Privacy Respecting</h3>
<p class="text-gray-600">
Your data stays yours. We collect minimal analytics and never sell user information.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Provider Agnostic</h3>
<p class="text-gray-600">
Works with any AI provider. Use Claude, GPT, local models, or any CLI-accessible AI.
</p>
</div>
</div>
</div>
</div>
<!-- Sustainability -->
<div class="bg-gray-50 py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Sustainability</h2>
<div class="prose prose-lg prose-indigo max-w-none">
<p>
SmartTools is committed to long-term sustainability. Revenue from optional ads
and donations supports:
</p>
<ul>
<li>Maintaining and expanding the project</li>
<li>Hosting infrastructure for the registry</li>
<li>Future hosting of AI models for users with less access to paid services</li>
<li>Building a sustainable, community-first platform</li>
</ul>
<p>
<a href="{{ url_for('web.donate') }}" class="text-indigo-600 hover:text-indigo-800">
Support the project
</a>
</p>
</div>
</div>
</div>
<!-- Open Source -->
<div class="py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Contribute</h2>
<p class="text-lg text-gray-600 mb-8">
SmartTools is open source and welcomes contributions of all kinds.
</p>
<a href="https://github.com/your-org/smarttools"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-6 py-3 text-base font-medium text-white bg-gray-900 rounded-md hover:bg-gray-800">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
View on GitHub
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Community - SmartTools{% endblock %}
{% block meta_description %}Connect with the SmartTools community, share tools, and collaborate on new ideas.{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Hero -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<h1 class="text-3xl font-bold text-gray-900">Community</h1>
<p class="mt-4 text-lg text-gray-600">
Collaboration over competition. Share tools, ask questions, and build together.
</p>
</div>
</div>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Community hubs -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900">Discussions</h2>
<p class="mt-2 text-sm text-gray-600">
Ask for help, share feedback, and discover best practices.
</p>
<a href="#" class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800">
Coming soon
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900">Contributor Spotlight</h2>
<p class="mt-2 text-sm text-gray-600">
Celebrate creators who publish tools and help others succeed.
</p>
<a href="#" class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800">
Coming soon
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900">Project Showcase</h2>
<p class="mt-2 text-sm text-gray-600">
See how teams use SmartTools in real projects and workflows.
</p>
<a href="#" class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800">
Coming soon
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</section>
<!-- Calls to action -->
<section class="bg-white rounded-lg border border-gray-200 p-8 text-center">
<h2 class="text-2xl font-bold text-gray-900">Get Involved</h2>
<p class="mt-3 text-gray-600">
Want to contribute tools, documentation, or tutorials? We’d love to hear from you.
</p>
<div class="mt-6 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.docs', path='contributing') }}"
class="inline-flex items-center px-6 py-3 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Contribution Guide
</a>
<a href="{{ url_for('web.tools') }}"
class="inline-flex items-center px-6 py-3 text-sm font-medium text-indigo-600 border border-indigo-600 rounded-md hover:bg-indigo-50">
Browse Tools
</a>
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ title }} - SmartTools{% endblock %}
{% block content %}
<section class="bg-white py-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold text-gray-900">{{ title }}</h1>
<p class="mt-4 text-gray-600">{{ body }}</p>
</div>
</section>
{% endblock %}
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% from "components/callouts.html" import info, warning, tip %}
{% block title %}{{ page.title }} - SmartTools Docs{% endblock %}
{% block meta_description %}{{ page.description or page.title }}{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="lg:grid lg:grid-cols-12 lg:gap-8">
<!-- Left Sidebar - TOC -->
<aside class="hidden lg:block lg:col-span-3">
<nav class="sticky top-4 bg-white rounded-lg border border-gray-200 p-4 max-h-[calc(100vh-6rem)] overflow-y-auto">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Documentation</h3>
<ul class="space-y-1">
{% for section in toc %}
<li>
<a href="{{ url_for('web.docs', path=section.slug) }}"
class="block px-3 py-2 text-sm rounded-md {{ 'bg-indigo-50 text-indigo-700 font-medium' if current_path == section.slug else 'text-gray-600 hover:bg-gray-50' }}">
{{ section.title }}
</a>
{% if section.children %}
<ul class="ml-4 mt-1 space-y-1">
{% for child in section.children %}
<li>
<a href="{{ url_for('web.docs', path=child.slug) }}"
class="block px-3 py-1.5 text-sm rounded-md {{ 'bg-indigo-50 text-indigo-700 font-medium' if current_path == child.slug else 'text-gray-500 hover:text-gray-700 hover:bg-gray-50' }}">
{{ child.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="lg:col-span-6">
<!-- Mobile TOC Toggle -->
<div class="lg:hidden mb-6">
<button type="button"
onclick="toggleMobileToc()"
class="w-full flex items-center justify-between px-4 py-3 bg-white border border-gray-300 rounded-lg">
<span class="font-medium text-gray-700">On this page</span>
<svg id="toc-chevron" class="w-5 h-5 text-gray-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<nav id="mobile-toc" class="hidden mt-2 bg-white border border-gray-200 rounded-lg p-4">
<ul class="space-y-2">
{% for section in toc %}
<li>
<a href="{{ url_for('web.docs', path=section.slug) }}"
class="block text-sm {{ 'text-indigo-600 font-medium' if current_path == section.slug else 'text-gray-600' }}">
{{ section.title }}
</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
<!-- Breadcrumb -->
<nav class="mb-6 text-sm text-gray-500" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li><a href="{{ url_for('web.docs', path='') }}" class="hover:text-gray-700">Docs</a></li>
{% if page.parent %}
<li class="flex items-center">
<svg class="w-4 h-4 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<a href="{{ url_for('web.docs', path=page.parent.slug) }}" class="hover:text-gray-700">{{ page.parent.title }}</a>
</li>
{% endif %}
<li class="flex items-center">
<svg class="w-4 h-4 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-gray-900 font-medium">{{ page.title }}</span>
</li>
</ol>
</nav>
<!-- Article Content -->
<article class="bg-white rounded-lg border border-gray-200 p-8">
<h1 class="text-3xl font-bold text-gray-900 mb-6">{{ page.title }}</h1>
<div class="prose prose-lg prose-indigo max-w-none">
{{ page.content_html|safe }}
</div>
<!-- Page Navigation -->
<div class="mt-12 pt-8 border-t border-gray-200 flex items-center justify-between">
{% if prev_page %}
<a href="{{ url_for('web.docs', path=prev_page.slug) }}"
class="flex items-center text-indigo-600 hover:text-indigo-800">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
{{ prev_page.title }}
</a>
{% else %}
<div></div>
{% endif %}
{% if next_page %}
<a href="{{ url_for('web.docs', path=next_page.slug) }}"
class="flex items-center text-indigo-600 hover:text-indigo-800">
{{ next_page.title }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% endif %}
</div>
</article>
<!-- Edit on GitHub -->
<div class="mt-6 text-center">
<a href="https://github.com/your-org/smarttools/edit/main/docs/{{ current_path }}.md"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
</svg>
Edit this page on GitHub
</a>
</div>
</main>
<!-- Right Sidebar - On This Page + Ads -->
<aside class="hidden lg:block lg:col-span-3">
<div class="sticky top-4 space-y-6">
<!-- On This Page -->
{% if page.headings %}
<nav class="bg-white rounded-lg border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">On this page</h3>
<ul class="space-y-2 text-sm" id="page-toc">
{% for heading in page.headings %}
<li class="{{ 'ml-4' if heading.level > 2 else '' }}">
<a href="#{{ heading.id }}"
class="text-gray-500 hover:text-gray-900 transition-colors"
data-heading="{{ heading.id }}">
{{ heading.text }}
</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
<!-- Ad Zone -->
{% if show_ads %}
<div class="bg-blue-50 rounded-lg border border-blue-100 p-4">
<p class="text-xs text-gray-500 mb-2">Advertisement</p>
<div id="docs-sidebar-ad" class="min-h-[250px]">
<!-- Ad content -->
</div>
</div>
{% endif %}
</div>
</aside>
</div>
</div>
</div>
<script>
function toggleMobileToc() {
const toc = document.getElementById('mobile-toc');
const chevron = document.getElementById('toc-chevron');
toc.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
// Scroll spy for page TOC
document.addEventListener('DOMContentLoaded', function() {
const headings = document.querySelectorAll('article h2, article h3');
const tocLinks = document.querySelectorAll('#page-toc a');
if (headings.length === 0 || tocLinks.length === 0) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocLinks.forEach(link => {
link.classList.remove('text-indigo-600', 'font-medium');
link.classList.add('text-gray-500');
if (link.dataset.heading === entry.target.id) {
link.classList.remove('text-gray-500');
link.classList.add('text-indigo-600', 'font-medium');
}
});
}
});
}, {rootMargin: '-100px 0px -66%'});
headings.forEach(heading => observer.observe(heading));
});
</script>
{% endblock %}
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Support SmartTools - Donate{% endblock %}
{% block meta_description %}Support SmartTools development and help keep AI tools accessible for everyone.{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Hero -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<h1 class="text-3xl font-bold text-gray-900">Support SmartTools</h1>
<p class="mt-4 text-lg text-gray-600">
Your support keeps the registry running and funds new features for the community.
</p>
</div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Impact -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900">Infrastructure</h2>
<p class="mt-2 text-sm text-gray-600">
Keep the registry fast, reliable, and available for everyone.
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900">Open Access</h2>
<p class="mt-2 text-sm text-gray-600">
Fund future hosting of shared AI models and public demos.
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900">Community Growth</h2>
<p class="mt-2 text-sm text-gray-600">
Support tutorials, examples, and recognition for contributors.
</p>
</div>
</section>
<!-- Donation options -->
<section class="bg-white rounded-lg border border-gray-200 p-8 text-center">
<h2 class="text-2xl font-bold text-gray-900">Choose a Way to Contribute</h2>
<p class="mt-3 text-gray-600">
Placeholder links for donation providers. Replace with real endpoints when ready.
</p>
<div class="mt-6 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="#"
class="inline-flex items-center px-6 py-3 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Donate via GitHub Sponsors
</a>
<a href="#"
class="inline-flex items-center px-6 py-3 text-sm font-medium text-indigo-600 border border-indigo-600 rounded-md hover:bg-indigo-50">
Donate via Ko-fi
</a>
</div>
</section>
<!-- Transparency -->
<section class="mt-12 bg-gray-100 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900">How funds are used</h3>
<ul class="mt-3 text-sm text-gray-600 space-y-2">
<li>Registry hosting, backups, and monitoring</li>
<li>Documentation, tutorials, and example projects</li>
<li>Ongoing development and community support</li>
</ul>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,221 @@
{% extends "base.html" %}
{% from "components/tool_card.html" import tool_card %}
{% from "components/tutorial_card.html" import tutorial_card %}
{% from "components/contributor_card.html" import contributor_card %}
{% block title %}SmartTools - Build Custom AI Commands in YAML{% endblock %}
{% block meta_description %}Create Unix-style pipeable AI tools with simple YAML configuration. Provider-agnostic, composable, and community-driven.{% endblock %}
{% block content %}
<!-- Hero Section -->
<section class="bg-white py-16 md:py-24">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 leading-tight">
Build Custom AI Commands in YAML
</h1>
<p class="mt-6 text-xl text-gray-600 max-w-3xl mx-auto">
Create Unix-style pipeable tools that work with any AI provider.
Provider-agnostic and composable for ultimate flexibility.
</p>
<!-- Install Command -->
<div class="mt-10 max-w-xl mx-auto">
<div class="flex items-center bg-gray-100 rounded-lg p-4 group">
<span class="text-gray-400 mr-2 select-none">$</span>
<code id="install-command" class="flex-1 text-gray-800 font-mono text-sm md:text-base text-left">pip install smarttools && smarttools init</code>
<button type="button"
onclick="copyInstallCommand()"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Copy install command">
<svg id="copy-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg id="check-icon" class="w-5 h-5 hidden text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
<!-- CTAs -->
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.docs', path='getting-started') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 text-lg font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors shadow-md">
Get Started
</a>
<a href="{{ url_for('web.tutorials') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 text-lg font-medium text-cyan-600 border-2 border-cyan-500 rounded-md hover:bg-cyan-50 transition-colors">
View Tutorials
</a>
</div>
</div>
</section>
<!-- Three Pillars Section -->
<section class="py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-gray-900 text-center mb-12">
Why SmartTools?
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Pillar 1: Easy -->
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-3">Easy to Use</h3>
<p class="text-gray-600">
Simple YAML configuration for quick setup. No complex programming required to get started.
</p>
</div>
<!-- Pillar 2: Powerful -->
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-cyan-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-3">Powerful</h3>
<p class="text-gray-600">
Leverage any AI provider, compose complex multi-step workflows with Python code integration.
</p>
</div>
<!-- Pillar 3: Community -->
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-3">Community</h3>
<p class="text-gray-600">
Share, discover, and contribute to a growing ecosystem of tools built by developers like you.
</p>
</div>
</div>
</div>
</section>
<!-- Featured Tools Section -->
<section class="py-16 bg-white">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-12">
<h2 class="text-3xl font-bold text-gray-900">
Featured Tools & Projects
</h2>
<a href="{{ url_for('web.tools') }}" class="text-indigo-600 hover:text-indigo-800 font-medium flex items-center">
View All
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for tool in featured_tools %}
{{ tool_card(
owner=tool.owner,
name=tool.name,
description=tool.description,
category=tool.category,
downloads=tool.downloads,
version=tool.version
) }}
{% else %}
<!-- Placeholder when no tools -->
<div class="col-span-full text-center py-12 text-gray-500">
<p class="mb-4">No featured tools yet. Be the first to publish!</p>
<a href="{{ url_for('web.docs', path='publishing') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Learn how to publish a tool
</a>
</div>
{% endfor %}
</div>
</div>
</section>
<!-- Getting Started Section -->
<section class="py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-gray-900 text-center mb-12">
Getting Started
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{{ tutorial_card(
title="Basic Setup",
description="Learn how to install SmartTools and configure your first AI provider.",
href=url_for('web.docs', path='getting-started'),
step_number=1
) }}
{{ tutorial_card(
title="Your First Tool",
description="Create a simple AI-powered command that you can use from your terminal.",
href=url_for('web.tutorials_path', path='first-tool'),
step_number=2
) }}
{{ tutorial_card(
title="Advanced Workflows",
description="Combine multiple steps and providers to build powerful automation.",
href=url_for('web.tutorials_path', path='advanced-workflows'),
step_number=3
) }}
</div>
</div>
</section>
<!-- Featured Contributor Section -->
{% if featured_contributor %}
<section class="py-16 bg-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-gray-900 text-center mb-12">
Featured Contributor
</h2>
{{ contributor_card(
display_name=featured_contributor.display_name,
slug=featured_contributor.slug,
bio=featured_contributor.bio_override or featured_contributor.bio,
verified=featured_contributor.verified
) }}
</div>
</section>
{% endif %}
<!-- Ad Zone (Optional) -->
{% if show_ads %}
<section class="py-8 bg-blue-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<p class="text-xs text-gray-500 mb-2">Advertisement</p>
<div id="footer-ad-zone" class="min-h-[90px] flex items-center justify-center">
<!-- Ad content loaded dynamically -->
<p class="text-gray-400 text-sm">Support SmartTools Development</p>
</div>
</div>
</section>
{% endif %}
<script>
function copyInstallCommand() {
const command = 'pip install smarttools && smarttools init';
navigator.clipboard.writeText(command).then(() => {
const copyIcon = document.getElementById('copy-icon');
const checkIcon = document.getElementById('check-icon');
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
});
}
</script>
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Sign In - SmartTools{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Sign in to SmartTools</h1>
<p class="mt-2 text-gray-600">
Access your dashboard and manage your tools
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{{ form_errors(errors) }}
<form action="{{ url_for('web.login') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if next_url %}
<input type="hidden" name="next" value="{{ next_url }}">
{% endif %}
{{ text_input(
name='email',
label='Email address',
type='email',
placeholder='[email protected]',
required=true,
value=email or ''
) }}
{{ text_input(
name='password',
label='Password',
type='password',
placeholder='Your password',
required=true
) }}
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<input type="checkbox"
name="remember"
id="remember"
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<label for="remember" class="ml-2 text-sm text-gray-600">
Remember me
</label>
</div>
<a href="{{ url_for('web.forgot_password') }}" class="text-sm text-indigo-600 hover:text-indigo-800">
Forgot password?
</a>
</div>
{{ button_primary('Sign in', full_width=true) }}
</form>
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Don't have an account?
<a href="{{ url_for('web.register') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Create one
</a>
</p>
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More