Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a33d57de1 | ||
|
|
e029166ba6 | ||
|
|
9070029d31 | ||
|
|
24c91b8de7 | ||
|
|
b26ae5092b | ||
|
|
c41d2eace2 | ||
|
|
16554c0694 | ||
|
|
0200ad4f6e | ||
|
|
b90e632ec7 | ||
|
|
d6519bde66 | ||
|
|
c8a937ec15 | ||
|
|
cfb9858967 | ||
|
|
34428f0e72 | ||
|
|
284e9981bc | ||
|
|
b67d44ac8b | ||
|
|
9d783af2f9 | ||
|
|
c1499e76d2 | ||
|
|
c8a0433ecc | ||
|
|
e2816bf6f6 | ||
|
|
d830a483d9 | ||
|
|
38a446640f | ||
|
|
543ded0a5f | ||
|
|
b09008c215 | ||
|
|
2cbaaeb9d5 | ||
|
|
452cebadda | ||
|
|
6106b7837a | ||
|
|
9d3b65118d | ||
|
|
b4c7491784 | ||
|
|
6abca22019 | ||
|
|
f65d250973 | ||
|
|
42be82df6b | ||
|
|
65279bd8c8 | ||
|
|
7e9bd0d533 | ||
|
|
35835f8254 | ||
|
|
d09a0088ef | ||
|
|
bcda4150a7 | ||
|
|
0c966a8adb | ||
|
|
9c6b682c73 | ||
|
|
541d49b7f9 | ||
|
|
cff3b674ac | ||
|
|
f84e06aa05 | ||
|
|
67a83e5379 | ||
|
|
6a4e153a62 | ||
|
|
f227831feb | ||
|
|
765a53df1c | ||
|
|
aef5c0f99f | ||
|
|
b8c85df398 | ||
|
|
bdaf9e48de | ||
|
|
c4b5492dc7 | ||
|
|
039df4a6a5 | ||
|
|
4c1d3aa4fd | ||
|
|
02b5a496c4 | ||
|
|
4c71dbded2 | ||
|
|
28dac465e6 | ||
|
|
ba18c0feaa |
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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_*
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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 +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 +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 +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 +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 +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
@@ -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 +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))})()})();
|
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+249
@@ -0,0 +1,249 @@
|
|||||||
|
# Installation Guide
|
||||||
|
|
||||||
|
Complete installation instructions for SmartTools.
|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||||
|
cd SmartTools
|
||||||
|
pip install -e .
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
smarttools providers install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detailed Installation
|
||||||
|
|
||||||
|
### Step 1: Install Python 3.10+
|
||||||
|
|
||||||
|
**Ubuntu/Debian:**
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install python3 python3-pip
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
brew install python@3.11
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
python3 --version # Should be 3.10 or higher
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Install SmartTools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||||
|
cd SmartTools
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
**With TUI and development dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Configure PATH
|
||||||
|
|
||||||
|
SmartTools creates executable wrappers in `~/.local/bin/`. Add this to your shell config:
|
||||||
|
|
||||||
|
**Bash (~/.bashrc):**
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zsh (~/.zshrc):**
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fish (~/.config/fish/config.fish):**
|
||||||
|
```fish
|
||||||
|
set -gx PATH $HOME/.local/bin $PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reload:
|
||||||
|
```bash
|
||||||
|
source ~/.bashrc # or restart terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Install an AI Provider
|
||||||
|
|
||||||
|
You need at least one AI CLI tool. The easiest way is the interactive installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
smarttools providers install
|
||||||
|
```
|
||||||
|
|
||||||
|
This guides you through installing and authenticating providers.
|
||||||
|
|
||||||
|
#### Manual Installation
|
||||||
|
|
||||||
|
**OpenCode (free tier available):**
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
source ~/.bashrc # or start new shell
|
||||||
|
opencode # Opens browser for sign-in
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude CLI (pay-per-use):**
|
||||||
|
```bash
|
||||||
|
npm install -g @anthropic-ai/claude-code
|
||||||
|
claude # Opens browser for sign-in
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codex (pay-per-use):**
|
||||||
|
```bash
|
||||||
|
npm install -g @openai/codex
|
||||||
|
codex # Opens browser for sign-in
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gemini (free tier available):**
|
||||||
|
```bash
|
||||||
|
npm install -g @google/gemini-cli
|
||||||
|
gemini # Opens browser for Google sign-in
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ollama (free, runs locally):**
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://ollama.ai/install.sh | bash
|
||||||
|
ollama pull llama3
|
||||||
|
smarttools providers add ollama "ollama run llama3" -d "Local Llama 3"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
|
||||||
|
|
||||||
|
### Step 5: Verify Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check SmartTools is installed
|
||||||
|
smarttools --version
|
||||||
|
|
||||||
|
# List configured providers
|
||||||
|
smarttools providers list
|
||||||
|
|
||||||
|
# Check which providers are available
|
||||||
|
smarttools providers check
|
||||||
|
|
||||||
|
# Test with mock provider (no AI needed)
|
||||||
|
echo "hello world" | smarttools run --provider mock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Create Wrapper Scripts
|
||||||
|
|
||||||
|
After installation or any Python environment change:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
smarttools refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates executable scripts in `~/.local/bin/` for all your tools.
|
||||||
|
|
||||||
|
## Installing Example Tools
|
||||||
|
|
||||||
|
### Quick Install (All 28 Tools)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/install.py
|
||||||
|
smarttools refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Install
|
||||||
|
|
||||||
|
Copy individual tool configs to `~/.smarttools/<toolname>/config.yaml`.
|
||||||
|
|
||||||
|
See [EXAMPLES.md](EXAMPLES.md) for all tool configurations.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "smarttools: command not found"
|
||||||
|
|
||||||
|
PATH is not configured. Add to your shell config:
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Provider 'X' not found"
|
||||||
|
|
||||||
|
1. Check provider is installed: `which claude` or `which opencode`
|
||||||
|
2. Check provider is configured: `smarttools providers`
|
||||||
|
3. Add missing provider: `smarttools providers add`
|
||||||
|
|
||||||
|
### "Permission denied" on tool execution
|
||||||
|
|
||||||
|
Refresh wrapper scripts:
|
||||||
|
```bash
|
||||||
|
smarttools refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### TUI doesn't start / looks broken
|
||||||
|
|
||||||
|
Install urwid:
|
||||||
|
```bash
|
||||||
|
pip install urwid
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure your terminal supports mouse (most modern terminals do).
|
||||||
|
|
||||||
|
### Tools work with `smarttools run` but not directly
|
||||||
|
|
||||||
|
The wrapper scripts may be outdated. Refresh them:
|
||||||
|
```bash
|
||||||
|
smarttools refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python version mismatch
|
||||||
|
|
||||||
|
If you have multiple Python versions, ensure SmartTools uses the right one:
|
||||||
|
```bash
|
||||||
|
python3.11 -m pip install smarttools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove package
|
||||||
|
pip uninstall smarttools
|
||||||
|
|
||||||
|
# Remove config and tools (optional)
|
||||||
|
rm -rf ~/.smarttools
|
||||||
|
|
||||||
|
# Remove wrapper scripts (optional)
|
||||||
|
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --upgrade smarttools
|
||||||
|
smarttools refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
After installation:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.smarttools/
|
||||||
|
├── providers.yaml # Provider configurations
|
||||||
|
├── summarize/
|
||||||
|
│ └── config.yaml # Tool config
|
||||||
|
├── translate/
|
||||||
|
│ ├── config.yaml
|
||||||
|
│ └── prompt.txt # External prompt file (optional)
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
~/.local/bin/
|
||||||
|
├── smarttools # Main command
|
||||||
|
├── summarize # Tool wrapper script
|
||||||
|
├── translate # Tool wrapper script
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. [Set up providers](PROVIDERS.md)
|
||||||
|
2. [Browse example tools](EXAMPLES.md)
|
||||||
|
3. Run `smarttools ui` to create your first tool
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1462
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-55
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Generated
+1015
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)",
|
||||||
|
]
|
||||||
@@ -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())
|
||||||
@@ -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 +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>
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Registry API server package."""
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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, ""
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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",
|
||||||
|
)
|
||||||
@@ -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)))
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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.",
|
||||||
|
)
|
||||||
@@ -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>",
|
||||||
|
])
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
© {{ 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
Reference in New Issue
Block a user