diff --git a/.gitignore b/.gitignore index 138c396..76d7621 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,13 @@ ENV/ .DS_Store Thumbs.db +# Node.js +node_modules/ +package-lock.json + # Project specific *.log +discussions/ +diagrams/ +*.puml +.tmp_* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7510abd --- /dev/null +++ b/AGENTS.md @@ -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//config.yaml` and should not be added to the repo. diff --git a/docs/REGISTRY.md b/docs/REGISTRY.md new file mode 100644 index 0000000..a93c152 --- /dev/null +++ b/docs/REGISTRY.md @@ -0,0 +1,1896 @@ +# SmartTools Registry Design + +## Purpose +Build a centralized registry for SmartTools to enable discovery, publishing, dependency management, and future curation at scale. + +## Terminology + +| Term | Definition | +|------|------------| +| **Tool definition** | The full YAML file in the registry (`config.yaml`) containing name, steps, arguments, etc. | +| **Tool config** | The configuration within a tool definition (arguments, steps, provider settings) | +| **smarttools.yaml** | Project manifest file declaring tool dependencies and overrides | +| **config.yaml** | The tool definition file, both in registry and when installed locally | +| **Owner** | Immutable namespace slug identifying the publisher (e.g., `rob`, `alice`) | +| **Publisher** | A registered user who can publish tools to the registry | +| **Wrapper script** | Auto-generated bash script in `~/.local/bin/` that invokes a tool | + +**Canonical naming:** Use `SmartTools-Registry` (capitalized, hyphenated) for the repository name. + +## Diagram References +- System overview: `discussions/diagrams/smarttools-registry_rob_1.puml` +- Data flows: `discussions/diagrams/smarttools-registry_rob_5.puml` + +## System Overview +Users interact via the CLI and a future Web UI. Both call a Registry API hosted at `https://gitea.brrd.tech/api/v1` (future alias: `registry.smarttools.dev/api/v1`). The API syncs from a Gitea-backed registry repo and maintains a SQLite cache/search index. + +**Canonical API base path:** `https://gitea.brrd.tech/api/v1` + +All API endpoints are versioned under `/api/v1`. When breaking changes are needed, a new version (`/api/v2`) will be introduced with deprecation notices. + +Core API endpoints: +- `GET /api/v1/tools` +- `GET /api/v1/tools/search?q=...` +- `GET /api/v1/tools/{owner}/{name}` +- `GET /api/v1/tools/{owner}/{name}/versions` +- `GET /api/v1/tools/{owner}/{name}/download?version=...` +- `POST /api/v1/tools` (publish) +- `GET /api/v1/categories` +- `GET /api/v1/stats/popular` +- `POST /api/v1/webhook/gitea` + +### Pagination + +All list endpoints support pagination: + +| Parameter | Default | Max | Description | +|-----------|---------|-----|-------------| +| `page` | 1 | - | Page number (1-indexed) | +| `per_page` | 20 | 100 | Items per page | +| `sort` | `downloads` | - | Sort field | +| `order` | `desc` | - | Sort order (asc/desc) | + +**Stable ordering:** To ensure deterministic results across pages, sorting includes a secondary key: +- Primary: requested field (e.g., `downloads`) +- Secondary: `published_at` (desc) +- Tertiary: `id` (for absolute stability) + +```sql +ORDER BY downloads DESC, published_at DESC, id DESC +LIMIT 20 OFFSET 0 +``` + +**Response pagination metadata:** +```json +{ + "data": [...], + "meta": { + "page": 1, + "per_page": 20, + "total": 142, + "total_pages": 8 + } +} +``` + +### Input Constraints + +Size limits to prevent oversized uploads: + +| Field | Max Size | Notes | +|-------|----------|-------| +| `config.yaml` | 64 KB | Tool definition | +| `README.md` | 256 KB | Documentation | +| Request body | 512 KB | Total POST payload | +| Tool name | 64 chars | Alphanumeric + hyphen | +| Description | 500 chars | Short summary | +| Tag | 32 chars | Individual tag | +| Tags array | 10 items | Maximum tags per tool | + +**Validation errors:** +```json +{ + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "config.yaml exceeds 64KB limit", + "details": { + "field": "config", + "size": 72000, + "limit": 65536 + } + } +} +``` + +### Sort Fields and Indexes + +**Allowed sort fields:** + +| Endpoint | Allowed `sort` values | +|----------|----------------------| +| `GET /tools` | `downloads`, `published_at`, `name` | +| `GET /tools/search` | `relevance`, `downloads`, `published_at` | +| `GET /categories` | `name`, `tool_count` | + +Invalid sort values return 400: +```json +{"error": {"code": "INVALID_SORT", "message": "Unknown sort field 'foo'. Allowed: downloads, published_at, name"}} +``` + +**Database indexes:** +```sql +-- Frequent query patterns +CREATE INDEX idx_tools_owner_name ON tools(owner, name); +CREATE INDEX idx_tools_category ON tools(category); +CREATE INDEX idx_tools_published_at ON tools(published_at DESC); +CREATE INDEX idx_tools_downloads ON tools(downloads DESC); +CREATE INDEX idx_tools_owner_name_version ON tools(owner, name, version); + +-- For pagination stability +CREATE INDEX idx_tools_sort_stable ON tools(downloads DESC, published_at DESC, id DESC); + +-- Publisher lookups +CREATE INDEX idx_publishers_slug ON publishers(slug); +CREATE INDEX idx_publishers_email ON publishers(email); + +-- Token lookups +CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash); +CREATE INDEX idx_api_tokens_publisher ON api_tokens(publisher_id); +``` + +### API Version Compatibility + +**Forward compatibility:** Clients should ignore unknown fields in API responses: + +```python +# Good: ignore unknown fields +tool = response['data'] +name = tool.get('name') +# Don't fail if 'new_field' exists but client doesn't know about it + +# Bad: strict parsing that fails on unknown fields +tool = ToolSchema.parse(response['data']) # May fail on new fields +``` + +**Backward compatibility:** The API will: +- Never remove fields in a version (only deprecate) +- Never change field types +- Add new optional fields without version bump +- Use new version (`/api/v2`) for breaking changes + +**Deprecation process:** +1. Add `X-Deprecated-Field: old_field` header +2. Document in changelog +3. Remove after 6 months minimum +4. Major version bump if widely used + +**Client version header:** +``` +X-SmartTools-Client: cli/1.2.0 +``` +Helps server track client versions for deprecation decisions. + +## Source of Truth +- Gitea registry repo is the source of truth. +- API syncs repo content into SQLite for fast queries, stats, and FTS5 search. +- `index.json` remains useful for offline CLI search and as a fallback. + +If the cache is stale, the API can fall back to repo reads; a warning header may be emitted. + +## Namespacing and Paths +Support owner/name from day one: +- Registry path: `tools/{owner}/{name}/config.yaml` +- API URL: `/tools/{owner}/{name}` +- Install: `smarttools registry install rob/summarize` +- Shorthand: `smarttools registry install summarize` resolves to the official namespace. + +PR branches: `submit/{owner}/{name}/{version}`. + +### Namespace Identity + +The `owner` is an **immutable slug**, not the display name: + +```sql +-- In publishers table +slug TEXT UNIQUE NOT NULL, -- immutable: "rob", "alice-dev" +display_name TEXT NOT NULL, -- mutable: "Rob", "Alice Developer" +``` + +**Slug rules:** +- Lowercase alphanumeric + hyphens only: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` +- 2-39 characters +- Cannot start/end with hyphen +- Set once at registration, cannot be changed +- Reserved slugs: `official`, `admin`, `system`, `api`, `registry` + +**Rename policy:** +- `display_name` can be changed anytime via dashboard +- `slug` (owner) is permanent to preserve URLs and tool references +- If a publisher absolutely must change slug (legal reasons, etc.): + 1. Create new account with new slug + 2. Republish tools under new namespace + 3. Mark old tools as deprecated with `replacement` pointing to new namespace + 4. Old namespace remains reserved (cannot be reused by others) + +**Why immutable:** +- `rob/summarize@1.0.0` must always resolve to the same tool +- Prevents namespace hijacking after rename +- Simplifies caching and CDN strategies + +## Tool Format (Registry == Local) +Registry tool folders mirror local tools: +``` +tools/ + rob/ + summarize/ + config.yaml + README.md +``` + +Tool files match the existing SmartTools format. Registry-specific metadata is kept under `registry:`. Deprecation is tool-defined and top-level: +```yaml +name: summarize +version: "1.2.0" +deprecated: true +deprecated_message: "Security issue. Use v1.2.1" +replacement: "rob/summarize@1.2.1" +registry: + published_at: "2025-01-15T10:30:00Z" + downloads: 142 +``` + +**Schema compatibility note:** The current SmartTools config parser may reject unknown top-level keys like `deprecated`, `replacement`, and `registry`. Before implementing registry features: +1. Update the YAML parser to ignore unknown keys (permissive mode) +2. Or explicitly define these fields in the Tool dataclass with defaults +3. Validate registry-specific fields only when publishing, not when running locally + +This ensures local tools continue to work even if they don't have registry fields. + +## Versioning and Immutability +- Unique key: `owner/name + version`. +- Published versions are immutable. +- Deprecation uses `deprecated`, `deprecated_message`, and `replacement`. +- CLI warns on install if a version is deprecated. + +### Yank Policy + +Yanking allows removing a version from resolution without deleting it (for auditability): + +```yaml +# In tool config +yanked: true +yanked_reason: "Critical security vulnerability CVE-2025-1234" +yanked_at: "2025-01-20T15:00:00Z" +``` + +**Yanked version behavior:** + +| Operation | Behavior | +|-----------|----------| +| `install foo@1.0.0` (exact) | Warns but allows install | +| `install foo@^1.0.0` (constraint) | Excludes yanked, resolves to next valid | +| `search` / `browse` | Hidden by default, shown with `--include-yanked` | +| Direct URL access | Returns tool with `yanked: true` in response | +| Already installed | Continues to work, no forced removal | + +**Database schema addition:** +```sql +-- Add to tools table +yanked BOOLEAN DEFAULT FALSE, +yanked_reason TEXT, +yanked_at TIMESTAMP +``` + +**Yank vs Delete:** +- **Yank**: Version remains in DB, excluded from resolution, auditable +- **Delete**: Reserved for DMCA/legal, requires admin action, leaves tombstone record + +### Version Format + +Tools use semantic versioning (semver): +``` +MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD] + +Examples: + 1.0.0 # stable release + 1.2.3 # stable release + 2.0.0-alpha.1 # prerelease + 2.0.0-beta.2 # prerelease + 2.0.0-rc.1 # release candidate +``` + +### Version Constraints + +Manifest files support these constraint formats: + +| Constraint | Meaning | Example Match | +|------------|---------|---------------| +| `1.2.3` | Exact version | `1.2.3` only | +| `>=1.2.0` | Minimum version | `1.2.0`, `1.3.0`, `2.0.0` | +| `<2.0.0` | Below version | `1.9.9`, `1.0.0` | +| `>=1.0.0,<2.0.0` | Range | `1.0.0` to `1.9.9` | +| `^1.2.3` | Compatible (same major) | `1.2.3` to `1.9.9` | +| `~1.2.3` | Approximately (same minor) | `1.2.3` to `1.2.9` | +| `*` | Any version | latest stable | + +### Version Resolution Rules + +When resolving a version constraint: + +1. **Filter**: Get all versions matching the constraint +2. **Exclude prereleases**: Unless constraint explicitly includes them (e.g., `>=2.0.0-alpha.1`) +3. **Sort**: By semver precedence (descending) +4. **Select**: Highest matching version + +**Tie-breakers:** +- Stable versions preferred over prereleases +- Later publish date wins if versions are equal (shouldn't happen with immutability) + +**Unsatisfiable constraints:** +```json +// API Response: 404 +{ + "error": { + "code": "VERSION_NOT_FOUND", + "message": "No version of 'rob/summarize' satisfies constraint '>=5.0.0'", + "details": { + "tool": "rob/summarize", + "constraint": ">=5.0.0", + "available_versions": ["1.0.0", "1.1.0", "1.2.0"], + "latest_stable": "1.2.0" + } + } +} +``` + +### Prerelease Handling + +- Prereleases are **not** returned for `*` or range constraints by default +- To install prerelease: `smarttools registry install rob/summarize@2.0.0-beta.1` +- To allow prereleases in manifest: `version: ">=2.0.0-0"` (the `-0` suffix includes prereleases) + +### Download Endpoint Version Selection + +The `/api/v1/tools/{owner}/{name}/download` endpoint accepts version parameters: + +| Parameter | Behavior | Example | +|-----------|----------|---------| +| (none) | Returns latest stable version | `/download` → `1.2.0` | +| `version=1.2.0` | Exact version (must exist) | `/download?version=1.2.0` | +| `version=^1.0.0` | Server resolves constraint | `/download?version=^1.0.0` → `1.2.0` | +| `version=latest` | Alias for latest stable | `/download?version=latest` | + +**Server-side resolution:** The API server resolves version constraints, not the client. This ensures consistent resolution and allows the server to apply policies (e.g., exclude yanked versions). + +``` +GET /api/v1/tools/rob/summarize/download?version=^1.0.0&install=true + +Response (200): +{ + "data": { + "owner": "rob", + "name": "summarize", + "resolved_version": "1.2.0", + "config": "... YAML content ..." + }, + "meta": { + "constraint": "^1.0.0", + "available_versions": ["1.0.0", "1.1.0", "1.2.0"] + } +} +``` + +**Invalid/unsatisfiable constraint:** +``` +GET /api/v1/tools/rob/summarize/download?version=^5.0.0 + +Response (404): +{ + "error": { + "code": "CONSTRAINT_UNSATISFIABLE", + "message": "No version matches constraint '^5.0.0'", + "details": { + "constraint": "^5.0.0", + "latest_stable": "1.2.0", + "available_versions": ["1.0.0", "1.1.0", "1.2.0"] + } + } +} +``` + +## Tool Resolution Order +When a tool is invoked, the CLI searches in this order: + +1. **Local project**: `./.smarttools///config.yaml` (or `./.smarttools//` for unnamespaced) +2. **Global user**: `~/.smarttools///config.yaml` +3. **Registry**: Fetch from API, install to global, then run +4. **Error**: `Tool '' not found` + +Step 3 only occurs if `auto_fetch_from_registry: true` in config (default: true). + +**Path convention:** Use `.smarttools/` (with leading dot) for both local and global to maintain consistency. + +Resolution also respects namespacing: +- `summarize` → searches for any tool named `summarize`, prefers `official/summarize` if exists +- `rob/summarize` → searches for exactly `rob/summarize` + +### Official Namespace + +The slug `official` is reserved for curated, high-quality tools maintained by the registry administrators. + +- Shorthand `summarize` resolves to `official/summarize` if it exists +- If no `official/summarize`, falls back to most-downloaded tool named `summarize` +- To avoid ambiguity, always use full `owner/name` in manifests + +Reserved slugs that cannot be registered: `official`, `admin`, `system`, `api`, `registry`, `smarttools` + +## Auto-Fetch Behavior +When enabled (`auto_fetch_from_registry: true`), missing tools are automatically fetched: + +```bash +$ summarize < file.txt +# Tool 'summarize' not found locally. +# Fetching from registry... +# Installed: official/summarize@1.2.0 +# Running... +``` + +Behavior details: +- Fetches latest stable version unless pinned in `smarttools.yaml` +- Installs to `~/.smarttools///` +- Generates wrapper script in `~/.local/bin/` +- Subsequent runs use local copy (no re-fetch) + +To disable (require explicit install): +```yaml +# ~/.smarttools/config.yaml +auto_fetch_from_registry: false +``` + +### Wrapper Script Collisions + +When two tools from different owners have the same name: + +| Scenario | Behavior | +|----------|----------| +| Install `official/summarize` | Creates wrapper `~/.local/bin/summarize` | +| Install `rob/summarize` (collision) | Creates wrapper `~/.local/bin/rob-summarize` | +| Uninstall `official/summarize` | Removes `summarize` wrapper, promotes `rob-summarize` → `summarize` if desired | + +The first-installed tool with a given name gets the short wrapper. Subsequent tools use `owner-name` format. + +To invoke a specific owner's tool: +```bash +# Short form (whichever was installed first) +summarize < file.txt + +# Explicit owner form (always works) +rob-summarize < file.txt + +# Or via smarttools run +smarttools run rob/summarize < file.txt +``` + +## Project Manifest (smarttools.yaml) +Defines tool dependencies with optional runtime overrides: +``` +name: my-ai-project +version: "1.0.0" +dependencies: + - name: rob/summarize + version: ">=1.0.0" +overrides: + rob/summarize: + provider: ollama +``` + +Overrides are applied at runtime and do not mutate installed tool configs. + +## CLI Config and Tokens +Global config lives in `~/.smarttools/config.yaml`: +```yaml +registry: + url: https://gitea.brrd.tech/api/v1 # Must match canonical base path + token: "reg_xxxxxxxxxxxx" +client_id: "anon_abc123def456" +auto_fetch_from_registry: true +``` + +`client_id` is generated locally and used for anonymous install dedupe. + +## Publishing and Auth +Publishing uses registry accounts, not Gitea accounts: +- Public endpoints require no auth. +- `POST /tools` requires a registry token. +- The API server uses a private Gitea service account to open PRs. + +### Publish Idempotency and Edge Cases + +**Idempotency key:** `owner/name@version` + +| Scenario | API Response | HTTP Code | +|----------|--------------|-----------| +| New version, no PR exists | Create PR, return URL | `201 Created` | +| PR already exists (pending) | Return existing PR URL | `200 OK` | +| Version already published | Error: version exists | `409 Conflict` | +| PR was closed without merge | Allow new PR | `201 Created` | +| PR was merged, then tool deleted | Error: version exists (tombstone) | `409 Conflict` | + +**Version immutability enforcement:** +```json +// Attempt to publish existing version +// Response: 409 Conflict +{ + "error": { + "code": "VERSION_EXISTS", + "message": "Version 1.2.0 of 'rob/summarize' already exists and cannot be overwritten", + "details": { + "published_at": "2025-01-15T10:30:00Z", + "action": "Bump version number to publish changes" + } + } +} +``` + +**Closed PR handling:** +- Track PR state in database: `pending`, `merged`, `closed` +- If PR was closed (rejected/abandoned), allow new submission for same version +- If PR was merged, version is immutable forever + +**Update flow (new version, not overwrite):** +1. Developer modifies tool locally +2. Bumps version in `config.yaml` (e.g., `1.2.0` → `1.3.0`) +3. Runs `smarttools registry publish` +4. New PR created for `1.3.0` +5. Old version `1.2.0` remains available + +## Publisher Registration +Publishers register on the registry website, not Gitea: + +**Registration flow:** +1. User visits `https://gitea.brrd.tech/registry/register` (or future `registry.smarttools.dev`) +2. Creates account with email + password + slug +3. Receives verification email (optional in v1, but track `verified` status) +4. Logs into dashboard at `/dashboard` +5. Generates API token from dashboard +6. Uses token in CLI for publishing + +### Authentication Security + +**Password hashing:** +- Algorithm: Argon2id (memory-hard, recommended by OWASP) +- Parameters: `memory=65536, iterations=3, parallelism=4` +- Library: `argon2-cffi` for Python + +```python +from argon2 import PasswordHasher +ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4) +hash = ph.hash(password) +ph.verify(hash, password) # raises on mismatch +``` + +**API token format:** +``` +reg_ + +Example: reg_7kX9mPqR2sT4vW6xY8zA1bC3dE5fG7hJ +``` +- Prefix `reg_` for easy identification in logs/configs +- 32 bytes of cryptographically random data +- Base62 encoded (alphanumeric, no special chars) +- Total length: ~47 characters +- Stored as SHA-256 hash in database (never plain text) + +**Token lifecycle:** +| Action | Behavior | +|--------|----------| +| Generate | Create new token, return once, store hash | +| List | Show token name, created date, last used (not the token itself) | +| Revoke | Set `revoked_at` timestamp, reject future uses | +| Rotate | Generate new token, optionally revoke old | + +**Rate limits:** + +| Endpoint | Limit | Window | Scope | Retry-After | +|----------|-------|--------|-------|-------------| +| `POST /register` | 5 | 1 hour | IP | 3600 | +| `POST /login` | 10 | 15 min | IP | 900 | +| `POST /login` (failed) | 5 | 15 min | IP + email | 900 | +| `POST /tokens` | 10 | 1 hour | Token | 3600 | +| `POST /tools` | 20 | 1 hour | Token | 3600 | +| `GET /tools/*` | 100 | 1 min | IP | 60 | +| `GET /download` | 60 | 1 min | IP | 60 | + +**Rate limit response (429):** +```json +{ + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests. Try again in 60 seconds.", + "details": { + "limit": 100, + "window": "1 minute", + "retry_after": 60 + } + } +} +``` + +**Headers on rate-limited response:** +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 60 +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1705766400 +``` + +**Scope priority:** For authenticated requests, both IP and token limits apply. The more restrictive limit wins. + +**Account lockout:** +- After 5 failed login attempts: 15-minute lockout for that email +- After 10 failed attempts: 1-hour lockout +- Lockout clears on successful password reset + +**Password reset flow (deferred to v1.1):** +1. User requests reset via email +2. Server generates time-limited token (1 hour expiry) +3. Email contains reset link with token +4. User sets new password +5. All existing sessions/tokens optionally invalidated + +**Email verification flow (deferred to v1.1):** +1. On registration, send verification email +2. User clicks link with verification token +3. Set `verified = true` in database +4. Unverified accounts can browse but not publish + +### Token Scopes and Authorization + +Tokens have scopes that limit their capabilities: + +| Scope | Permissions | +|-------|-------------| +| `read` | View own published tools, download stats | +| `publish` | Submit new tools, update own tool metadata | +| `admin` | Yank tools, manage categories (registry admins only) | + +**Default scope:** New tokens get `read,publish` by default. + +**Ownership enforcement:** + +```python +@app.route('/api/v1/tools', methods=['POST']) +@require_token(scopes=['publish']) +def publish_tool(): + token = get_current_token() + tool_data = request.json + + # Enforce owner == token holder's slug + if tool_data['owner'] != token.publisher.slug: + return { + "error": { + "code": "FORBIDDEN", + "message": f"Cannot publish to namespace '{tool_data['owner']}'. " + f"Your namespace is '{token.publisher.slug}'." + } + }, 403 + + # Proceed with publish... +``` + +**`GET /api/v1/me/tools` authorization:** +- Requires valid token with `read` scope +- Returns only tools where `owner == token.publisher.slug` +- Includes pending PRs and all versions (including yanked) + +### Web Session Security + +Dashboard login uses session cookies (not tokens) for browser auth: + +**Cookie settings:** +```python +SESSION_COOKIE_NAME = 'smarttools_session' +SESSION_COOKIE_HTTPONLY = True # Prevent JS access +SESSION_COOKIE_SECURE = True # HTTPS only in production +SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection +SESSION_COOKIE_MAX_AGE = 86400 * 7 # 7 days +``` + +**CSRF protection:** +- All POST/PUT/DELETE forms include `csrf_token` hidden field +- Token validated server-side before processing +- 403 Forbidden if token missing or invalid + +**Session lifecycle:** +| Event | Action | +|-------|--------| +| Login | Create session, set cookie | +| Logout | Delete session, clear cookie | +| Idle 24h | Session expires, re-login required | +| Password change | Invalidate all sessions | +| Token revocation | Existing sessions continue (token != session) | + +**Secure session storage:** +```python +# Store sessions in DB, not filesystem +from flask_session import Session +app.config['SESSION_TYPE'] = 'sqlalchemy' +app.config['SESSION_SQLALCHEMY_TABLE'] = 'sessions' +``` + +**Database schema:** +```sql +-- Publishers +CREATE TABLE publishers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, -- immutable namespace: "rob", "alice-dev" + display_name TEXT NOT NULL, -- mutable: "Rob", "Alice Developer" + bio TEXT, + website TEXT, + verified BOOLEAN DEFAULT FALSE, + locked_until TIMESTAMP, -- account lockout + failed_login_attempts INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- API tokens (one publisher can have multiple) +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + publisher_id INTEGER NOT NULL REFERENCES publishers(id), + token_hash TEXT NOT NULL, + name TEXT NOT NULL, -- "CLI token", "CI token" + last_used_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP -- NULL if active +); + +-- Tools (links to publisher) +CREATE TABLE tools ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, -- namespace slug (immutable, from publisher.slug) + name TEXT NOT NULL, + version TEXT NOT NULL, + description TEXT, + category TEXT, + tags TEXT, -- JSON array + config_yaml TEXT NOT NULL, -- Full tool config + 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) +); + +-- Download stats (for deduplication) +CREATE TABLE 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) +); + +-- Search index (FTS5) +CREATE VIRTUAL TABLE tools_fts USING fts5( + name, description, tags, readme, + content='tools', + content_rowid='id' +); + +-- FTS5 sync triggers (required for external content tables) +CREATE TRIGGER 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 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 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; + +-- Pending PRs (track publish state) +CREATE TABLE 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', -- pending, merged, closed + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner, name, version) +); + +-- Webhook sync log (idempotency) +CREATE TABLE webhook_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + delivery_id TEXT UNIQUE NOT NULL, -- Gitea delivery ID + event_type TEXT NOT NULL, + processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +**Note on tags indexing:** The `tags` column stores JSON arrays as text. For v1, FTS5 will search within the JSON string. If tag filtering becomes a bottleneck, normalize to a `tool_tags` junction table: + +```sql +-- Future: normalized tags (if needed) +CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +CREATE TABLE tool_tags ( + tool_id INTEGER REFERENCES tools(id), + tag_id INTEGER REFERENCES tags(id), + PRIMARY KEY (tool_id, tag_id) +); +``` + +**CLI first-time publish flow:** +```bash +$ smarttools registry publish + +No registry account configured. + +1. Register at: https://gitea.brrd.tech/registry/register +2. Generate a token from your dashboard +3. Enter your token below + +Registry token: ******** +Token saved to ~/.smarttools/config.yaml + +Validating tool... +✓ config.yaml is valid +✓ README.md exists (2.3 KB) +✓ Version 1.0.0 not yet published + +Publishing rob/my-tool@1.0.0... +✓ PR created: https://gitea.brrd.tech/rob/SmartTools-Registry/pulls/42 + +Your tool is pending review. You'll receive an email when it's approved. +``` + +## CLI Commands Reference +Full mapping of CLI commands to API calls: + +### Registry Commands + +```bash +# Search for tools +$ smarttools registry search [--category=] [--limit=20] + → GET /api/v1/tools/search?q=&category=&limit=20 + +# Browse tools (TUI) +$ smarttools registry browse [--category=] + → GET /api/v1/tools?category=&page=1 + → GET /api/v1/categories + +# View tool details +$ smarttools registry info + → GET /api/v1/tools// + +# Install a tool +$ smarttools registry install [--version=] + → GET /api/v1/tools///download?version=&install=true + → Writes to ~/.smarttools///config.yaml + → Generates ~/.local/bin/ wrapper (or - if collision) + +# Uninstall a tool +$ smarttools registry uninstall + → Removes ~/.smarttools/// + → Removes wrapper script + +# Publish a tool +$ smarttools registry publish [path] [--dry-run] + → POST /api/v1/tools (with registry token) + → Returns PR URL + +# List my published tools +$ smarttools registry my-tools + → GET /api/v1/me/tools (with registry token) + +# Update index cache +$ smarttools registry update + → GET /api/v1/index.json + → Writes to ~/.smarttools/registry/index.json +``` + +### Project Commands + +```bash +# Install project dependencies from smarttools.yaml +$ smarttools install + → Reads ./smarttools.yaml + → For each dependency: + GET /api/v1/tools///download?version=&install=true + → Installs to ~/.smarttools/// + +# Add a dependency to smarttools.yaml +$ smarttools add [--version=] + → Adds to ./smarttools.yaml dependencies + → Runs install for that tool + +# Show project dependencies status +$ smarttools deps + → Reads ./smarttools.yaml + → Shows installed status for each dependency + → Note: "smarttools list" is reserved for listing installed tools +``` + +**Command naming note:** `smarttools list` already exists to list locally installed tools. Use `smarttools deps` to show project manifest dependencies. + +### Flags available on most commands + +| Flag | Description | +|------|-------------| +| `--offline` | Use cached index only, don't fetch | +| `--refresh` | Force refresh of cached data | +| `--json` | Output in JSON format | +| `--verbose` | Show detailed output | + +## Webhooks and Security + +### HMAC Verification + +All Gitea webhooks are verified using HMAC-SHA256: + +```python +import hmac +import hashlib + +def verify_webhook(request, secret): + signature = request.headers.get('X-Gitea-Signature') + if not signature: + return False + + expected = hmac.new( + secret.encode(), + request.body, + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected) +``` + +### Replay Protection + +While sync is idempotent, implement basic replay protection: + +```python +def process_webhook(request): + delivery_id = request.headers.get('X-Gitea-Delivery') + + # Check if already processed + if db.webhook_log.exists(delivery_id=delivery_id): + return {"status": "already_processed"}, 200 + + # Verify signature + if not verify_webhook(request, WEBHOOK_SECRET): + return {"error": "invalid_signature"}, 401 + + # Process with lock to prevent concurrent processing + with db.lock(f"webhook:{delivery_id}"): + # Double-check after acquiring lock + if db.webhook_log.exists(delivery_id=delivery_id): + return {"status": "already_processed"}, 200 + + # Process the webhook + result = sync_from_repo() + + # Log successful processing + db.webhook_log.insert( + delivery_id=delivery_id, + event_type=request.json.get('action'), + processed_at=datetime.utcnow() + ) + + return {"status": "processed"}, 200 +``` + +### Sync Job Locking + +Prevent concurrent sync operations: + +```python +# Using file lock or database advisory lock +SYNC_LOCK_TIMEOUT = 300 # 5 minutes max + +def sync_from_repo(): + try: + with acquire_lock("registry_sync", timeout=SYNC_LOCK_TIMEOUT): + # Pull latest from Gitea + repo.fetch() + repo.reset('origin/main', hard=True) + + # Parse and update database + for tool_path in glob('tools/*/*/config.yaml'): + update_tool_in_db(tool_path) + + # Rebuild FTS index if needed + rebuild_fts_index() + + except LockTimeout: + logger.warning("Sync already in progress, skipping") + return {"status": "skipped", "reason": "sync_in_progress"} +``` + +### Atomic Sync Strategy + +To avoid partially updated DB during webhook sync, use transactional table swap: + +```python +def sync_from_repo_atomic(): + with acquire_lock("registry_sync", timeout=SYNC_LOCK_TIMEOUT): + # 1. Pull latest from Gitea + repo.fetch() + repo.reset('origin/main', hard=True) + + # 2. Parse all tools into memory + new_tools = [] + for tool_path in glob('tools/*/*/config.yaml'): + tool_data = parse_tool(tool_path) + if tool_data: + new_tools.append(tool_data) + + # 3. Atomic swap using transaction + with db.transaction(): + # Create temp table + db.execute("CREATE TABLE tools_new AS SELECT * FROM tools WHERE 0") + + # Bulk insert into temp table + for tool in new_tools: + db.execute("INSERT INTO tools_new ...", tool) + + # Swap tables atomically + db.execute("ALTER TABLE tools RENAME TO tools_old") + db.execute("ALTER TABLE tools_new RENAME TO tools") + db.execute("DROP TABLE tools_old") + + # Rebuild FTS index + db.execute("INSERT INTO tools_fts(tools_fts) VALUES('rebuild')") + + # Update sync timestamp + db.execute("UPDATE sync_status SET last_sync = ?", [datetime.utcnow()]) +``` + +**Why atomic:** Per-row updates with FTS triggers can yield inconsistent reads under load. Readers may see partial state mid-sync. Table swap ensures all-or-nothing visibility. + +### Error Handling + +| Error Scenario | Behavior | +|----------------|----------| +| Repo fetch fails | Log error, retry in 5 min, alert if 3 failures | +| YAML parse error | Skip tool, log error, continue with others | +| Database write fails | Rollback transaction, retry once, then alert | +| Lock timeout | Skip this sync, next webhook will retry | + +## Automated CI Validation +PRs are validated automatically using SmartTools (dogfooding): + +``` +PR Submitted + │ + ▼ +┌─────────────────────────────────────┐ +│ Gitea CI runs validation tools: │ +│ • schema-validator │ +│ • security-scanner │ +│ • duplicate-detector │ +└───────────────┬─────────────────────┘ + │ + ┌───────┴───────┐ + │ │ + All pass Any fail + │ │ + ▼ ▼ + Auto-merge or Add comment, + flag for review request changes +``` + +Validation checks: +1. **Schema validation**: config.yaml matches expected format +2. **Security scan**: No dangerous shell commands, no secrets in prompts +3. **Duplicate detection**: AI-powered similarity check against existing tools +4. **README check**: README.md exists and is non-empty + +CI workflow (`.gitea/workflows/validate.yaml`): +```yaml +name: Validate Tool Submission +on: [pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Validate schema + run: python scripts/validate_tool.py ${{ github.event.pull_request.head.sha }} + - name: Security scan + run: smarttools run security-scanner < changed_files.txt + - name: Check duplicates + run: smarttools run duplicate-detector < changed_files.txt +``` + +## Registry Repository Structure +Full structure of the SmartTools-Registry repo: + +``` +SmartTools-Registry/ +├── README.md # Registry overview +├── CONTRIBUTING.md # How to submit tools +├── LICENSE +│ +├── tools/ # All published tools +│ ├── rob/ +│ │ ├── summarize/ +│ │ │ ├── config.yaml +│ │ │ └── README.md +│ │ └── translate/ +│ │ ├── config.yaml +│ │ └── README.md +│ └── alice/ +│ └── code-review/ +│ ├── config.yaml +│ └── README.md +│ +├── categories/ +│ └── categories.yaml # Category definitions +│ +├── index.json # Auto-generated search index +│ +├── .gitea/ +│ └── workflows/ +│ ├── validate.yaml # PR validation +│ ├── build-index.yaml # Rebuild index on merge +│ └── notify-api.yaml # Webhook to API server +│ +└── scripts/ + ├── validate_tool.py # Schema validation + ├── build_index.py # Generate index.json + ├── check_duplicates.py # Similarity detection + └── security_scan.py # Security checks +``` + +`categories.yaml` format: +```yaml +categories: + - name: text-processing + description: Tools for manipulating and analyzing text + icon: 📝 + - name: code + description: Tools for code review, generation, and analysis + icon: 💻 + - name: data + description: Tools for data transformation and analysis + icon: 📊 + - name: media + description: Tools for image, audio, and video processing + icon: 🎨 + - name: productivity + description: General productivity and automation tools + icon: ⚡ +``` + +## Download Stats + +### Counting Methodology + +- Count installs only, not views or searches +- Increment **after** successful download (response sent) +- Dedupe by `client_id + tool_id + date` + +```python +def download_tool(owner, name, version, install=False, client_id=None): + tool = get_tool(owner, name, version) + if not tool: + return {"error": "not_found"}, 404 + + config_yaml = tool.config_yaml + + # Only count if this is an install (not just viewing) + if install: + record_download(tool.id, client_id) + + return {"config": config_yaml}, 200 + +def record_download(tool_id, client_id): + today = date.today() + + # Use client_id if provided, otherwise generate anonymous fallback + effective_client_id = client_id or f"anon_{hash(request.remote_addr)}" + + # Dedupe: only count once per client per tool per day + try: + db.download_stats.insert( + tool_id=tool_id, + client_id=effective_client_id, + downloaded_at=today + ) + # Increment counter (can be async/batch updated) + db.execute("UPDATE tools SET downloads = downloads + 1 WHERE id = ?", [tool_id]) + except IntegrityError: + pass # Already counted today, ignore +``` + +### Client ID Generation + +CLI generates a persistent anonymous ID on first run: + +```python +# In CLI, on first run +import uuid +import os + +CONFIG_PATH = os.path.expanduser("~/.smarttools/config.yaml") + +def get_or_create_client_id(): + config = load_config() + if 'client_id' not in config: + config['client_id'] = f"anon_{uuid.uuid4().hex[:16]}" + save_config(config) + return config['client_id'] +``` + +**Fallback when client_id missing:** +- If header `X-Client-ID` not sent, use IP hash as fallback +- This still provides some dedupe for anonymous users +- Logged users' downloads are attributed to their account instead + +### Privacy Considerations + +- No IP addresses stored in database +- `client_id` is client-controlled and can be regenerated +- Stats are aggregated (total count), not individual tracking + +### Async Stats Strategy + +To avoid DB contention on the hot download path: + +```python +from queue import Queue +from threading import Thread + +# In-memory queue for stats +stats_queue = Queue() + +def record_download_async(tool_id, client_id): + """Non-blocking: enqueue for background processing""" + stats_queue.put({ + 'tool_id': tool_id, + 'client_id': client_id, + 'date': date.today() + }) + +def stats_worker(): + """Background thread: batch process stats every 5 seconds""" + batch = [] + while True: + try: + item = stats_queue.get(timeout=5) + batch.append(item) + except Empty: + if batch: + flush_batch(batch) + batch = [] + +def flush_batch(batch): + """Bulk insert with conflict ignore""" + with db.transaction(): + for item in batch: + try: + db.execute(""" + INSERT INTO download_stats (tool_id, client_id, downloaded_at) + VALUES (?, ?, ?) + ON CONFLICT DO NOTHING + """, [item['tool_id'], item['client_id'], item['date']]) + except Exception as e: + logger.warning(f"Stats insert failed: {e}") + # Don't fail downloads for stats errors +``` + +**Failure behavior:** If stats DB write fails, log the error but don't fail the download. Stats are "best effort" - the download must succeed. + +## Search +- Primary search: SQLite FTS5 inside the API. +- `index.json` provides offline CLI search and backup. +- If FTS5 is stale, return results with `X-Search-Index-Stale: true`. + +## API Caching Strategy + +### Cache Headers + +| Endpoint | Cache-Control | ETag | Notes | +|----------|---------------|------|-------| +| `GET /index.json` | `max-age=300, stale-while-revalidate=60` | Yes | 5 min cache, background refresh | +| `GET /tools/{owner}/{name}` | `max-age=60` | Yes | 1 min cache | +| `GET /tools/{owner}/{name}/download` | `max-age=3600, immutable` | Yes | Immutable versions, 1 hour | +| `GET /tools/search` | `no-cache` | No | Always fresh | +| `GET /categories` | `max-age=3600` | Yes | Categories change rarely | + +### ETag Implementation + +```python +import hashlib +from datetime import datetime + +def get_tool_etag(tool): + """Generate ETag from tool identity (immutable versions don't change)""" + # Since versions are immutable, owner/name@version is stable + # Use published_at for extra safety (not updated_at, which doesn't exist) + content = f"{tool.owner}/{tool.name}@{tool.version}:{tool.published_at.isoformat()}" + return hashlib.md5(content.encode()).hexdigest() + +def get_index_etag(): + """Generate ETag from last sync timestamp""" + last_sync = db.get_last_sync_time() + return hashlib.md5(last_sync.isoformat().encode()).hexdigest() + +@app.route('/api/v1/tools///download') +def download_tool(owner, name): + version = request.args.get('version', 'latest') + tool = resolve_and_get_tool(owner, name, version) + etag = get_tool_etag(tool) + + # Check If-None-Match header + if request.headers.get('If-None-Match') == etag: + return '', 304 # Not Modified + + response = jsonify({ + "data": { + "owner": tool.owner, + "name": tool.name, + "resolved_version": tool.version, + "config": tool.config_yaml + } + }) + response.headers['ETag'] = etag + response.headers['Cache-Control'] = 'max-age=3600, immutable' + return response +``` + +**Note:** Since tool versions are immutable, the ETag based on `owner/name@version` is permanently stable. The `published_at` timestamp is included for defense-in-depth but won't change. + +### DB vs Repo Read Strategy + +| Scenario | Read From | Reason | +|----------|-----------|--------| +| Normal operation | SQLite DB | Fast, indexed, FTS | +| DB empty/corrupted | Gitea repo | Fallback/recovery | +| Webhook sync in progress | DB (stale OK) | Avoid blocking reads | +| Search query | SQLite FTS5 | Full-text search | +| Download specific version | DB, fallback to repo | DB is cache, repo is truth | + +### Staleness Detection + +```python +STALE_THRESHOLD = timedelta(minutes=10) + +def is_db_stale(): + last_sync = db.get_last_sync_time() + return datetime.utcnow() - last_sync > STALE_THRESHOLD + +@app.route('/tools/search') +def search_tools(q): + results = db.search_fts(q) + + response = jsonify({"results": results}) + if is_db_stale(): + response.headers['X-Search-Index-Stale'] = 'true' + response.headers['X-Last-Sync'] = db.get_last_sync_time().isoformat() + + return response +``` + +## Error Model + +### Response Envelopes + +**Success response:** +```json +{ + "data": { ... }, + "meta": { + "page": 1, + "per_page": 20, + "total": 42, + "total_pages": 3 + } +} +``` + +**Error response:** +```json +{ + "error": { + "code": "TOOL_NOT_FOUND", + "message": "Tool 'foo/bar' does not exist", + "details": { + "owner": "foo", + "name": "bar", + "suggestion": "Did you mean 'rob/bar'?" + }, + "docs_url": "https://registry.smarttools.dev/docs/errors#TOOL_NOT_FOUND" + } +} +``` + +### Error Codes + +| Code | HTTP | Description | +|------|------|-------------| +| `TOOL_NOT_FOUND` | 404 | Tool does not exist | +| `VERSION_NOT_FOUND` | 404 | Requested version doesn't exist | +| `VERSION_EXISTS` | 409 | Cannot overwrite published version | +| `INVALID_VERSION` | 400 | Version string is not valid semver | +| `INVALID_CONSTRAINT` | 400 | Version constraint syntax error | +| `CONSTRAINT_UNSATISFIABLE` | 404 | No version matches constraint | +| `VALIDATION_ERROR` | 400 | Tool config validation failed | +| `UNAUTHORIZED` | 401 | Missing or invalid auth token | +| `FORBIDDEN` | 403 | Token valid but lacks permission | +| `RATE_LIMITED` | 429 | Too many requests | +| `SLUG_TAKEN` | 409 | Namespace slug already registered | +| `ACCOUNT_LOCKED` | 403 | Too many failed login attempts | +| `SERVER_ERROR` | 500 | Internal error (logged for debugging) | + +## Error Scenarios and Fallbacks + +### CLI Error Handling + +| Scenario | CLI Behavior | User Message | +|----------|--------------|--------------| +| Registry offline | Use cached tools if available | "Registry unavailable. Using cached version." | +| Tool not found | Check cache, then fail | "Tool 'foo/bar' not found in registry or cache." | +| Version constraint unsatisfiable | Show available versions | "No version matches '>=5.0.0'. Available: 1.0.0, 1.1.0, 1.2.0" | +| Auth token expired | Prompt for new token | "Token expired. Please re-authenticate." | +| Rate limited | Wait and retry (backoff) | "Rate limited. Retrying in 30 seconds..." | +| Network timeout | Retry with backoff, then fail | "Connection timed out. Check your network." | + +### Validation Failure Details + +When `VALIDATION_ERROR` occurs, provide specific field errors: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Tool configuration is invalid", + "details": { + "errors": [ + { + "path": "steps[0].provider", + "message": "Provider 'gpt5' is not recognized", + "allowed": ["claude", "openai", "ollama", "mock"] + }, + { + "path": "version", + "message": "Version '1.0' is not valid semver (use '1.0.0')" + } + ] + }, + "docs_url": "https://registry.smarttools.dev/docs/tool-format" + } +} +``` + +### Dependency Resolution Failures + +When `smarttools install` fails on a manifest: + +```bash +$ smarttools install + +Error: Could not resolve all dependencies + + rob/summarize@^2.0.0 + ✗ No matching version (latest: 1.2.0) + + alice/translate@>=1.0.0 + ✓ Found 1.3.0 + +Suggestions: + - Update rob/summarize constraint to "^1.0.0" + - Contact the tool author for a v2 release +``` + +### Graceful Degradation + +| Component Down | Fallback Behavior | +|----------------|-------------------| +| API server | CLI uses `~/.smarttools/registry/index.json` for search | +| Gitea repo | API serves from DB cache (may be stale) | +| FTS5 index | Fall back to LIKE queries (slower but works) | +| Network | Use locally installed tools, skip registry features | + +## UX Requirements (CLI/TUI) + +### Publishing UX + +- `smarttools registry publish --dry-run` validates locally and shows what would be submitted: + ```bash + $ smarttools registry publish --dry-run + + Validating tool... + ✓ config.yaml is valid + ✓ README.md exists (2.3 KB) + ✓ Version 1.1.0 not yet published + + Would submit: + Owner: rob + Name: summarize + Version: 1.1.0 + Category: text-processing + Tags: summarization, ai, text + + Config preview: + ───────────────────────────── + name: summarize + version: "1.1.0" + description: Summarize text using AI + ... + ───────────────────────────── + + Run without --dry-run to submit for review. + ``` + +- **Version bump reminder:** CLI warns if version hasn't changed from published: + ``` + ⚠ Version 1.0.0 is already published. Bump version in config.yaml to publish changes. + ``` + +- First-time publishing flow prompts for token and saves it to config. + +### Progress Indicators + +Long-running operations show progress: + +```bash +$ smarttools install + +Installing project dependencies... + [1/3] rob/summarize@^1.0.0 + Resolving version... 1.2.0 + Downloading... done + Installing... done ✓ + [2/3] alice/translate@>=2.0.0 + Resolving version... 2.1.0 + Downloading... done + Installing... done ✓ + [3/3] official/code-review@* + Resolving version... 1.0.0 + Downloading... done + Installing... done ✓ + +✓ Installed 3 tools +``` + +```bash +$ smarttools registry publish + +Submitting rob/summarize@1.1.0... + Validating... done ✓ + Uploading... done ✓ + Creating PR... done ✓ + +✓ PR created: https://gitea.brrd.tech/rob/SmartTools-Registry/pulls/42 + +Your tool is pending review. You'll receive an email when it's approved. +``` + +### TUI Browse + +`smarttools registry browse` opens a full-screen terminal UI: + +``` +┌─ SmartTools Registry ───────────────────────────────────────┐ +│ Search: [________________] [All Categories ▼] [Sort: Popular ▼] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ▶ rob/summarize v1.2.0 ⬇ 142 │ +│ Summarize text using AI │ +│ [text-processing] [ai] [summarization] │ +│ │ +│ alice/translate v2.1.0 ⬇ 98 │ +│ Translate text between languages │ +│ [text-processing] [translation] │ +│ │ +│ official/code-review v1.0.0 ⬇ 87 │ +│ AI-powered code review │ +│ [code] [review] [ai] │ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ ↑↓ Navigate Enter: Details i: Install /: Search q: Quit │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Keyboard shortcuts:** +| Key | Action | +|-----|--------| +| `↑/↓` or `j/k` | Navigate list | +| `Enter` | View tool details | +| `i` | Install selected tool | +| `/` | Focus search box | +| `c` | Change category filter | +| `s` | Change sort order | +| `?` | Show help | +| `q` | Quit | + +**Virtual scrolling:** For large tool lists (>100), use virtual scrolling to maintain performance. + +### Project Initialization + +```bash +$ smarttools init + +Creating smarttools.yaml... + +Project name [my-project]: my-ai-project +Version [1.0.0]: + +Would you like to add any tools? (search with 's', skip with Enter) +> s +Search: summ + 1. rob/summarize v1.2.0 - Summarize text using AI + 2. alice/summary v1.0.0 - Generate summaries + +Add tool (number, or Enter to finish): 1 +Added rob/summarize@^1.2.0 + +Add tool (number, or Enter to finish): + +✓ Created smarttools.yaml + +name: my-ai-project +version: "1.0.0" +dependencies: + - name: rob/summarize + version: "^1.2.0" + +Run 'smarttools install' to install dependencies. +``` + +### Accessibility + +- **CLI:** All output works with screen readers, no color-only information +- **TUI:** Full keyboard navigation, high-contrast mode support +- **Web UI:** WCAG 2.1 AA compliance target + - Semantic HTML + - ARIA labels for interactive elements + - Focus management in modals + - Skip links for navigation + +## Offline Cache +Cache registry index locally: +``` +~/.smarttools/registry/index.json +``` +Refresh when older than 24 hours; support `--offline` and `--refresh` flags. + +### Index Integrity + +The cached `index.json` includes integrity metadata: + +```json +{ + "version": "1.0", + "generated_at": "2025-01-20T12:00:00Z", + "checksum": "sha256:abc123...", + "tool_count": 142, + "tools": [...] +} +``` + +**API response headers:** +``` +ETag: "abc123def456" +X-Index-Checksum: sha256:abc123... +X-Index-Generated: 2025-01-20T12:00:00Z +``` + +**CLI verification:** +```python +def verify_cached_index(): + """Verify cached index integrity on load""" + cached = load_cached_index() + if not cached: + return None + + # Verify checksum + content = json.dumps(cached['tools'], sort_keys=True) + computed = hashlib.sha256(content.encode()).hexdigest() + + if computed != cached.get('checksum', '').replace('sha256:', ''): + logger.warning("Cached index checksum mismatch, will refresh") + return None + + return cached +``` + +**Corruption handling:** +- If checksum fails, discard cache and fetch fresh +- If partial write detected (missing fields), discard and refresh +- CLI shows warning: "Cached index corrupted, fetching fresh copy..." + +## Web UI Vision +The registry includes a full website, not just an API: + +**Site structure:** +``` +registry.smarttools.dev (or gitea.brrd.tech/registry) +├── / # Landing page +├── /tools # Browse all tools +├── /tools/{owner}/{name} # Tool detail page +├── /categories # Browse by category +├── /categories/{name} # Tools in category +├── /search?q=... # Search results +├── /docs # Documentation +│ ├── /docs/getting-started +│ ├── /docs/creating-tools +│ ├── /docs/publishing +│ └── /docs/best-practices +├── /tutorials # Step-by-step guides +│ ├── /tutorials/first-tool +│ ├── /tutorials/chaining-steps +│ └── /tutorials/code-steps +├── /examples # Example projects +├── /blog # Updates, announcements (optional) +├── /register # Publisher registration +├── /login # Publisher login +├── /dashboard # Publisher dashboard +│ ├── /dashboard/tools # My published tools +│ ├── /dashboard/tokens # API tokens +│ └── /dashboard/settings # Account settings +└── /api/v1/... # API endpoints +``` + +**Landing page content:** +- Hero: "Share and discover AI-powered CLI tools" +- Quick install example +- Featured/popular tools +- Category highlights +- "Get Started" CTA + +**Tool detail page:** +- Name, description, version, author +- README rendered as markdown (sanitized) +- Install command (copy-to-clipboard) +- Version history +- Download stats +- Category/tags +- "Report" button for abuse + +### README Security + +When rendering README markdown, apply XSS sanitization: + +```python +import bleach +from markdown import markdown + +ALLOWED_TAGS = [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'hr', + 'ul', 'ol', 'li', + 'strong', 'em', 'code', 'pre', + 'blockquote', + 'a', 'img', + 'table', 'thead', 'tbody', 'tr', 'th', 'td' +] + +ALLOWED_ATTRS = { + 'a': ['href', 'title'], + 'img': ['src', 'alt', 'title'], + 'code': ['class'], # for syntax highlighting +} + +def render_readme_safe(readme_raw: str) -> str: + """Convert markdown to sanitized HTML""" + # Convert markdown to HTML + html = markdown(readme_raw, extensions=['fenced_code', 'tables']) + + # Sanitize to prevent XSS + safe_html = bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRS, + strip=True + ) + + # Linkify URLs + safe_html = bleach.linkify(safe_html) + + return safe_html +``` + +**Storage strategy:** +- Store raw README in `tools.readme` +- Render and sanitize on request (or cache rendered HTML) +- Never trust client-submitted HTML directly + +**Tech stack options:** +| Option | Pros | Cons | +|--------|------|------| +| Flask + Jinja + Tailwind | Simple, Python-only, fast to build | Less interactive | +| FastAPI + Vue/React SPA | Modern, interactive | More complex, separate build | +| Astro/Next.js | Great SEO, static-first | Different stack (Node.js) | + +**Recommendation:** Flask + Jinja + Tailwind for v1 +- Keeps everything in Python +- Server-rendered is fine for a registry +- Good SEO out of the box +- Can add interactivity with Alpine.js or htmx if needed + +**Monetization considerations:** +- AdSense-compatible (server-rendered pages) +- Analytics tracking for traffic insights +- Future: sponsored tools, featured placements +- Future: premium publisher tiers (more tools, priority review) + +## Implementation Phases + +### Phase 1: Foundation +- Define `smarttools.yaml` manifest format +- Implement tool resolution order (local → global → registry) +- Create SmartTools-Registry repo on Gitea (bootstrap) +- Add 3-5 example tools to seed the registry + +### Phase 2: Core Backend +- Set up Flask/FastAPI project structure +- Implement SQLite database schema +- Build core API endpoints (list, search, get, download) +- Implement webhook receiver for Gitea sync +- Set up HMAC verification + +### Phase 3: CLI Commands +- `smarttools registry search` +- `smarttools registry install` +- `smarttools registry info` +- `smarttools registry browse` (TUI) +- Local index caching + +### Phase 4: Publishing +- Publisher registration (web UI) +- Token management +- `smarttools registry publish` command +- PR creation via Gitea API +- CI validation workflows + +### Phase 5: Project Dependencies +- `smarttools install` (from manifest) +- `smarttools add` command +- Runtime override application +- Dependency resolution + +### Phase 6: Smart Features +- SQLite FTS5 search index +- AI-powered auto-categorization +- Duplicate/similarity detection +- Security scanning + +### Phase 7: Full Web UI +- Landing page +- Tool browsing/search pages +- Tool detail pages with README rendering +- Publisher dashboard +- Documentation/tutorials section + +### Phase 8: Polish & Scale +- Rate limiting +- Abuse reporting +- Analytics integration +- Performance optimization +- Monitoring/alerting diff --git a/docs/WEB_UI.md b/docs/WEB_UI.md new file mode 100644 index 0000000..fa44a82 --- /dev/null +++ b/docs/WEB_UI.md @@ -0,0 +1,1462 @@ +# SmartTools Web UI Design + +## Purpose +Deliver a professional web front-end that explains SmartTools, helps users discover tools, and supports a collaborative ecosystem. The site should drive sustainable revenue without undermining trust or usability. + +## Mission Alignment +This web UI serves the broader SmartTools mission: to provide a **universally accessible development ecosystem** that empowers regular people to collaborate and build upon each other's progress rather than compete. Revenue generated supports: +- Maintaining and expanding the project +- Future hosting of AI models for users with less access to paid services +- Building a sustainable, community-first platform + +The UI design must reflect these values through its structure, content, and monetization approach. + +## Guiding Principles +- **Utility first**: Documentation, tutorials, and examples are the primary draw. +- **Trust and clarity**: Ads and monetization are transparent, minimal, and never block core flows. +- **Collaboration over competition**: Highlight contributors, shared projects, and community learning. +- **Performance and accessibility**: Fast, readable, WCAG 2.1 AA target. +- **Unix philosophy**: Composable, provider-agnostic, YAML-based tools—the UI should communicate this clearly. + +## Information Architecture +Public, ad-supported (Tier 1): +- `/` landing +- `/docs/*` documentation +- `/tutorials/*` +- `/examples` +- `/blog` +- `/about` +- `/donate` + +Registry (Tier 2, ad-free): +- `/tools` +- `/tools/{owner}/{name}` +- `/categories` +- `/categories/{name}` +- `/search` + +Community (Tier 3, light ads): +- `/forum` +- `/contributors` +- `/announcements` + +Publisher dashboard (auth-only): +- `/register`, `/login` +- `/dashboard/tools` +- `/dashboard/tokens` +- `/dashboard/settings` + +API: +- `/api/v1/*` (shared with CLI) + +## Visual Design System + +### Color Palette + +| Role | Color | Hex | Usage | +|------|-------|-----|-------| +| Primary | Indigo | `#6366F1` | CTAs, active states, brand identity | +| Secondary | Cyan | `#06B6D4` | Secondary actions, accents, links | +| Background | Off-white | `#F9FAFB` | Page background | +| Surface | White | `#FFFFFF` | Cards, content areas | +| Text Primary | Dark gray | `#1F2937` | Headlines, body text | +| Text Secondary | Medium gray | `#6B7280` | Descriptions, metadata | +| Text Muted | Light gray | `#9CA3AF` | Timestamps, hints | +| Border | Light gray | `#E5E7EB` | Card borders, dividers | +| Success | Green | `#10B981` | Success states, confirmations | +| Warning | Amber | `#F59E0B` | Warnings, deprecation notices | +| Error | Red | `#EF4444` | Error states, critical alerts | +| Header | Dark slate | `#2C3E50` | Header background | +| Ad Container | Light blue | `#DBEAFE` | Ad zone background (distinct from content) | +| Sponsored | Light amber | `#FEF3C7` | Sponsored content background | + +**Contrast Requirements:** +- Body text: minimum 4.5:1 ratio (WCAG AA) +- Large text (18px+): minimum 3:1 ratio +- UI components: minimum 3:1 ratio against adjacent colors + +### Typography + +| Element | Font | Size | Weight | Line Height | +|---------|------|------|--------|-------------| +| H1 | Inter/system-ui | 36px (2.25rem) | 700 | 1.2 | +| H2 | Inter/system-ui | 24px (1.5rem) | 700 | 1.3 | +| H3 | Inter/system-ui | 20px (1.25rem) | 600 | 1.4 | +| H4 | Inter/system-ui | 18px (1.125rem) | 600 | 1.4 | +| Body | Inter/system-ui | 16px (1rem) | 400 | 1.6 | +| Small | Inter/system-ui | 14px (0.875rem) | 400 | 1.5 | +| Code | JetBrains Mono/monospace | 14px | 400 | 1.5 | +| Code block | JetBrains Mono/monospace | 13px | 400 | 1.6 | + +**Font Stack:** +```css +--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; +--font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace; +``` + +### Spacing System + +Use an 8px base grid for consistent spacing: + +| Token | Value | Usage | +|-------|-------|-------| +| `--space-1` | 4px | Tight spacing, icon margins | +| `--space-2` | 8px | Element gaps | +| `--space-3` | 12px | Small component padding | +| `--space-4` | 16px | Card padding, section gaps | +| `--space-6` | 24px | Section padding | +| `--space-8` | 32px | Large gaps | +| `--space-12` | 48px | Section margins | +| `--space-16` | 64px | Major section separators | + +### Border Radius + +| Token | Value | Usage | +|-------|-------|-------| +| `--radius-sm` | 4px | Buttons, badges | +| `--radius-md` | 8px | Cards, inputs | +| `--radius-lg` | 12px | Modals, large cards | +| `--radius-full` | 9999px | Avatars, pills | + +### Shadow System + +```css +--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +--shadow-hover: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +``` + +## Page Requirements + +### Landing Page (`/`) + +**Purpose:** Convert visitors to users by clearly communicating SmartTools' value proposition and providing immediate paths to explore. + +**Reference mockup:** `discussions/diagrams/smarttools-registry_rob_6.svg` + +#### Section 1: Hero (Above the Fold) +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [SmartTools] Docs Tutorials Registry Community About 🔍 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Build Custom AI Commands in YAML │ +│ ───────────────────────────────────────────────────────── │ +│ Create Unix-style pipeable tools that work with any AI │ +│ provider. Provider-agnostic and composable. │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ $ pip install smarttools && smarttools init │ [📋]│ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ [Get Started] [View Tutorials] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Content:** +- **Headline:** "Build Custom AI Commands in YAML" (benefit-focused, differentiating) +- **Subheadline:** "Create Unix-style pipeable tools that work with any AI provider. Provider-agnostic and composable for ultimate flexibility." +- **Install snippet:** `pip install smarttools && smarttools init` with copy button +- **Primary CTA:** "Get Started" → links to `/docs/getting-started` (indigo background) +- **Secondary CTA:** "View Tutorials" → links to `/tutorials` (outlined, cyan border) + +**Design Notes:** +- Hero background: white card (#FFFFFF) with subtle shadow on off-white page +- Maximum content width: 1100px centered +- Install snippet: monospace font, light gray background (#E0E0E0), copy icon on right + +#### Section 2: Three Pillars (Why SmartTools?) +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Why SmartTools? │ +├──────────────────┬──────────────────┬────────────────────────────┤ +│ [✓] Easy │ [⚡] Powerful │ [👥] Community │ +│ │ │ │ +│ Simple YAML │ Any AI │ Share, discover, │ +│ configuration │ provider, │ contribute to a │ +│ for quick │ compose │ growing ecosystem. │ +│ setup. │ complex │ │ +│ │ workflows. │ │ +└──────────────────┴──────────────────┴────────────────────────────┘ +``` + +**Content:** +- **Pillar 1 - Easy to Use:** Icon in indigo circle, "Simple YAML configuration for quick setup." +- **Pillar 2 - Powerful:** Icon in cyan circle, "Leverage any AI provider, compose complex workflows." +- **Pillar 3 - Community:** Icon in indigo circle, "Share, discover, and contribute to a growing ecosystem." + +**Design Notes:** +- Each pillar: white card with 1px border, subtle shadow on hover +- Icon circles: 40px diameter with pillar icon centered +- Equal width columns (3 across on desktop) + +#### Section 3: Featured Tools & Projects +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Featured Tools & Projects │ +├──────────────────┬──────────────────┬────────────────────────────┤ +│ [Category] │ [Category] │ [Category] │ +│ ● Tool Title │ ● Tool Title │ ● Tool Title │ +│ Description... │ Description... │ Description... │ +│ Author: name │ Author: name │ Author: name │ +│ Downloads: 1.2K │ Downloads: 800 │ Downloads: 2.5K │ +│ ┌────────────┐ │ ┌────────────┐ │ ┌────────────┐ │ +│ │run command │ │ │run command │ │ │run command │ │ +│ └────────────┘ │ └────────────┘ │ └────────────┘ │ +├──────────────────┼──────────────────┼────────────────────────────┤ +│ [Row 2...] │ [Row 2...] │ [Row 2...] │ +└──────────────────┴──────────────────┴────────────────────────────┘ +``` + +**Content (per card):** +- Category badge (top-right, cyan pill) +- Tool icon/avatar (indigo circle) +- Tool name (bold, 18px) +- Short description (14px, secondary text) +- Author attribution +- Download count with icon +- One-line install command in code box + +**Data Source:** `GET /api/v1/tools?sort=downloads&limit=6` + +**Design Notes:** +- 3 columns on desktop, 2 on tablet, 1 on mobile +- Cards have subtle shadow, lift on hover +- "View All Tools" link below grid → `/tools` + +#### Section 4: Getting Started +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Getting Started │ +├──────────────────┬──────────────────┬────────────────────────────┤ +│ Tutorial 1: │ Tutorial 2: │ Tutorial 3: │ +│ Basic Setup │ Your First Tool │ Advanced Workflows │ +│ Learn how to... │ Create a │ Combine multiple │ +│ │ simple AI... │ tools for... │ +│ [Read More] │ [Read More] │ [Read More] │ +└──────────────────┴──────────────────┴────────────────────────────┘ +``` + +**Content:** +- 3 tutorial cards highlighting core learning paths +- Each card: title (bold), description (2 lines max), CTA button + +**Design Notes:** +- Matches tool card styling for visual consistency +- "Read More" buttons in primary indigo + +#### Section 5: Featured Contributor +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Featured Contributor │ +├─────────────────────────────────────────────────────────────────┤ +│ [Avatar] Name Here │ +│ Creator of "Tool Name" and active community member. │ +│ [View Profile] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Content:** +- Monthly rotating spotlight +- Avatar (60px circle), name, brief bio, profile link + +**Data Source:** Manual curation or `GET /api/v1/contributors/featured` + +#### Section 6: Footer Ad Zone (Optional) +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [Advertisement: Support SmartTools Development] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Design Notes:** +- Light blue background (#DBEAFE) to distinguish from content +- Clearly labeled as advertisement +- Optional based on ad fill rate + +#### Section 7: Footer +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SmartTools │ +│ ───────────────────────────────────────────────────────────── │ +│ Docs | Registry | Community | About | Donate │ +│ Privacy | Terms | GitHub | Twitter │ +│ │ +│ © 2025 SmartTools. Open source under MIT License. │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Docs/Tutorials Pages (`/docs/*`, `/tutorials/*`) + +**Layout:** +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ [Header Navigation] │ +├──────────────┬────────────────────────────────────────────┬───────────┤ +│ TOC │ Content Area (70%) │ Sidebar │ +│ (Desktop) │ │ (Ads) │ +│ │ # Page Title │ │ +│ - Section 1 │ │ [Ad] │ +│ - Section 2 │ Content with code blocks... │ │ +│ - Section 3 │ │ │ +│ │ ```python │ │ +│ │ # Code with syntax highlighting │ │ +│ │ ``` [Copy] │ │ +│ │ │ │ +│ │ [Embedded Video] │ │ +│ │ │ │ +└──────────────┴────────────────────────────────────────────┴───────────┘ +``` + +**Content Requirements:** +- Persistent left TOC on desktop (scroll-spy highlighting current section) +- Code blocks with syntax highlighting and copy button +- Video embeds (YouTube) with play button overlay, lazy-loaded +- Callout boxes for tips, warnings, info (color-coded) +- Related articles at bottom + +**Design Notes:** +- TOC: fixed on scroll, 200px width +- Content area: max-width 700px, generous line-height (1.6-1.8) +- Sidebar ads: 300px width, only on desktop +- Mobile: TOC collapses to hamburger, sidebar hidden + +### Tool Detail Page (`/tools/{owner}/{name}`) + +**Layout:** +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ [Header Navigation] │ +├─────────────────────────────────────────────────────┬─────────────────┤ +│ README Content (70%) │ Sidebar (30%) │ +│ │ │ +│ # tool-name │ ┌───────────┐ │ +│ │ │Install │ │ +│ Rendered markdown from README.md... │ │ │ │ +│ │ │$ run cmd │ │ +│ - Usage examples │ └───────────┘ │ +│ - Configuration │ │ +│ - Step definitions │ Versions: │ +│ │ v1.2.0 ● │ +│ │ v1.1.0 │ +│ │ v1.0.0 │ +│ │ │ +│ │ Downloads: │ +│ │ 1,234 │ +│ │ │ +│ │ Category: │ +│ │ [text-proc] │ +│ │ │ +│ │ Tags: │ +│ │ [ai] [cli] │ +│ │ │ +│ │ [Report] │ +└─────────────────────────────────────────────────────┴─────────────────┘ +``` + +**Sidebar Elements:** +- Install command with copy button (prominent) +- Version selector/list (current version highlighted) +- Download statistics +- Category badge (linked) +- Tags (linked to search) +- Report abuse button +- Publisher info (avatar, name, link to profile) + +**NO ADS on tool detail pages** (Tier 2 - registry is ad-free) + +### Registry Browse Page (`/tools`) + +**Layout:** +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ [Header Navigation] │ +├───────────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 🔍 Search tools... [Category ▼] [Sort ▼] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +├───────────────────────────────────────────────────────────────────────┤ +│ Showing 142 tools │ +├──────────────────┬──────────────────┬──────────────────┬──────────────┤ +│ [Tool Card] │ [Tool Card] │ [Tool Card] │ [Tool Card] │ +│ │ │ │ │ +├──────────────────┼──────────────────┼──────────────────┼──────────────┤ +│ [Tool Card] │ [Tool Card] │ [Tool Card] │ [Tool Card] │ +│ │ │ │ │ +├───────────────────────────────────────────────────────────────────────┤ +│ [← Previous] Page 1 of 8 [Next →] │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +**Search/Filter Features:** +- Full-text search with debounce (300ms) +- Category dropdown filter +- Sort options: Popular (downloads), Recent, Name +- Results count display +- Pagination (20 per page) + +**Tool Card (compact):** +- Tool name + owner +- Short description (2 lines max, truncated) +- Download count +- Last updated date +- Category tag + +**NO ADS on browse pages** (Tier 2) + +### Publisher Dashboard (`/dashboard`) + +**Layout:** +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ [Header with user menu] │ +├─────────────────┬─────────────────────────────────────────────────────┤ +│ Sidebar │ Content Area │ +│ │ │ +│ My Tools │ ┌─ Tab: My Tools ──────────────────────────────┐ │ +│ API Tokens │ │ │ │ +│ Settings │ │ Published Tools (3) [+ New Tool] │ │ +│ │ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ │ summarize v1.2.0 | 142 downloads │ │ │ +│ │ │ │ [Edit] [View] [Yank] │ │ │ +│ │ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ │ Pending PRs (1) │ │ +│ │ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ │ new-tool v1.0.0 | Awaiting review │ │ │ +│ │ │ │ [View PR] │ │ │ +│ │ │ └─────────────────────────────────────────┘ │ │ +│ │ └──────────────────────────────────────────────┘ │ +└─────────────────┴─────────────────────────────────────────────────────┘ +``` + +**Tabs:** +1. **My Tools:** List of published tools with stats, edit/view/yank actions +2. **API Tokens:** Token management table (name, created, last used, revoke) +3. **Settings:** Profile editing (display name, bio, website), password change + +**Design Notes:** +- Clean, utilitarian design +- Clear action buttons +- Status indicators for pending PRs +- Token creation shows token only once with copy functionality + +### Donate Page (`/donate`) + +**Content:** +- Mission statement (emotional, connecting to values) +- Clear explanation of fund usage (hosting, development, future AI hosting) +- Multiple donation options (GitHub Sponsors, PayPal, Ko-fi) +- Optional donor recognition section +- Transparency about current costs/goals + +**Design Notes:** +- Clean, trustworthy design +- Clear CTAs for each donation method +- No ads on this page + +## Component Library + +### Buttons + +**Primary Button:** +```html + +``` +- Background: Primary indigo (#6366F1) +- Text: White, 16px, font-weight 600 +- Padding: 12px 24px +- Border-radius: 4px +- Hover: Darken 10%, subtle shadow +- Focus: 2px outline offset + +**Secondary Button:** +```html + +``` +- Background: Transparent +- Border: 2px solid cyan (#06B6D4) +- Text: Cyan, 16px, font-weight 600 +- Hover: Light cyan background (#ECFEFF) + +**Ghost Button:** +```html + +``` +- Background: Transparent +- Text: Primary indigo +- Hover: Light indigo background + +**Danger Button:** +```html + +``` +- Background: Error red (#EF4444) +- Text: White +- Used for destructive actions + +### Cards + +**Tool Card:** +``` +┌───────────────────────────────────────┐ +│ [Category] │ +│ [●] Tool Name │ +│ Short description of the tool that │ +│ may span two lines maximum... │ +│ │ +│ Author: owner-name │ +│ ⬇ 1,234 downloads │ +│ │ +│ ┌─────────────────────────────────┐ │ +│ │ smarttools run owner/tool │ │ +│ └─────────────────────────────────┘ │ +└───────────────────────────────────────┘ +``` +- Background: White (#FFFFFF) +- Border: 1px solid border color (#E5E7EB) +- Border-radius: 8px +- Shadow: shadow-sm, shadow-md on hover +- Padding: 16px +- Category badge: absolute top-right, cyan background, white text, 12px + +**Tutorial Card:** +``` +┌───────────────────────────────────────┐ +│ [Optional Thumbnail Image] │ +│ │ +│ Tutorial Title Here │ +│ Brief description of what the │ +│ tutorial covers... │ +│ │ +│ [Read More →] │ +└───────────────────────────────────────┘ +``` +- Same base styling as tool card +- Optional thumbnail: aspect-ratio 16:9, lazy-loaded + +**Contributor Card:** +``` +┌───────────────────────────────────────┐ +│ [Avatar] Contributor Name │ +│ @github-handle │ +│ Creator of "Tool Name" │ +│ [View Profile] │ +└───────────────────────────────────────┘ +``` +- Avatar: 48px circle +- Horizontal layout for spotlight, vertical for grid + +### Navigation + +**Header Navigation:** +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ [Logo] [Docs] [Tutorials] [Registry] [Community] [About] 🔍 │ +│ [Donate] │ +└─────────────────────────────────────────────────────────────────────────┘ +``` +- Background: Dark slate (#2C3E50) +- Logo: White text, 24px, bold +- Nav links: White text, 16px +- Active/hover: Underline or slight background +- Mobile: Hamburger menu with slide-out drawer + +**Breadcrumbs:** +``` +Registry > owner > tool-name > v1.2.0 +``` +- Separator: `>` or `/` +- Current page: bold, not linked +- Previous pages: linked, secondary color + +### Form Elements + +**Text Input:** +```html + +``` +- Border: 1px solid border color +- Border-radius: 8px +- Padding: 12px 16px +- Focus: Primary indigo border, subtle shadow +- Height: 44px (touch target compliance) + +**Search Input with Icon:** +``` +┌─────────────────────────────────────┐ +│ 🔍 Search tools... │ +└─────────────────────────────────────┘ +``` +- Icon: Left-aligned, muted color +- Placeholder: Secondary text color + +**Select/Dropdown:** +``` +┌─────────────────────────────────────┐ +│ Category ▼ │ +└─────────────────────────────────────┘ +``` +- Same styling as text input +- Chevron icon on right + +### Badges and Tags + +**Category Badge:** +```html +text-processing +``` +- Background: Cyan (#06B6D4) +- Text: White, 12px +- Padding: 4px 8px +- Border-radius: 4px + +**Tag:** +```html +ai +``` +- Background: Light gray (#F3F4F6) +- Text: Secondary gray, 12px +- Border: 1px solid border color +- Border-radius: 9999px (pill) +- Clickable (links to search) + +**Status Badge:** +```html +Published +Pending +Yanked +``` +- Success: Green background +- Warning: Amber background +- Error: Red background + +### Code Blocks + +**Inline Code:** +```html +smarttools run foo +``` +- Background: Light gray (#F3F4F6) +- Font: Monospace +- Padding: 2px 6px +- Border-radius: 4px + +**Code Block with Copy:** +``` +┌───────────────────────────────────────────────────────┐ +│ ```python [📋] │ +│ def hello(): │ +│ print("Hello, World!") │ +│ ``` │ +└───────────────────────────────────────────────────────┘ +``` +- Background: Dark (#1F2937) or light (#F9FAFB) +- Syntax highlighting (Prism.js or Highlight.js) +- Copy button: top-right, appears on hover +- Line numbers: optional, enabled for tutorials + +### Callout Boxes + +**Info Callout:** +``` +┌─────────────────────────────────────────────────────┐ +│ ℹ️ Note │ +│ This is helpful information for the user. │ +└─────────────────────────────────────────────────────┘ +``` +- Background: Light blue (#DBEAFE) +- Border-left: 4px solid blue (#3B82F6) + +**Warning Callout:** +``` +┌─────────────────────────────────────────────────────┐ +│ ⚠️ Warning │ +│ Be careful with this configuration. │ +└─────────────────────────────────────────────────────┘ +``` +- Background: Light amber (#FEF3C7) +- Border-left: 4px solid amber (#F59E0B) + +**Error Callout:** +``` +┌─────────────────────────────────────────────────────┐ +│ ❌ Important │ +│ This action cannot be undone. │ +└─────────────────────────────────────────────────────┘ +``` +- Background: Light red (#FEE2E2) +- Border-left: 4px solid red (#EF4444) + +**Tip Callout:** +``` +┌─────────────────────────────────────────────────────┐ +│ 💡 Tip │ +│ You can also use this shortcut... │ +└─────────────────────────────────────────────────────┘ +``` +- Background: Light green (#D1FAE5) +- Border-left: 4px solid green (#10B981) + +### Loading States + +**Skeleton Loader:** +``` +┌───────────────────────────────────────┐ +│ ████████████████ │ +│ ████████████████████████████████ │ +│ ████████████ │ +└───────────────────────────────────────┘ +``` +- Animated shimmer effect +- Matches component dimensions +- Used for cards, text blocks + +**Spinner:** +- Circular spinner for buttons, inline loading +- Primary indigo color +- Size: 16px (small), 24px (medium), 32px (large) + +**Progress Bar:** +- Used for multi-step operations +- Shows percentage or step count + +## Responsive Design + +### Breakpoints + +| Breakpoint | Width | Name | Grid Columns | +|------------|-------|------|--------------| +| xs | < 480px | Extra small phones | 1 | +| sm | 480-639px | Phones | 1 | +| md | 640-767px | Large phones / small tablets | 2 | +| lg | 768-1023px | Tablets | 2-3 | +| xl | 1024-1279px | Small desktops | 3-4 | +| 2xl | ≥ 1280px | Large desktops | 4 | + +### Layout Adaptations + +**Mobile (< 640px):** +- Single-column layout +- Navigation: hamburger menu with slide-out drawer +- Hero: stacked content, centered +- Tool cards: full-width, stacked +- TOC: collapsible accordion at top of page +- Sidebar ads: hidden +- Footer ads: optional, minimal + +**Tablet (640-1023px):** +- Two-column grid for cards +- Navigation: horizontal but condensed +- TOC: collapsible sidebar +- Sidebar ads: may show below content + +**Desktop (≥ 1024px):** +- Full multi-column layout +- Navigation: full horizontal with all links visible +- TOC: fixed left sidebar +- Sidebar ads: visible in right column +- Maximum content width: 1280px with centered container + +### Touch Targets + +All interactive elements must meet minimum touch target size: +- Minimum size: 44×44px (WCAG 2.1 AA) +- Spacing between targets: minimum 8px +- Applies to: buttons, links, form inputs, navigation items + +### Mobile-Specific Considerations + +- No horizontal scrolling +- Images: responsive with max-width: 100% +- Tables: horizontal scroll wrapper on small screens +- Code blocks: horizontal scroll with visible scrollbar +- Modals: full-screen on mobile, centered on desktop +- Keyboard: virtual keyboard should not obscure inputs + +## Performance Budgets + +### Core Web Vitals Targets + +| Metric | Target | Maximum | +|--------|--------|---------| +| Largest Contentful Paint (LCP) | < 1.5s | < 2.5s | +| First Input Delay (FID) | < 50ms | < 100ms | +| Cumulative Layout Shift (CLS) | < 0.05 | < 0.1 | +| First Contentful Paint (FCP) | < 1.0s | < 1.8s | +| Time to Interactive (TTI) | < 2.5s | < 3.5s | + +### Resource Budgets + +| Resource | Budget | Notes | +|----------|--------|-------| +| Total page weight | < 500KB | Excluding ads | +| JavaScript (compressed) | < 100KB | Main bundle | +| CSS (compressed) | < 50KB | Main stylesheet | +| Images (above fold) | < 200KB | Hero, featured tools | +| Fonts | < 100KB | Subset, WOFF2 format | +| Third-party scripts | < 150KB | Analytics, ads (lazy) | + +### Loading Strategy + +**Critical Path (synchronous):** +1. HTML document +2. Critical CSS (inlined in ``) +3. Above-the-fold content + +**Deferred Loading:** +1. Non-critical CSS (preload, async) +2. JavaScript (defer) +3. Below-fold images (lazy-load with `loading="lazy"`) +4. Third-party scripts (ads, analytics) +5. Video embeds (lazy, placeholder until visible) + +### Caching Strategy + +| Resource | Cache-Control | Notes | +|----------|---------------|-------| +| Static assets (CSS, JS) | `max-age=31536000, immutable` | Hashed filenames | +| Images | `max-age=86400` | 1 day | +| HTML pages | `max-age=300, stale-while-revalidate=60` | 5 min, background refresh | +| API responses | `max-age=60` | 1 min for tool lists | +| Tool downloads | `max-age=3600, immutable` | Immutable versions | + +### Performance Monitoring + +- Monitor Core Web Vitals in production +- Set up alerts for degradation (>10% threshold) +- Track page load times by route +- Monitor JavaScript error rates + +## Error States and Fallbacks + +### Network Errors + +**API Unavailable:** +``` +┌─────────────────────────────────────────────────────┐ +│ ⚠️ Registry Temporarily Unavailable │ +│ │ +│ We're having trouble connecting to the registry. │ +│ Please try again in a few moments. │ +│ │ +│ [Retry] │ +└─────────────────────────────────────────────────────┘ +``` + +**Slow Connection:** +- Show skeleton loaders for content +- Progressive loading with visible feedback +- Timeout after 10 seconds with retry option + +### Tool Not Found (404): +``` +┌─────────────────────────────────────────────────────┐ +│ Tool Not Found │ +│ │ +│ The tool "owner/tool-name" doesn't exist or may │ +│ have been removed. │ +│ │ +│ [Browse Tools] [Search Registry] │ +└─────────────────────────────────────────────────────┘ +``` + +### Search No Results: +``` +┌─────────────────────────────────────────────────────┐ +│ No tools found for "query" │ +│ │ +│ Suggestions: │ +│ • Try different keywords │ +│ • Check spelling │ +│ • Browse by category │ +│ │ +│ [Browse All Tools] │ +└─────────────────────────────────────────────────────┘ +``` + +### Offline Mode + +If service worker is implemented: +- Show cached pages when offline +- Indicate offline status in header +- Queue actions (report, install) for when online + +### Form Errors + +**Inline Validation:** +- Show error message below field +- Red border on invalid fields +- Error icon in field + +**Form Submission Error:** +- Toast notification for transient errors +- Inline error summary for validation failures +- Preserve form state on error + +## Ad and Revenue Strategy +- **Ad placement**: One sidebar unit on long-form docs/tut pages, optional footer banner on landing, none on registry pages. +- **No ads** in install flows, login/registration, or tool browsing details. +- **Sponsored content**: Clearly labeled and separated from organic rankings. +- **YouTube**: Embed tutorials with transcripts; also drive to channel. + +## Monetization Extensions (Optional) +- **Donations**: Single page with clear use-of-funds. +- **Featured projects**: Curated or sponsored slots with explicit labeling. +- **Premium publisher tier**: More tools, enhanced analytics, priority review. +- **Training/consulting**: Workshops or enterprise onboarding. + +## Data and Governance +Proposed minimal tables (web-only): +- `promotions` (featured tools/projects, start/end, placement, audit). +- `featured_projects` (title, description, owner, url, status). +- `content_pages` (docs/tutorials metadata for listing). +- `announcements` (title, body, published_at). + +Roles and permissions: +- `admin`: can publish announcements, manage promotions, moderate reports. +- `editor`: can create/update docs, tutorials, featured projects. +- `publisher`: can manage their own tools and profile only. +- `contributor`: can partisipate in discussions in the forums. + +Audit fields (required on content tables): +- `created_by`, `updated_by`, `created_at`, `updated_at`. + +Ranking rules: +- Organic search uses relevance and downloads. +- Sponsored placements appear in dedicated sections and do not alter organic order. + +Promotions placement rules: +- Slots are deterministic (e.g., positions 1 and 5 in lists). +- Promotions are clearly labeled and never mixed into organic ranking. +- `promotions` includes `placement`, `priority`, `start_at`, `end_at`, `status`. + +## Privacy and Consent +- Consent banner for analytics/ads. +- Minimal tracking, anonymized IPs. +- Clear privacy policy and retention policy. + +Consent storage: +- Store a `consents` record keyed by `client_id` (anonymous) or user id (logged-in). +- Respect opt-outs by disabling analytics/ads on server-rendered pages. + +## UX and Accessibility +- Keyboard navigation for all interactive elements. +- High contrast and readable typography. +- Mobile-first layout; ads hidden on mobile except optional footer. +- Avoid popups and auto-play media. + +Responsive breakpoints (baseline): +- Mobile: <640px +- Tablet: 640–1024px +- Desktop: >1024px + +## Tech Stack (Phase 7 Target) +- **Flask + Jinja + Tailwind** for SEO-friendly server-rendered pages. +- Optional **htmx** or **Alpine.js** for small interactivity. +- Shared registry API for data. + +## Auth and Session Model +- Server-side sessions (DB-backed) for dashboard views. +- Cookies: `HttpOnly`, `SameSite=Lax`, `Secure` in production. +- CSRF protection on all POST/PUT/DELETE web forms. +- Session TTL: 7 days with rotation on login. +- Logout invalidates session server-side. + +## API Surfaces for Web UI +Read-only UI calls should use the existing API: +- `/api/v1/tools`, `/api/v1/tools/search`, `/api/v1/categories`, `/api/v1/stats/popular`. + +Publisher dashboard uses auth endpoints: +- `/api/v1/login`, `/api/v1/tokens`, `/api/v1/me/tools`. + +## Payments and Donations (Optional) +- Decide early on a processor (Stripe, Ko-fi, paypal, bitcoin/crypto) to avoid churn. +- Webhook handling must verify signatures and enforce idempotency keys. +- Store donation/subscription state with `status`, `amount`, `currency`, `provider_id`, `created_at`. + +## Moderation and Abuse Reporting +- Tool detail pages require a minimal abuse report endpoint. +- Create `reports` table with `tool_id`, `reporter`, `reason`, `status`. +- Add rate limit to the report endpoint to prevent spam. + +## Media and Asset Handling +- Images/screenshots stored in object storage (preferred) or a dedicated `assets/` bucket. +- Enforce size limits and content-type validation. +- Generate thumbnails for cards and lazy-load in UI. + +## Caching and SEO Serving +- Public pages include ETag/Last-Modified for CDN caching. +- Dashboard pages are non-cacheable and user-specific. +- Avoid cache poisoning by varying on auth cookies. + +## SEO Strategy + +### Technical SEO + +**URL Structure:** +``` +/ # Landing page +/tools # Registry browse +/tools/{owner}/{name} # Tool detail (canonical) +/tools/{owner}/{name}/v/1.0 # Specific version +/categories/{slug} # Category listing +/docs/{section}/{page} # Documentation +/tutorials/{slug} # Tutorial pages +``` + +**Meta Tags (per page type):** + +Landing page: +```html +SmartTools - Build Custom AI Commands in YAML + + +``` + +Tool detail page: +```html +{tool-name} by {owner} - SmartTools Registry + +``` + +### Structured Data (Schema.org) + +**SoftwareApplication (for tools):** +```json +{ + "@context": "https://schema.org", + "@type": "SoftwareApplication", + "name": "summarize", + "applicationCategory": "DeveloperApplication", + "operatingSystem": "Linux, macOS, Windows", + "author": { + "@type": "Person", + "name": "owner-name" + }, + "downloadUrl": "https://registry.smarttools.dev/tools/owner/summarize", + "softwareVersion": "1.2.0", + "aggregateRating": { + "@type": "AggregateRating", + "ratingValue": "4.5", + "ratingCount": "142" + } +} +``` + +**Organization (site-wide):** +```json +{ + "@context": "https://schema.org", + "@type": "Organization", + "name": "SmartTools", + "url": "https://smarttools.dev", + "logo": "https://smarttools.dev/logo.png", + "sameAs": [ + "https://github.com/your-org/smarttools", + "https://twitter.com/smarttools" + ] +} +``` + +**Article (for tutorials/blog):** +```json +{ + "@context": "https://schema.org", + "@type": "TechArticle", + "headline": "Getting Started with SmartTools", + "author": {"@type": "Person", "name": "Author Name"}, + "datePublished": "2025-01-15", + "dateModified": "2025-01-20" +} +``` + +### Open Graph & Social Sharing + +```html + + + + + + + + + + + + + +``` + +### Sitemap + +Auto-generate `sitemap.xml`: +- All public pages +- Tool detail pages (updated on publish) +- Category pages +- Documentation pages +- Priority based on page importance + +```xml + + + + https://smarttools.dev/ + 1.0 + daily + + + https://smarttools.dev/tools + 0.9 + daily + + + +``` + +### robots.txt + +``` +User-agent: * +Allow: / + +# Block auth pages from indexing +Disallow: /login +Disallow: /register +Disallow: /dashboard +Disallow: /api/ + +Sitemap: https://smarttools.dev/sitemap.xml +``` + +### Canonical URLs + +- Each page has a single canonical URL +- Use `` tag +- Avoid duplicate content issues +- Tool versions link to latest as canonical + +### Performance for SEO + +- Server-side rendering for all public pages (Flask + Jinja) +- No JavaScript required for content visibility +- Fast TTFB (< 200ms target) +- Mobile-friendly (responsive design) +- Core Web Vitals in "good" range + +## Content Strategy +- Core tutorials that mirror CLI workflows. +- “Project spotlights” to showcase real usage. +- Contributor recognition (monthly spotlight). +- Announcements and changelog summaries. +- Encourage AI parsing to increase adsense revenue. + +## Risks and Mitigations +- **Ad overload**: strict placement rules, no ads in registry. +- **Moderation burden and load**: Implement AI enabled moderation with flags to alert human intervention to keep things moving and simplefy maintenence. +- **Content drift**: quarterly doc reviews tied to releases. + - **Consent and tracking**: default to privacy-preserving settings. + +## Phase 7 Implementation Checklist + +### 7.1 Foundation & Setup +- [ ] Set up Flask project structure with blueprints +- [ ] Configure Jinja2 templates with base layout +- [ ] Integrate Tailwind CSS (build pipeline) +- [ ] Set up static asset handling (CSS, JS, images) +- [ ] Configure development/production environments +- [ ] Set up database models for web-specific tables + +### 7.2 Core Templates & Components +- [ ] Create base template with header/footer +- [ ] Implement navigation component (desktop + mobile) +- [ ] Build reusable card components (tool, tutorial, contributor) +- [ ] Create form components (inputs, buttons, dropdowns) +- [ ] Implement callout/alert components +- [ ] Build code block component with copy functionality +- [ ] Create loading states (skeleton, spinner) +- [ ] Implement responsive grid system + +### 7.3 Landing Page +- [ ] Hero section with install snippet +- [ ] Three pillars section (Easy, Powerful, Community) +- [ ] Featured tools grid (API integration) +- [ ] Getting started tutorial cards +- [ ] Featured contributor spotlight +- [ ] Footer with links and optional ad zone + +### 7.4 Registry Pages (Ad-Free) +- [ ] Tool browse page with search bar +- [ ] Category dropdown filter +- [ ] Sort options (popular, recent, name) +- [ ] Pagination component +- [ ] Tool card grid layout +- [ ] Tool detail page with README rendering +- [ ] Version selector in sidebar +- [ ] Install command with copy +- [ ] Report abuse button/modal +- [ ] Category pages + +### 7.5 Documentation & Tutorials +- [ ] Docs landing page with section links +- [ ] Tutorial listing page +- [ ] Content page template with TOC +- [ ] Scroll-spy for TOC highlighting +- [ ] Code syntax highlighting (Prism/Highlight.js) +- [ ] Video embed component (YouTube) +- [ ] Related articles section +- [ ] Sidebar ad placement (desktop only) + +### 7.6 Authentication & Dashboard +- [ ] Registration page and flow +- [ ] Login page with error handling +- [ ] Password reset flow (if implementing) +- [ ] Session management (cookies, CSRF) +- [ ] Dashboard layout with sidebar +- [ ] My Tools tab with tool list +- [ ] API Tokens tab with create/revoke +- [ ] Settings tab with profile edit +- [ ] Logout functionality + +### 7.7 Privacy & Consent +- [ ] Cookie consent banner +- [ ] Consent preferences modal +- [ ] Consent state storage +- [ ] Privacy policy page +- [ ] Terms of service page +- [ ] Honor consent in analytics/ad loading + +### 7.8 Ads & Monetization +- [ ] AdSense integration (account setup) +- [ ] Ad container components +- [ ] Lazy loading for ad scripts +- [ ] Ad placement rules enforcement +- [ ] Sponsored content styling (if applicable) +- [ ] Donate page with donation options + +### 7.9 SEO & Performance +- [ ] Meta tags for all page types +- [ ] Open Graph tags +- [ ] Schema.org structured data +- [ ] Sitemap generation +- [ ] robots.txt configuration +- [ ] Canonical URL implementation +- [ ] Image optimization pipeline +- [ ] CSS/JS minification +- [ ] Critical CSS inlining +- [ ] Lazy loading for images + +### 7.10 Testing & QA +- [ ] Responsive design testing (all breakpoints) +- [ ] Accessibility testing (WCAG 2.1 AA) +- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge) +- [ ] Performance testing (Lighthouse scores) +- [ ] Form validation testing +- [ ] Error state testing +- [ ] Mobile usability testing + +### 7.11 Launch Preparation +- [ ] Content creation (initial docs, tutorials) +- [ ] Seed featured tools selection +- [ ] Initial contributor spotlight +- [ ] Analytics setup (privacy-respecting) +- [ ] Error monitoring (Sentry or similar) +- [ ] SSL certificate configuration +- [ ] CDN setup (optional) +- [ ] Backup and recovery procedures + +## API Endpoints for Web UI + +The web UI consumes these existing API endpoints: + +**Public (read-only):** +- `GET /api/v1/tools` - List tools with pagination/filters +- `GET /api/v1/tools/search?q=...` - Search tools +- `GET /api/v1/tools/{owner}/{name}` - Tool details +- `GET /api/v1/tools/{owner}/{name}/versions` - Version list +- `GET /api/v1/categories` - Category list +- `GET /api/v1/stats/popular` - Popular tools + +**Authenticated (dashboard):** +- `POST /api/v1/login` - User login (returns session) +- `POST /api/v1/register` - User registration +- `GET /api/v1/me/tools` - User's published tools +- `GET /api/v1/tokens` - User's API tokens +- `POST /api/v1/tokens` - Create new token +- `DELETE /api/v1/tokens/{id}` - Revoke token +- `PUT /api/v1/me/settings` - Update profile + +**New endpoints for web UI:** +- `GET /api/v1/featured/tools` - Curated featured tools +- `GET /api/v1/featured/contributors` - Featured contributor +- `GET /api/v1/content/announcements` - Site announcements +- `POST /api/v1/reports` - Abuse report submission + +## Diagram References + +- Landing page mockup: `discussions/diagrams/smarttools-registry_rob_6.svg` +- System overview: `discussions/diagrams/smarttools-registry_rob_1.puml` +- Data flows: `discussions/diagrams/smarttools-registry_rob_5.puml` +- Web UI strategy: `discussions/diagrams/smarttools-web-ui-strategy.puml` +- UI visual strategy: `discussions/diagrams/smarttools-web-ui-visual-strategy.puml` + +## Deployment Guide + +### Requirements + +- Python 3.11+ +- pip/virtualenv +- SQLite 3 (included with Python) + +### Quick Start (Development) + +```bash +# Clone the repository +git clone https://gitea.brrd.tech/rob/SmartTools.git +cd SmartTools + +# Create virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install with registry extras +pip install -e ".[registry]" + +# Run the web server +python -m smarttools.web.app +``` + +The server will start on `http://localhost:5000`. + +### Production Deployment + +#### 1. Environment Variables + +```bash +# Required +export SMARTTOOLS_REGISTRY_DB=/path/to/registry.db +export PORT=5050 + +# Optional +export SMARTTOOLS_ENV=production # Enables secure cookies +export SMARTTOOLS_SHOW_ADS=true # Enable ad placeholders +``` + +#### 2. Database Location + +By default, the registry uses `~/.smarttools/registry.db`. For production: + +```bash +# Create dedicated directory +mkdir -p /var/lib/smarttools +export SMARTTOOLS_REGISTRY_DB=/var/lib/smarttools/registry.db +``` + +**Note**: If using a merged filesystem (e.g., mergerfs), store the database on a single disk or in `/tmp` to avoid SQLite WAL mode issues: + +```bash +export SMARTTOOLS_REGISTRY_DB=/tmp/smarttools-registry/registry.db +``` + +#### 3. Running with systemd + +Create `/etc/systemd/system/smarttools-registry.service`: + +```ini +[Unit] +Description=SmartTools Registry Web Server +After=network.target + +[Service] +Type=simple +User=smarttools +WorkingDirectory=/opt/smarttools +Environment=SMARTTOOLS_REGISTRY_DB=/var/lib/smarttools/registry.db +Environment=PORT=5050 +Environment=SMARTTOOLS_ENV=production +ExecStart=/opt/smarttools/venv/bin/python -m smarttools.web.app +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable smarttools-registry +sudo systemctl start smarttools-registry +``` + +#### 4. Reverse Proxy (nginx) + +```nginx +server { + listen 80; + server_name registry.smarttools.dev; + + location / { + proxy_pass http://127.0.0.1:5050; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /static { + alias /opt/smarttools/src/smarttools/web/static; + expires 1y; + add_header Cache-Control "public, immutable"; + } +} +``` + +#### 5. SSL with Certbot + +```bash +sudo certbot --nginx -d registry.smarttools.dev +``` + +### Tailwind CSS Build + +The CSS is pre-built and committed. To rebuild after changes: + +```bash +# Install dependencies +npm install + +# Build for production +npx tailwindcss -i src/smarttools/web/static/css/input.css \ + -o src/smarttools/web/static/css/main.css \ + --minify +``` + +### Health Check + +```bash +curl http://localhost:5050/api/v1/tools +# Returns: {"data":[],"meta":{"page":1,"per_page":20,"total":0,"total_pages":1}} +``` + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| `disk I/O error` | Move database to non-merged filesystem | +| Port already in use | Change PORT environment variable | +| 500 errors | Check `/tmp/smarttools.log` for stack traces | +| Static files not loading | Verify static folder path in deployment | + +## Future Considerations (Phase 8+) + +- **Forum integration**: External (Discourse) or built-in discussions +- **Newsletter signup**: Email collection with double opt-in +- **A/B testing**: Hero messaging, CTA variations +- **Analytics dashboard**: Traffic insights for publishers +- **Premium features**: Private registries, enhanced analytics +- **Internationalization**: Multi-language support +- **Dark mode**: Theme toggle with persistence +- **PWA features**: Offline support, install prompt diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3c29279 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1015 @@ +{ + "name": "smarttools-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smarttools-web", + "devDependencies": { + "tailwindcss": "^3.4.19" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a92064c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pyproject.toml b/pyproject.toml index 5efc203..bfc1011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ ] dependencies = [ "PyYAML>=6.0", + "requests>=2.28", ] [project.optional-dependencies] @@ -42,8 +43,14 @@ dev = [ "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] @@ -61,3 +68,6 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "integration: marks tests as integration tests (require running server)", +] diff --git a/scripts/sync_to_db.py b/scripts/sync_to_db.py new file mode 100644 index 0000000..a1262cd --- /dev/null +++ b/scripts/sync_to_db.py @@ -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()) diff --git a/scripts/validate_tool.py b/scripts/validate_tool.py new file mode 100644 index 0000000..983f0fa --- /dev/null +++ b/scripts/validate_tool.py @@ -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()) diff --git a/src/smarttools/cli.py b/src/smarttools/cli.py index 4bbfaec..66f6d88 100644 --- a/src/smarttools/cli.py +++ b/src/smarttools/cli.py @@ -8,6 +8,15 @@ from . import __version__ from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolArgument, PromptStep, CodeStep from .ui import run_ui from .providers import load_providers, add_provider, delete_provider, Provider, call_provider +from .config import load_config, save_config, set_registry_token +from .manifest import ( + load_manifest, save_manifest, create_manifest, find_manifest, + Manifest, Dependency, MANIFEST_FILENAME +) +from .resolver import ( + resolve_tool, find_tool, install_from_registry, uninstall_tool, + list_installed_tools, ToolNotFoundError, ToolSpec +) def cmd_list(args): @@ -617,6 +626,627 @@ def cmd_providers(args): return 0 +# ------------------------------------------------------------------------- +# Registry Commands +# ------------------------------------------------------------------------- + +def cmd_registry(args): + """Handle registry subcommands.""" + from .registry_client import ( + RegistryClient, RegistryError, RateLimitError, + get_client, search, install_tool as registry_install + ) + + if args.registry_cmd == "search": + try: + client = get_client() + results = client.search_tools( + query=args.query, + category=args.category, + per_page=args.limit or 20 + ) + + if not results.data: + print(f"No tools found matching '{args.query}'") + return 0 + + print(f"Found {results.total} tools:\n") + for tool in results.data: + owner = tool.get("owner", "") + name = tool.get("name", "") + version = tool.get("version", "") + desc = tool.get("description", "") + downloads = tool.get("downloads", 0) + + print(f" {owner}/{name} v{version}") + print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}") + print(f" Downloads: {downloads}") + print() + + if results.total_pages > 1: + print(f"Showing page {results.page}/{results.total_pages}") + + except RegistryError as e: + if e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print(f"Rate limited. Please wait and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error searching registry: {e}", file=sys.stderr) + print("If the problem persists, check: smarttools config show", file=sys.stderr) + return 1 + + return 0 + + elif args.registry_cmd == "install": + tool_spec = args.tool + version = args.version + + print(f"Installing {tool_spec}...") + + try: + resolved = install_from_registry(tool_spec, version) + print(f"Installed: {resolved.full_name}@{resolved.version}") + print(f"Location: {resolved.path}") + + # Show wrapper info + from .tool import BIN_DIR + wrapper_name = resolved.tool.name + if resolved.owner: + # Check for collision + short_wrapper = BIN_DIR / resolved.tool.name + if short_wrapper.exists(): + wrapper_name = f"{resolved.owner}-{resolved.tool.name}" + + print(f"\nRun with: {wrapper_name}") + + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) + print(f"Try: smarttools registry search {tool_spec.split('/')[-1]}", file=sys.stderr) + elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": + print(f"Error: {e.message}", file=sys.stderr) + if e.details and "available_versions" in e.details: + versions = e.details["available_versions"] + print(f"Available versions: {', '.join(versions[:5])}", file=sys.stderr) + if e.details.get("latest_stable"): + print(f"Latest stable: {e.details['latest_stable']}", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error installing tool: {e}", file=sys.stderr) + return 1 + + return 0 + + elif args.registry_cmd == "uninstall": + tool_spec = args.tool + + print(f"Uninstalling {tool_spec}...") + + if uninstall_tool(tool_spec): + print(f"Uninstalled: {tool_spec}") + else: + print(f"Tool '{tool_spec}' not found", file=sys.stderr) + return 1 + + return 0 + + elif args.registry_cmd == "info": + tool_spec = args.tool + + try: + # Parse the tool spec + parsed = ToolSpec.parse(tool_spec) + owner = parsed.owner or "official" + + client = get_client() + tool_info = client.get_tool(owner, parsed.name) + + print(f"{tool_info.owner}/{tool_info.name} v{tool_info.version}") + print("=" * 50) + print(f"Description: {tool_info.description}") + print(f"Category: {tool_info.category}") + print(f"Tags: {', '.join(tool_info.tags)}") + print(f"Downloads: {tool_info.downloads}") + print(f"Published: {tool_info.published_at}") + + if tool_info.deprecated: + print() + print(f"DEPRECATED: {tool_info.deprecated_message}") + if tool_info.replacement: + print(f"Use instead: {tool_info.replacement}") + + # Show versions + versions = client.get_tool_versions(owner, parsed.name) + if versions: + print(f"\nVersions: {', '.join(versions[:5])}") + if len(versions) > 5: + print(f" ...and {len(versions) - 5} more") + + print(f"\nInstall: smarttools registry install {tool_info.owner}/{tool_info.name}") + + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) + print(f"Try: smarttools registry search {parsed.name}", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error fetching tool info: {e}", file=sys.stderr) + return 1 + + return 0 + + elif args.registry_cmd == "update": + print("Updating registry index...") + + try: + client = get_client() + index = client.get_index(force_refresh=True) + + tool_count = index.get("tool_count", len(index.get("tools", []))) + generated = index.get("generated_at", "unknown") + + print(f"Index updated: {tool_count} tools") + print(f"Generated: {generated}") + + except RegistryError as e: + if e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print("Rate limited. Please wait a moment and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error updating index: {e}", file=sys.stderr) + return 1 + + return 0 + + elif args.registry_cmd == "publish": + # Read tool from current directory or specified path + tool_path = Path(args.path) if args.path else Path.cwd() + + if tool_path.is_dir(): + config_path = tool_path / "config.yaml" + else: + config_path = tool_path + tool_path = config_path.parent + + if not config_path.exists(): + print(f"Error: config.yaml not found in {tool_path}", file=sys.stderr) + return 1 + + # Read config + import yaml + config_yaml = config_path.read_text() + + # Read README if exists + readme_path = tool_path / "README.md" + readme = readme_path.read_text() if readme_path.exists() else "" + + # Validate + try: + data = yaml.safe_load(config_yaml) + name = data.get("name", "") + version = data.get("version", "") + if not name or not version: + print("Error: config.yaml must have 'name' and 'version' fields", file=sys.stderr) + return 1 + except yaml.YAMLError as e: + print(f"Error: Invalid YAML in config.yaml: {e}", file=sys.stderr) + return 1 + + if args.dry_run: + print("Dry run - validating only") + print() + print(f"Would publish:") + print(f" Name: {name}") + print(f" Version: {version}") + print(f" Config: {len(config_yaml)} bytes") + print(f" README: {len(readme)} bytes") + return 0 + + # Check for token + config = load_config() + if not config.registry.token: + print("No registry token configured.") + print() + print("1. Register at: https://gitea.brrd.tech/registry/register") + print("2. Generate a token from your dashboard") + print("3. Enter your token below") + print() + + try: + token = input("Registry token: ").strip() + if not token: + print("Cancelled.") + return 1 + set_registry_token(token) + print("Token saved.") + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + return 1 + + print(f"Publishing {name}@{version}...") + + try: + client = get_client() + result = client.publish_tool(config_yaml, readme) + + pr_url = result.get("pr_url", "") + status = result.get("status", "") + + if status == "published" or result.get("version"): + print(f"Published: {result.get('owner', '')}/{result.get('name', '')}@{result.get('version', version)}") + elif pr_url: + print(f"PR created: {pr_url}") + print("Your tool is pending review.") + else: + print("Published successfully!") + + # Show suggestions if provided (from Phase 6 smart features) + suggestions = result.get("suggestions", {}) + if suggestions: + print() + + # Category suggestion + cat_suggestion = suggestions.get("category") + if cat_suggestion and cat_suggestion.get("suggested"): + confidence = cat_suggestion.get("confidence", 0) + print(f"Suggested category: {cat_suggestion['suggested']} ({confidence:.0%} confidence)") + + # Similar tools warning + similar = suggestions.get("similar_tools", []) + if similar: + print("Similar existing tools:") + for tool in similar[:3]: + similarity = tool.get("similarity", 0) + print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)") + + except RegistryError as e: + if e.code == "UNAUTHORIZED": + print("Authentication failed.", file=sys.stderr) + print("Your token may have expired. Generate a new one from the registry.", file=sys.stderr) + elif e.code == "INVALID_CONFIG": + print(f"Invalid tool config: {e.message}", file=sys.stderr) + print("Check your config.yaml for errors.", file=sys.stderr) + elif e.code == "VERSION_EXISTS": + print(f"Version already exists: {e.message}", file=sys.stderr) + print("Bump the version in config.yaml and try again.", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print("Rate limited. Please wait a moment and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error publishing: {e}", file=sys.stderr) + return 1 + + return 0 + + elif args.registry_cmd == "my-tools": + try: + client = get_client() + tools = client.get_my_tools() + + if not tools: + print("You haven't published any tools yet.") + print("Publish your first tool with: smarttools registry publish") + return 0 + + print(f"Your published tools ({len(tools)}):\n") + for tool in tools: + status = "[DEPRECATED]" if tool.deprecated else "" + print(f" {tool.owner}/{tool.name} v{tool.version} {status}") + print(f" Downloads: {tool.downloads}") + print() + + except RegistryError as e: + if e.code == "UNAUTHORIZED": + print("Not logged in. Set your registry token first:", file=sys.stderr) + print(" smarttools config set-token ", file=sys.stderr) + print() + print("Don't have a token? Register at the registry website.", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print("Could not connect to the registry.", file=sys.stderr) + print("Check your internet connection or try again later.", file=sys.stderr) + elif e.code == "RATE_LIMITED": + print("Rate limited. Please wait a moment and try again.", file=sys.stderr) + else: + print(f"Error: {e.message}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + return 0 + + elif args.registry_cmd == "browse": + # Launch TUI browser + try: + from .ui_registry import run_registry_browser + return run_registry_browser() + except ImportError: + print("TUI browser requires urwid. Install with:", file=sys.stderr) + print(" pip install 'smarttools[tui]'", file=sys.stderr) + print() + print("Or search from command line:", file=sys.stderr) + print(" smarttools registry search ", file=sys.stderr) + return 1 + + else: + # Default: show registry help + print("Registry commands:") + print(" search Search for tools") + print(" install Install a tool") + print(" uninstall Uninstall a tool") + print(" info Show tool information") + print(" update Update local index cache") + print(" publish [path] Publish a tool") + print(" my-tools List your published tools") + print(" browse Browse tools (TUI)") + return 0 + + +# ------------------------------------------------------------------------- +# Project Commands +# ------------------------------------------------------------------------- + +def cmd_deps(args): + """Show project dependencies from smarttools.yaml.""" + manifest = load_manifest() + + if manifest is None: + print("No smarttools.yaml found in current project.") + print("Create one with: smarttools init") + return 1 + + print(f"Project: {manifest.name} v{manifest.version}") + print() + + if not manifest.dependencies: + print("No dependencies defined.") + print("Add one with: smarttools add ") + return 0 + + print(f"Dependencies ({len(manifest.dependencies)}):") + print() + + for dep in manifest.dependencies: + # Check if installed + installed = find_tool(dep.name) + status = "[installed]" if installed else "[not installed]" + + print(f" {dep.name}") + print(f" Version: {dep.version}") + print(f" Status: {status}") + print() + + if manifest.overrides: + print("Overrides:") + for name, override in manifest.overrides.items(): + if override.provider: + print(f" {name}: provider={override.provider}") + + return 0 + + +def cmd_install_deps(args): + """Install dependencies from smarttools.yaml.""" + from .registry_client import get_client, RegistryError + + manifest = load_manifest() + + if manifest is None: + print("No smarttools.yaml found in current project.") + print("Create one with: smarttools init") + return 1 + + if not manifest.dependencies: + print("No dependencies to install.") + return 0 + + print(f"Installing dependencies for {manifest.name}...") + print() + + failed = [] + installed = [] + + for i, dep in enumerate(manifest.dependencies, 1): + print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}") + + # Check if already installed + existing = find_tool(dep.name) + if existing: + print(f" Already installed: {existing.full_name}") + installed.append(dep.name) + continue + + try: + print(f" Downloading...") + resolved = install_from_registry(dep.name, dep.version) + print(f" Installed: {resolved.full_name}@{resolved.version}") + installed.append(dep.name) + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f" Not found in registry") + elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": + print(f" Version {dep.version} not available") + elif e.code == "CONNECTION_ERROR": + print(f" Connection failed (check network)") + else: + print(f" Failed: {e.message}") + failed.append(dep.name) + except Exception as e: + print(f" Failed: {e}") + failed.append(dep.name) + + print() + print(f"Installed {len(installed)} tools") + + if failed: + print(f"Failed: {', '.join(failed)}") + return 1 + + return 0 + + +def cmd_add(args): + """Add a tool to project dependencies.""" + tool_spec = args.tool + version = args.version or "*" + + # Find or create manifest + manifest_path = find_manifest() + if manifest_path: + manifest = load_manifest(manifest_path) + else: + # Create in current directory + manifest = create_manifest(name=Path.cwd().name) + manifest_path = Path.cwd() / MANIFEST_FILENAME + + # Parse tool spec + parsed = ToolSpec.parse(tool_spec) + full_name = parsed.full_name + + # Add dependency + manifest.add_dependency(full_name, version) + + # Save + save_manifest(manifest, manifest_path) + print(f"Added {full_name}@{version} to {manifest_path.name}") + + # Install if requested + if not args.no_install: + from .registry_client import RegistryError + print(f"Installing {full_name}...") + try: + resolved = install_from_registry(tool_spec, version if version != "*" else None) + print(f"Installed: {resolved.full_name}@{resolved.version}") + except RegistryError as e: + if e.code == "TOOL_NOT_FOUND": + print(f"Tool not found in registry.", file=sys.stderr) + print(f"It's been added to your dependencies - you can install it manually later.", file=sys.stderr) + elif e.code == "CONNECTION_ERROR": + print(f"Could not connect to registry.", file=sys.stderr) + print("Run 'smarttools install' to try again later.", file=sys.stderr) + else: + print(f"Install failed: {e.message}", file=sys.stderr) + print("Run 'smarttools install' to try again.", file=sys.stderr) + except Exception as e: + print(f"Install failed: {e}", file=sys.stderr) + print("Run 'smarttools install' to try again.", file=sys.stderr) + + return 0 + + +def cmd_init(args): + """Initialize a new smarttools.yaml.""" + manifest_path = Path.cwd() / MANIFEST_FILENAME + + if manifest_path.exists() and not args.force: + print(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.") + return 1 + + # Get project name + default_name = Path.cwd().name + if args.name: + name = args.name + else: + try: + name = input(f"Project name [{default_name}]: ").strip() or default_name + except (EOFError, KeyboardInterrupt): + print() + name = default_name + + # Get version + if args.version: + version = args.version + else: + try: + version = input("Version [1.0.0]: ").strip() or "1.0.0" + except (EOFError, KeyboardInterrupt): + print() + version = "1.0.0" + + # Create manifest + manifest = create_manifest(name=name, version=version) + save_manifest(manifest, manifest_path) + + print(f"Created {MANIFEST_FILENAME}") + print() + print("Add dependencies with: smarttools add ") + print("Install them with: smarttools install") + + return 0 + + +def cmd_config(args): + """Manage SmartTools configuration.""" + if args.config_cmd == "show": + config = load_config() + print("SmartTools Configuration:") + print(f" Registry URL: {config.registry.url}") + print(f" Token: {'***' if config.registry.token else '(not set)'}") + print(f" Client ID: {config.client_id}") + print(f" Auto-fetch: {config.auto_fetch_from_registry}") + if config.default_provider: + print(f" Default provider: {config.default_provider}") + return 0 + + elif args.config_cmd == "set-token": + token = args.token + set_registry_token(token) + print("Registry token saved.") + return 0 + + elif args.config_cmd == "set": + config = load_config() + key = args.key + value = args.value + + if key == "auto_fetch": + config.auto_fetch_from_registry = value.lower() in ("true", "1", "yes") + elif key == "default_provider": + config.default_provider = value if value else None + elif key == "registry_url": + config.registry.url = value + else: + print(f"Unknown config key: {key}", file=sys.stderr) + print("Available keys: auto_fetch, default_provider, registry_url") + return 1 + + save_config(config) + print(f"Set {key} = {value}") + return 0 + + else: + print("Config commands:") + print(" show Show current configuration") + print(" set-token Set registry authentication token") + print(" set Set a configuration value") + return 0 + + def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser( @@ -722,6 +1352,106 @@ def main(): # Default for providers with no subcommand p_providers.set_defaults(func=lambda args: cmd_providers(args) if args.providers_cmd else (setattr(args, 'providers_cmd', 'list') or cmd_providers(args))) + # ------------------------------------------------------------------------- + # Registry Commands + # ------------------------------------------------------------------------- + p_registry = subparsers.add_parser("registry", help="Registry commands (search, install, publish)") + registry_sub = p_registry.add_subparsers(dest="registry_cmd", help="Registry commands") + + # registry search + p_reg_search = registry_sub.add_parser("search", help="Search for tools") + p_reg_search.add_argument("query", help="Search query") + p_reg_search.add_argument("-c", "--category", help="Filter by category") + p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)") + p_reg_search.set_defaults(func=cmd_registry) + + # registry install + p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry") + p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)") + p_reg_install.add_argument("-v", "--version", help="Version constraint") + p_reg_install.set_defaults(func=cmd_registry) + + # registry uninstall + p_reg_uninstall = registry_sub.add_parser("uninstall", help="Uninstall a tool") + p_reg_uninstall.add_argument("tool", help="Tool to uninstall (owner/name)") + p_reg_uninstall.set_defaults(func=cmd_registry) + + # registry info + p_reg_info = registry_sub.add_parser("info", help="Show tool information") + p_reg_info.add_argument("tool", help="Tool name (owner/name)") + p_reg_info.set_defaults(func=cmd_registry) + + # registry update + p_reg_update = registry_sub.add_parser("update", help="Update local index cache") + p_reg_update.set_defaults(func=cmd_registry) + + # registry publish + p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry") + p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)") + p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing") + p_reg_publish.set_defaults(func=cmd_registry) + + # registry my-tools + p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools") + p_reg_mytools.set_defaults(func=cmd_registry) + + # registry browse + p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)") + p_reg_browse.set_defaults(func=cmd_registry) + + # Default for registry with no subcommand + p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args))) + + # ------------------------------------------------------------------------- + # Project Commands + # ------------------------------------------------------------------------- + + # 'deps' command + p_deps = subparsers.add_parser("deps", help="Show project dependencies") + p_deps.set_defaults(func=cmd_deps) + + # 'install' command (for dependencies) + p_install = subparsers.add_parser("install", help="Install dependencies from smarttools.yaml") + p_install.set_defaults(func=cmd_install_deps) + + # 'add' command + p_add = subparsers.add_parser("add", help="Add a tool to project dependencies") + p_add.add_argument("tool", help="Tool to add (owner/name)") + p_add.add_argument("-v", "--version", help="Version constraint (default: *)") + p_add.add_argument("--no-install", action="store_true", help="Don't install after adding") + p_add.set_defaults(func=cmd_add) + + # 'init' command + p_init = subparsers.add_parser("init", help="Initialize smarttools.yaml") + p_init.add_argument("-n", "--name", help="Project name") + p_init.add_argument("-v", "--version", help="Project version") + p_init.add_argument("-f", "--force", action="store_true", help="Overwrite existing") + p_init.set_defaults(func=cmd_init) + + # ------------------------------------------------------------------------- + # Config Commands + # ------------------------------------------------------------------------- + p_config = subparsers.add_parser("config", help="Manage configuration") + config_sub = p_config.add_subparsers(dest="config_cmd", help="Config commands") + + # config show + p_cfg_show = config_sub.add_parser("show", help="Show current configuration") + p_cfg_show.set_defaults(func=cmd_config) + + # config set-token + p_cfg_token = config_sub.add_parser("set-token", help="Set registry authentication token") + p_cfg_token.add_argument("token", help="Registry token") + p_cfg_token.set_defaults(func=cmd_config) + + # config set + p_cfg_set = config_sub.add_parser("set", help="Set a configuration value") + p_cfg_set.add_argument("key", help="Config key") + p_cfg_set.add_argument("value", help="Config value") + p_cfg_set.set_defaults(func=cmd_config) + + # Default for config with no subcommand + p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args))) + args = parser.parse_args() # If no command, launch UI diff --git a/src/smarttools/config.py b/src/smarttools/config.py new file mode 100644 index 0000000..a85c7a0 --- /dev/null +++ b/src/smarttools/config.py @@ -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 diff --git a/src/smarttools/manifest.py b/src/smarttools/manifest.py new file mode 100644 index 0000000..f996029 --- /dev/null +++ b/src/smarttools/manifest.py @@ -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} diff --git a/src/smarttools/registry/__init__.py b/src/smarttools/registry/__init__.py new file mode 100644 index 0000000..c2a2eb9 --- /dev/null +++ b/src/smarttools/registry/__init__.py @@ -0,0 +1,3 @@ +"""Registry API server package.""" + +__all__ = ["app"] diff --git a/src/smarttools/registry/app.py b/src/smarttools/registry/app.py new file mode 100644 index 0000000..3fccee0 --- /dev/null +++ b/src/smarttools/registry/app.py @@ -0,0 +1,1423 @@ +"""Flask app for SmartTools Registry API (Phase 2).""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import secrets +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from flask import Flask, Response, g, jsonify, request +import yaml +from functools import wraps +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +from .db import connect_db, init_db, query_all, query_one +from .rate_limit import RateLimiter +from .sync import process_webhook, get_categories_cache_path, get_repo_dir + +MAX_BODY_BYTES = 512 * 1024 +MAX_CONFIG_BYTES = 64 * 1024 +MAX_README_BYTES = 256 * 1024 +MAX_TOOL_NAME_LEN = 64 +MAX_DESC_LEN = 500 +MAX_TAG_LEN = 32 +MAX_TAGS = 10 +MAX_PAGE_SIZE = 100 +DEFAULT_PAGE_SIZE = 20 + +RATE_LIMITS = { + "tools": {"limit": 100, "window": 60}, + "download": {"limit": 60, "window": 60}, + "register": {"limit": 5, "window": 3600}, + "login": {"limit": 10, "window": 900}, + "login_failed": {"limit": 5, "window": 900}, + "tokens": {"limit": 10, "window": 3600}, + "publish": {"limit": 20, "window": 3600}, +} + +ALLOWED_SORT = { + "/tools": {"downloads", "published_at", "name"}, + "/tools/search": {"relevance", "downloads", "published_at"}, + "/categories": {"name", "tool_count"}, +} + +TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$") +OWNER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +RESERVED_SLUGS = {"official", "admin", "system", "api", "registry", "smarttools"} + +rate_limiter = RateLimiter() +password_hasher = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4) + + +@dataclass(frozen=True) +class Semver: + major: int + minor: int + patch: int + prerelease: Tuple[Any, ...] = () + + @classmethod + def parse(cls, value: str) -> Optional["Semver"]: + match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.+)?$", value) + if not match: + return None + major, minor, patch = map(int, match.group(1, 2, 3)) + prerelease_raw = match.group(4) + if not prerelease_raw: + return cls(major, minor, patch, ()) + parts: List[Any] = [] + for part in prerelease_raw.split("."): + if part.isdigit(): + parts.append(int(part)) + else: + parts.append(part) + return cls(major, minor, patch, tuple(parts)) + + def is_prerelease(self) -> bool: + return bool(self.prerelease) + + def __lt__(self, other: "Semver") -> bool: + if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch): + return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch) + if not self.prerelease and other.prerelease: + return False + if self.prerelease and not other.prerelease: + return True + return self.prerelease < other.prerelease + + +@dataclass(frozen=True) +class Constraint: + op: str + version: Semver + + +def parse_constraints(raw: str) -> Tuple[List[Constraint], bool]: + raw = raw.strip() + if not raw or raw == "*": + return [], False + allow_prerelease = "-" in raw + parts = [part.strip() for part in raw.split(",") if part.strip()] + constraints: List[Constraint] = [] + for part in parts: + if part.startswith("^"): + base = Semver.parse(part[1:]) + if not base: + continue + constraints.append(Constraint(">=", base)) + if base.major > 0: + upper = Semver(base.major + 1, 0, 0, ()) + elif base.minor > 0: + upper = Semver(base.major, base.minor + 1, 0, ()) + else: + upper = Semver(base.major, base.minor, base.patch + 1, ()) + constraints.append(Constraint("<", upper)) + allow_prerelease = allow_prerelease or base.is_prerelease() + continue + if part.startswith("~"): + base = Semver.parse(part[1:]) + if not base: + continue + constraints.append(Constraint(">=", base)) + upper = Semver(base.major, base.minor + 1, 0, ()) + constraints.append(Constraint("<", upper)) + allow_prerelease = allow_prerelease or base.is_prerelease() + continue + match = re.match(r"^(>=|<=|>|<|=)?\s*(.+)$", part) + if not match: + continue + op = match.group(1) or "=" + version = Semver.parse(match.group(2)) + if not version: + continue + constraints.append(Constraint(op, version)) + allow_prerelease = allow_prerelease or version.is_prerelease() + return constraints, allow_prerelease + + +def satisfies(version: Semver, constraints: List[Constraint]) -> bool: + for constraint in constraints: + if constraint.op == ">" and not (version > constraint.version): + return False + if constraint.op == ">=" and not (version >= constraint.version): + return False + if constraint.op == "<" and not (version < constraint.version): + return False + if constraint.op == "<=" and not (version <= constraint.version): + return False + if constraint.op in {"=", "=="} and not (version == constraint.version): + return False + return True + + +def select_version(versions: List[str], constraint_raw: Optional[str]) -> Optional[str]: + parsed_versions: List[Tuple[Semver, str]] = [] + for version in versions: + parsed = Semver.parse(version) + if parsed: + parsed_versions.append((parsed, version)) + if not parsed_versions: + return None + + if not constraint_raw or constraint_raw.strip() == "*": + candidates = [item for item in parsed_versions if not item[0].is_prerelease()] + if not candidates: + candidates = parsed_versions + return max(candidates, key=lambda item: item[0])[1] + + constraints, allow_prerelease = parse_constraints(constraint_raw) + filtered = [] + for parsed, raw in parsed_versions: + if not allow_prerelease and parsed.is_prerelease(): + continue + if satisfies(parsed, constraints): + filtered.append((parsed, raw)) + if not filtered: + return None + return max(filtered, key=lambda item: item[0])[1] + + +def create_app() -> Flask: + app = Flask(__name__) + app.config["MAX_CONTENT_LENGTH"] = MAX_BODY_BYTES + + # Initialize database schema once at startup + with connect_db() as init_conn: + init_db(init_conn) + + @app.before_request + def attach_db() -> None: + g.db = connect_db() + + @app.teardown_request + def close_db(exc: Optional[BaseException]) -> None: + db = getattr(g, "db", None) + if db is not None: + db.close() + + @app.before_request + def enforce_rate_limit() -> Optional[Response]: + path = request.path + method = request.method.upper() + ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown") + if method == "GET": + if path.startswith("/api/v1/tools/") and path.endswith("/download"): + limit_config = RATE_LIMITS["download"] + elif path.startswith("/api/v1/tools"): + limit_config = RATE_LIMITS["tools"] + else: + return None + elif method == "POST": + if path == "/api/v1/register": + limit_config = RATE_LIMITS["register"] + elif path == "/api/v1/login": + limit_config = RATE_LIMITS["login"] + else: + return None + else: + return None + + allowed, state = rate_limiter.check(ip, limit_config["limit"], limit_config["window"]) + remaining = max(0, limit_config["limit"] - state.count) + reset_at = int(state.reset_at) + if not allowed: + payload = { + "error": { + "code": "RATE_LIMITED", + "message": f"Too many requests. Try again in {limit_config['window']} seconds.", + "details": { + "limit": limit_config["limit"], + "window": f"{limit_config['window']} seconds", + "retry_after": limit_config["window"], + }, + } + } + response = jsonify(payload) + response.status_code = 429 + response.headers["Retry-After"] = str(limit_config["window"]) + response.headers["X-RateLimit-Limit"] = str(limit_config["limit"]) + response.headers["X-RateLimit-Remaining"] = "0" + response.headers["X-RateLimit-Reset"] = str(reset_at) + return response + + request.rate_limit_headers = { + "X-RateLimit-Limit": str(limit_config["limit"]), + "X-RateLimit-Remaining": str(remaining), + "X-RateLimit-Reset": str(reset_at), + } + return None + + @app.after_request + def add_rate_limit_headers(response: Response) -> Response: + headers = getattr(request, "rate_limit_headers", None) + if headers: + response.headers.update(headers) + return response + + def error_response(code: str, message: str, status: int = 400, details: Optional[dict] = None) -> Response: + payload = {"error": {"code": code, "message": message, "details": details or {}}} + response = jsonify(payload) + response.status_code = status + return response + + def enforce_token_rate_limit(scope: str, token_hash: str) -> Optional[Response]: + limit_config = RATE_LIMITS[scope] + allowed, state = rate_limiter.check(token_hash, limit_config["limit"], limit_config["window"]) + remaining = max(0, limit_config["limit"] - state.count) + reset_at = int(state.reset_at) + if not allowed: + payload = { + "error": { + "code": "RATE_LIMITED", + "message": f"Too many requests. Try again in {limit_config['window']} seconds.", + "details": { + "limit": limit_config["limit"], + "window": f"{limit_config['window']} seconds", + "retry_after": limit_config["window"], + }, + } + } + response = jsonify(payload) + response.status_code = 429 + response.headers["Retry-After"] = str(limit_config["window"]) + response.headers["X-RateLimit-Limit"] = str(limit_config["limit"]) + response.headers["X-RateLimit-Remaining"] = "0" + response.headers["X-RateLimit-Reset"] = str(reset_at) + return response + + request.rate_limit_headers = { + "X-RateLimit-Limit": str(limit_config["limit"]), + "X-RateLimit-Remaining": str(remaining), + "X-RateLimit-Reset": str(reset_at), + } + return None + + def require_token(f): + @wraps(f) + def decorated(*args, **kwargs): + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return error_response("UNAUTHORIZED", "Missing or invalid token", 401) + token = auth_header[7:] + token_hash = hashlib.sha256(token.encode()).hexdigest() + row = query_one( + g.db, + """ + SELECT t.*, p.slug, p.display_name + FROM api_tokens t + JOIN publishers p ON t.publisher_id = p.id + WHERE t.token_hash = ? AND t.revoked_at IS NULL + """, + [token_hash], + ) + if not row: + return error_response("UNAUTHORIZED", "Invalid or revoked token", 401) + + g.db.execute( + "UPDATE api_tokens SET last_used_at = ? WHERE id = ?", + [datetime.utcnow().isoformat(), row["id"]], + ) + g.current_publisher = { + "id": row["publisher_id"], + "slug": row["slug"], + "display_name": row["display_name"], + } + g.current_token = {"id": row["id"], "hash": token_hash} + g.db.commit() + return f(*args, **kwargs) + + return decorated + + def generate_token() -> Tuple[str, str]: + alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + raw = secrets.token_bytes(32) + num = int.from_bytes(raw, "big") + chars = [] + while num > 0: + num, rem = divmod(num, 62) + chars.append(alphabet[rem]) + token_body = "".join(reversed(chars)).rjust(43, "0") + token = "reg_" + token_body[:43] + token_hash = hashlib.sha256(token.encode()).hexdigest() + return token, token_hash + + def validate_payload_size(field: str, content: str, limit: int) -> Optional[Response]: + size = len(content.encode("utf-8")) + if size > limit: + return error_response( + "PAYLOAD_TOO_LARGE", + f"{field} exceeds {limit} bytes limit", + 413, + details={"field": field, "size": size, "limit": limit}, + ) + return None + + def paginate(page: int, per_page: int, total: int) -> Dict[str, int]: + total_pages = max(1, math.ceil(total / per_page)) if per_page else 1 + return { + "page": page, + "per_page": per_page, + "total": total, + "total_pages": total_pages, + } + + def parse_pagination(endpoint_key: str, default_sort: str) -> Tuple[int, int, str, str, Optional[Response]]: + try: + page = int(request.args.get("page", 1)) + except ValueError: + return 1, DEFAULT_PAGE_SIZE, "downloads", "desc", error_response("VALIDATION_ERROR", "Invalid page") + per_page_raw = request.args.get("per_page") + if per_page_raw is None and request.args.get("limit") is not None: + per_page_raw = request.args.get("limit") + try: + per_page = int(per_page_raw) if per_page_raw is not None else DEFAULT_PAGE_SIZE + except ValueError: + return 1, DEFAULT_PAGE_SIZE, "downloads", "desc", error_response("VALIDATION_ERROR", "Invalid per_page") + if page < 1: + return 1, DEFAULT_PAGE_SIZE, "downloads", "desc", error_response("VALIDATION_ERROR", "Page must be >= 1") + if per_page < 1 or per_page > MAX_PAGE_SIZE: + return 1, DEFAULT_PAGE_SIZE, "downloads", "desc", error_response("VALIDATION_ERROR", "per_page out of range") + sort = request.args.get("sort", default_sort) + order = request.args.get("order", "desc").lower() + if order not in {"asc", "desc"}: + return 1, DEFAULT_PAGE_SIZE, "downloads", "desc", error_response("INVALID_SORT", "Invalid sort order") + allowed = ALLOWED_SORT.get(endpoint_key, set()) + if sort not in allowed: + return 1, DEFAULT_PAGE_SIZE, "downloads", "desc", error_response( + "INVALID_SORT", + f"Unknown sort field '{sort}'. Allowed: {', '.join(sorted(allowed))}", + ) + return page, per_page, sort, order, None + + def load_tool_row(owner: str, name: str, version: Optional[str] = None) -> Optional[dict]: + sql = "SELECT * FROM tools WHERE owner = ? AND name = ?" + params: List[Any] = [owner, name] + if version: + sql += " AND version = ?" + params.append(version) + sql += " ORDER BY id DESC LIMIT 1" + row = query_one(g.db, sql, params) + return dict(row) if row else None + + @app.route("/api/v1/tools", methods=["GET"]) + def list_tools() -> Response: + page, per_page, sort, order, error = parse_pagination("/tools", "downloads") + if error: + return error + category = request.args.get("category") + offset = (page - 1) * per_page + + base_where = "WHERE 1=1" + params: List[Any] = [] + if category: + base_where += " AND category = ?" + params.append(category) + + count_row = query_one( + g.db, + f"SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM tools {base_where}", + params, + ) + total = int(count_row["total"]) if count_row else 0 + + order_dir = "DESC" if order == "desc" else "ASC" + order_sql = f"{sort} {order_dir}, published_at DESC, id DESC" + + rows = query_all( + g.db, + f""" + WITH latest_any AS ( + SELECT owner, name, MAX(id) AS max_id + FROM tools + {base_where} + GROUP BY owner, name + ), + latest_stable AS ( + SELECT owner, name, MAX(id) AS max_id + FROM tools + {base_where} AND version NOT LIKE '%-%' + GROUP BY owner, name + ) + SELECT t.* FROM tools t + JOIN ( + SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id + FROM latest_any a + LEFT JOIN latest_stable s ON s.owner = a.owner AND s.name = a.name + ) latest + ON t.owner = latest.owner AND t.name = latest.name AND t.id = latest.max_id + ORDER BY {order_sql} + LIMIT ? OFFSET ? + """, + params + [per_page, offset], + ) + + data = [] + for row in rows: + data.append({ + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "category": row["category"], + "tags": json.loads(row["tags"] or "[]"), + "downloads": row["downloads"], + "published_at": row["published_at"], + }) + + return jsonify({"data": data, "meta": paginate(page, per_page, total)}) + + @app.route("/api/v1/tools/search", methods=["GET"]) + def search_tools() -> Response: + query_text = request.args.get("q", "").strip() + if not query_text: + return error_response("VALIDATION_ERROR", "Missing search query") + page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads") + if error: + return error + category = request.args.get("category") + offset = (page - 1) * per_page + + where_clause = "WHERE tools_fts MATCH ?" + params: List[Any] = [query_text] + if category: + where_clause += " AND tools.category = ?" + params.append(category) + + order_dir = "DESC" if order == "desc" else "ASC" + if sort == "relevance": + order_sql = f"rank {order_dir}, downloads DESC, published_at DESC, id DESC" + else: + order_sql = f"{sort} {order_dir}, published_at DESC, id DESC" + + rows = query_all( + g.db, + f""" + WITH matches AS ( + SELECT tools.*, bm25(tools_fts) AS rank + FROM tools_fts + JOIN tools ON tools_fts.rowid = tools.id + {where_clause} + ), + latest_any AS ( + SELECT owner, name, MAX(id) AS max_id + FROM matches + GROUP BY owner, name + ), + latest_stable AS ( + SELECT owner, name, MAX(id) AS max_id + FROM matches + WHERE version NOT LIKE '%-%' + GROUP BY owner, name + ) + SELECT m.* FROM matches m + JOIN ( + SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id + FROM latest_any a + LEFT JOIN latest_stable s ON s.owner = a.owner AND s.name = a.name + ) latest + ON m.owner = latest.owner AND m.name = latest.name AND m.id = latest.max_id + ORDER BY {order_sql} + LIMIT ? OFFSET ? + """, + params + [per_page, offset], + ) + + count_row = query_one( + g.db, + f""" + WITH matches AS ( + SELECT tools.* + FROM tools_fts + JOIN tools ON tools_fts.rowid = tools.id + {where_clause} + ) + SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM matches + """, + params, + ) + total = int(count_row["total"]) if count_row else 0 + + data = [] + for row in rows: + score = 1.0 / (1.0 + row["rank"]) if row["rank"] is not None else None + data.append({ + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "category": row["category"], + "tags": json.loads(row["tags"] or "[]"), + "downloads": row["downloads"], + "published_at": row["published_at"], + "score": score, + }) + + return jsonify({"data": data, "meta": paginate(page, per_page, total)}) + + @app.route("/api/v1/tools//", methods=["GET"]) + def get_tool(owner: str, name: str) -> Response: + if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name): + return error_response("VALIDATION_ERROR", "Invalid owner or tool name") + version = request.args.get("version") + if version: + row = load_tool_row(owner, name, version) + else: + row = resolve_tool(owner, name, "*") + if not row: + return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404) + + payload = { + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "category": row["category"], + "tags": json.loads(row["tags"] or "[]"), + "downloads": row["downloads"], + "published_at": row["published_at"], + "deprecated": bool(row["deprecated"]), + "deprecated_message": row["deprecated_message"], + "replacement": row["replacement"], + "config": row["config_yaml"], + "readme": row["readme"], + } + response = jsonify({"data": payload}) + response.headers["Cache-Control"] = "max-age=60" + return response + + def resolve_tool(owner: str, name: str, constraint: Optional[str]) -> Optional[dict]: + rows = query_all(g.db, "SELECT * FROM tools WHERE owner = ? AND name = ?", [owner, name]) + if not rows: + return None + versions = [row["version"] for row in rows] + selected = select_version(versions, constraint) + if not selected: + return None + for row in rows: + if row["version"] == selected: + return dict(row) + return None + + @app.route("/api/v1/tools///versions", methods=["GET"]) + def list_tool_versions(owner: str, name: str) -> Response: + if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name): + return error_response("VALIDATION_ERROR", "Invalid owner or tool name") + rows = query_all(g.db, "SELECT version FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC", [owner, name]) + if not rows: + return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404) + versions = [row["version"] for row in rows] + return jsonify({"data": {"versions": versions}}) + + @app.route("/api/v1/tools///download", methods=["GET"]) + def download_tool(owner: str, name: str) -> Response: + if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name): + return error_response("VALIDATION_ERROR", "Invalid owner or tool name") + constraint = request.args.get("version") + install_flag = request.args.get("install", "false").lower() == "true" + row = resolve_tool(owner, name, constraint) + if not row: + available = [r["version"] for r in query_all(g.db, "SELECT version FROM tools WHERE owner = ? AND name = ?", [owner, name])] + return error_response( + "VERSION_NOT_FOUND", + f"No version of '{owner}/{name}' satisfies constraint '{constraint or '*'}'", + 404, + details={ + "tool": f"{owner}/{name}", + "constraint": constraint or "*", + "available_versions": available, + "latest_stable": select_version(available, "*") if available else None, + }, + ) + + if install_flag: + client_id = request.headers.get("X-Client-ID") + if not client_id: + client_id = f"anon_{hash(request.remote_addr)}" + today = date.today().isoformat() + try: + g.db.execute( + "INSERT INTO download_stats (tool_id, client_id, downloaded_at) VALUES (?, ?, ?)", + [row["id"], client_id, today], + ) + g.db.execute("UPDATE tools SET downloads = downloads + 1 WHERE id = ?", [row["id"]]) + g.db.commit() + except Exception: + g.db.rollback() + + response = jsonify({ + "data": { + "owner": row["owner"], + "name": row["name"], + "resolved_version": row["version"], + "config": row["config_yaml"], + "readme": row["readme"] or "", + } + }) + response.headers["Cache-Control"] = "max-age=3600, immutable" + return response + + @app.route("/api/v1/categories", methods=["GET"]) + def list_categories() -> Response: + page, per_page, sort, order, error = parse_pagination("/categories", "name") + if error: + return error + cache_path = get_categories_cache_path() + categories_payload = None + if cache_path.exists(): + categories_payload = json.loads(cache_path.read_text(encoding="utf-8")) + else: + categories_yaml = get_repo_dir() / "categories" / "categories.yaml" + if categories_yaml.exists(): + categories_payload = yaml.safe_load(categories_yaml.read_text(encoding="utf-8")) or {} + categories = (categories_payload or {}).get("categories", []) + counts = query_all( + g.db, + "SELECT category, COUNT(DISTINCT owner || '/' || name) AS total FROM tools GROUP BY category", + ) + count_map = {row["category"]: row["total"] for row in counts} + data = [] + for cat in categories: + name = cat.get("name") + if not name: + continue + data.append({ + "name": name, + "description": cat.get("description"), + "icon": cat.get("icon"), + "tool_count": count_map.get(name, 0), + }) + + reverse = order == "desc" + if sort == "tool_count": + data.sort(key=lambda item: item["tool_count"], reverse=reverse) + else: + data.sort(key=lambda item: item["name"], reverse=reverse) + + total = len(data) + start = (page - 1) * per_page + end = start + per_page + sliced = data[start:end] + + response = jsonify({"data": sliced, "meta": paginate(page, per_page, total)}) + response.headers["Cache-Control"] = "max-age=3600" + return response + + @app.route("/api/v1/stats/popular", methods=["GET"]) + def popular_tools() -> Response: + limit = min(int(request.args.get("limit", 10)), 50) + rows = query_all( + g.db, + """ + WITH latest AS ( + SELECT owner, name, MAX(id) AS max_id + FROM tools + WHERE version NOT LIKE '%-%' + GROUP BY owner, name + ) + SELECT t.* 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 + LIMIT ? + """, + [limit], + ) + data = [] + for row in rows: + data.append({ + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "category": row["category"], + "tags": json.loads(row["tags"] or "[]"), + "downloads": row["downloads"], + "published_at": row["published_at"], + }) + return jsonify({"data": data}) + + @app.route("/api/v1/index.json", methods=["GET"]) + def get_index() -> Response: + rows = query_all( + g.db, + """ + WITH latest AS ( + SELECT owner, name, MAX(id) AS max_id + FROM tools + WHERE version NOT LIKE '%-%' + GROUP BY owner, name + ) + SELECT t.owner, t.name, t.version, t.description, t.category, t.tags, t.downloads + 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 + """, + ) + tools = [] + for row in rows: + tools.append({ + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "category": row["category"], + "tags": json.loads(row["tags"] or "[]"), + "downloads": row["downloads"], + }) + + # Generate checksum for integrity verification + content = json.dumps(tools, sort_keys=True) + checksum = "sha256:" + hashlib.sha256(content.encode()).hexdigest() + + payload = { + "version": "1.0", + "generated_at": datetime.utcnow().isoformat() + "Z", + "checksum": checksum, + "tool_count": len(tools), + "tools": tools, + } + response = jsonify(payload) + response.headers["Cache-Control"] = "max-age=300, stale-while-revalidate=60" + response.headers["ETag"] = f'"{checksum}"' + return response + + @app.route("/api/v1/register", methods=["POST"]) + def register() -> Response: + if request.content_length and request.content_length > MAX_BODY_BYTES: + return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413) + payload = request.get_json(silent=True) or {} + email = (payload.get("email") or "").strip() + password = payload.get("password") or "" + slug = (payload.get("slug") or "").strip() + display_name = (payload.get("display_name") or "").strip() + + if not email or not EMAIL_RE.match(email): + return error_response("VALIDATION_ERROR", "Invalid email format") + if not password or len(password) < 8: + return error_response("VALIDATION_ERROR", "Password must be at least 8 characters") + if not slug or not OWNER_RE.match(slug) or len(slug) < 2 or len(slug) > 39: + return error_response("VALIDATION_ERROR", "Invalid slug format") + if slug in RESERVED_SLUGS: + return error_response("SLUG_TAKEN", f"Slug '{slug}' is reserved", 409) + if not display_name: + return error_response("VALIDATION_ERROR", "Display name is required") + + existing_email = query_one(g.db, "SELECT id FROM publishers WHERE email = ?", [email]) + if existing_email: + return error_response("VALIDATION_ERROR", "Email already registered") + existing_slug = query_one(g.db, "SELECT id FROM publishers WHERE slug = ?", [slug]) + if existing_slug: + return error_response("SLUG_TAKEN", f"Slug '{slug}' is already taken", 409) + + password_hash = password_hasher.hash(password) + g.db.execute( + """ + INSERT INTO publishers (email, password_hash, slug, display_name, verified) + VALUES (?, ?, ?, ?, ?) + """, + [email, password_hash, slug, display_name, False], + ) + g.db.commit() + publisher_id = query_one(g.db, "SELECT id FROM publishers WHERE slug = ?", [slug])["id"] + + response = jsonify({ + "data": { + "id": publisher_id, + "slug": slug, + "display_name": display_name, + "email": email, + } + }) + response.status_code = 201 + return response + + @app.route("/api/v1/login", methods=["POST"]) + def login() -> Response: + if request.content_length and request.content_length > MAX_BODY_BYTES: + return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413) + payload = request.get_json(silent=True) or {} + email = (payload.get("email") or "").strip() + password = payload.get("password") or "" + if not email or not password: + return error_response("VALIDATION_ERROR", "Email and password are required") + + publisher = query_one( + g.db, + "SELECT * FROM publishers WHERE email = ?", + [email], + ) + if not publisher: + return error_response("UNAUTHORIZED", "Invalid credentials", 401) + + locked_until = publisher["locked_until"] + if locked_until: + try: + locked_dt = datetime.fromisoformat(locked_until) + if datetime.utcnow() < locked_dt: + return error_response("ACCOUNT_LOCKED", "Account is locked", 403) + except ValueError: + pass + + try: + password_hasher.verify(publisher["password_hash"], password) + except VerifyMismatchError: + ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown") + rate_key = f"{ip}:{email}:login_failed" + limit_config = RATE_LIMITS["login_failed"] + allowed, _ = rate_limiter.check(rate_key, limit_config["limit"], limit_config["window"]) + attempts = int(publisher["failed_login_attempts"] or 0) + 1 + locked = None + if attempts >= 10: + locked = datetime.utcnow() + timedelta(hours=1) + elif attempts >= 5: + locked = datetime.utcnow() + timedelta(minutes=15) + g.db.execute( + "UPDATE publishers SET failed_login_attempts = ?, locked_until = ? WHERE id = ?", + [attempts, locked.isoformat() if locked else None, publisher["id"]], + ) + g.db.commit() + if not allowed: + return error_response("RATE_LIMITED", "Too many failed logins. Try again later.", 429) + return error_response("UNAUTHORIZED", "Invalid credentials", 401) + + g.db.execute( + "UPDATE publishers SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?", + [publisher["id"]], + ) + + token, token_hash = generate_token() + g.db.execute( + """ + INSERT INTO api_tokens (publisher_id, token_hash, name, created_at) + VALUES (?, ?, ?, ?) + """, + [publisher["id"], token_hash, "login", datetime.utcnow().isoformat()], + ) + g.db.commit() + + return jsonify({ + "data": { + "token": token, + "publisher": { + "slug": publisher["slug"], + "display_name": publisher["display_name"], + }, + } + }) + + @app.route("/api/v1/tokens", methods=["POST"]) + @require_token + def create_token() -> Response: + if request.content_length and request.content_length > MAX_BODY_BYTES: + return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413) + rate_resp = enforce_token_rate_limit("tokens", g.current_token["hash"]) + if rate_resp: + return rate_resp + payload = request.get_json(silent=True) or {} + name = (payload.get("name") or "CLI token").strip() + token, token_hash = generate_token() + now = datetime.utcnow().isoformat() + g.db.execute( + "INSERT INTO api_tokens (publisher_id, token_hash, name, created_at) VALUES (?, ?, ?, ?)", + [g.current_publisher["id"], token_hash, name, now], + ) + g.db.commit() + response = jsonify({ + "data": { + "token": token, + "name": name, + "created_at": now, + } + }) + response.status_code = 201 + return response + + @app.route("/api/v1/tokens", methods=["GET"]) + @require_token + def list_tokens() -> Response: + rows = query_all( + g.db, + """ + SELECT id, name, created_at, last_used_at + FROM api_tokens + WHERE publisher_id = ? AND revoked_at IS NULL + ORDER BY created_at DESC + """, + [g.current_publisher["id"]], + ) + data = [] + for row in rows: + data.append({ + "id": row["id"], + "name": row["name"], + "created_at": row["created_at"], + "last_used_at": row["last_used_at"], + }) + return jsonify({"data": data}) + + @app.route("/api/v1/tokens/", methods=["DELETE"]) + @require_token + def revoke_token(token_id: int) -> Response: + row = query_one( + g.db, + "SELECT id FROM api_tokens WHERE id = ? AND publisher_id = ?", + [token_id, g.current_publisher["id"]], + ) + if not row: + return error_response("FORBIDDEN", "Cannot revoke this token", 403) + g.db.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE id = ?", + [datetime.utcnow().isoformat(), token_id], + ) + g.db.commit() + return jsonify({"data": {"revoked": True}}) + + @app.route("/api/v1/tools", methods=["POST"]) + @require_token + def publish_tool() -> Response: + if request.content_length and request.content_length > MAX_BODY_BYTES: + return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413) + rate_resp = enforce_token_rate_limit("publish", g.current_token["hash"]) + if rate_resp: + return rate_resp + payload = request.get_json(silent=True) or {} + config_text = payload.get("config") or "" + readme = payload.get("readme") or "" + dry_run = bool(payload.get("dry_run")) + + size_resp = validate_payload_size("config", config_text, MAX_CONFIG_BYTES) + if size_resp: + return size_resp + if readme: + size_resp = validate_payload_size("readme", readme, MAX_README_BYTES) + if size_resp: + return size_resp + + try: + data = yaml.safe_load(config_text) or {} + except yaml.YAMLError: + return error_response("VALIDATION_ERROR", "Invalid YAML in config") + + name = (data.get("name") or "").strip() + version = (data.get("version") or "").strip() + description = (data.get("description") or "").strip() + category = (data.get("category") or "").strip() or None + tags = data.get("tags") or [] + + if not name or not TOOL_NAME_RE.match(name) or len(name) > MAX_TOOL_NAME_LEN: + return error_response("VALIDATION_ERROR", "Invalid tool name") + if not version or Semver.parse(version) is None: + return error_response("INVALID_VERSION", "Version string is not valid semver") + if description and len(description) > MAX_DESC_LEN: + return error_response("VALIDATION_ERROR", "Description exceeds 500 characters") + if tags: + if not isinstance(tags, list): + return error_response("VALIDATION_ERROR", "Tags must be a list") + if len(tags) > MAX_TAGS: + return error_response("VALIDATION_ERROR", "Too many tags") + for tag in tags: + if len(str(tag)) > MAX_TAG_LEN: + return error_response("VALIDATION_ERROR", "Tag exceeds 32 characters") + + owner = g.current_publisher["slug"] + existing = query_one( + g.db, + "SELECT published_at FROM tools WHERE owner = ? AND name = ? AND version = ?", + [owner, name, version], + ) + if existing: + return error_response( + "VERSION_EXISTS", + f"Version {version} already exists", + 409, + details={"published_at": existing["published_at"]}, + ) + + suggestions = {"category": None, "similar_tools": []} + try: + from .categorize import suggest_categories + from .similarity import find_similar_tools + categories_path = get_repo_dir() / "categories" / "categories.yaml" + if not category and categories_path.exists(): + ranked = suggest_categories(name, description, tags, categories_path) + if ranked: + suggestions["category"] = { + "suggested": ranked[0][0], + "confidence": ranked[0][1], + } + rows = query_all( + g.db, + "SELECT owner, name, description, category, tags FROM tools", + ) + existing = [] + for row in rows: + try: + existing.append({ + "owner": row["owner"], + "name": row["name"], + "description": row["description"] or "", + "category": row["category"], + "tags": json.loads(row["tags"] or "[]"), + }) + except Exception: + continue + similar = find_similar_tools(existing, name, description, tags, category) + suggestions["similar_tools"] = [ + {"name": f"{tool['owner']}/{tool['name']}", "similarity": score} + for tool, score in similar[:5] + ] + except Exception: + pass + + if dry_run: + return jsonify({ + "data": { + "owner": owner, + "name": name, + "version": version, + "status": "validated", + "suggestions": suggestions, + } + }) + + tags_json = json.dumps(tags) + g.db.execute( + """ + INSERT INTO tools ( + owner, name, version, description, category, tags, config_yaml, readme, + publisher_id, deprecated, deprecated_message, replacement, downloads, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + owner, + name, + version, + description or None, + category, + tags_json, + config_text, + readme, + g.current_publisher["id"], + int(bool(data.get("deprecated"))), + data.get("deprecated_message"), + data.get("replacement"), + 0, + datetime.utcnow().isoformat(), + ], + ) + g.db.commit() + + response = jsonify({ + "data": { + "owner": owner, + "name": name, + "version": version, + "pr_url": "", + "status": "pending_review", + "suggestions": suggestions, + } + }) + response.status_code = 201 + return response + + @app.route("/api/v1/me/tools", methods=["GET"]) + @require_token + def my_tools() -> Response: + rows = query_all( + g.db, + """ + SELECT owner, name, version, description, downloads, deprecated, deprecated_message, replacement + FROM tools + WHERE owner = ? + ORDER BY published_at DESC + """, + [g.current_publisher["slug"]], + ) + data = [] + for row in rows: + data.append({ + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "downloads": row["downloads"], + "deprecated": bool(row["deprecated"]), + "deprecated_message": row["deprecated_message"], + "replacement": row["replacement"], + }) + return jsonify({"data": data}) + + @app.route("/api/v1/me/settings", methods=["PUT"]) + @require_token + def update_settings() -> Response: + """Update current user's profile settings.""" + data = request.get_json() or {} + + # Validate fields + display_name = data.get("display_name", "").strip() + bio = data.get("bio", "").strip() if data.get("bio") else None + website = data.get("website", "").strip() if data.get("website") else None + + if display_name and len(display_name) > 100: + return error_response("VALIDATION_ERROR", "Display name too long (max 100)", 400) + if bio and len(bio) > 500: + return error_response("VALIDATION_ERROR", "Bio too long (max 500)", 400) + if website and len(website) > 200: + return error_response("VALIDATION_ERROR", "Website URL too long (max 200)", 400) + + # Build update query + updates = [] + params = [] + if display_name: + updates.append("display_name = ?") + params.append(display_name) + if bio is not None: + updates.append("bio = ?") + params.append(bio) + if website is not None: + updates.append("website = ?") + params.append(website) + + if not updates: + return error_response("VALIDATION_ERROR", "No valid fields to update", 400) + + updates.append("updated_at = CURRENT_TIMESTAMP") + params.append(g.current_publisher["id"]) + + g.db.execute( + f"UPDATE publishers SET {', '.join(updates)} WHERE id = ?", + params, + ) + g.db.commit() + + return jsonify({"data": {"status": "updated"}}) + + @app.route("/api/v1/featured/tools", methods=["GET"]) + def featured_tools() -> Response: + """Get featured tools for homepage/landing.""" + placement = request.args.get("placement", "homepage") + limit = min(int(request.args.get("limit", 6)), 20) + + rows = query_all( + g.db, + """ + SELECT t.owner, t.name, t.version, t.description, t.category, t.downloads, + ft.priority + FROM featured_tools ft + JOIN tools t ON ft.tool_id = t.id + WHERE ft.placement = ? + AND ft.status = 'active' + AND (ft.start_at IS NULL OR ft.start_at <= CURRENT_TIMESTAMP) + AND (ft.end_at IS NULL OR ft.end_at > CURRENT_TIMESTAMP) + ORDER BY ft.priority DESC, t.downloads DESC + LIMIT ? + """, + [placement, limit], + ) + + # If no featured tools, fall back to popular + if not rows: + rows = query_all( + g.db, + """ + SELECT owner, name, version, description, category, downloads + FROM tools + WHERE deprecated = 0 + ORDER BY downloads DESC + LIMIT ? + """, + [limit], + ) + + data = [] + for row in rows: + data.append({ + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "category": row["category"], + "downloads": row["downloads"], + }) + + return jsonify({"data": data}) + + @app.route("/api/v1/featured/contributors", methods=["GET"]) + def featured_contributors() -> Response: + """Get featured contributor for homepage.""" + placement = request.args.get("placement", "homepage") + + row = query_one( + g.db, + """ + SELECT p.slug, p.display_name, p.bio, p.website, + fc.bio_override + FROM featured_contributors fc + JOIN publishers p ON fc.publisher_id = p.id + WHERE fc.placement = ? + AND fc.status = 'active' + AND (fc.start_at IS NULL OR fc.start_at <= CURRENT_TIMESTAMP) + AND (fc.end_at IS NULL OR fc.end_at > CURRENT_TIMESTAMP) + ORDER BY fc.created_at DESC + LIMIT 1 + """, + [placement], + ) + + if not row: + return jsonify({"data": None}) + + return jsonify({ + "data": { + "slug": row["slug"], + "display_name": row["display_name"], + "bio": row["bio_override"] or row["bio"], + "website": row["website"], + } + }) + + @app.route("/api/v1/content/announcements", methods=["GET"]) + def announcements() -> Response: + """Get published announcements.""" + limit = min(int(request.args.get("limit", 5)), 20) + + rows = query_all( + g.db, + """ + SELECT id, title, body, published_at + FROM announcements + WHERE published = 1 + ORDER BY published_at DESC + LIMIT ? + """, + [limit], + ) + + data = [] + for row in rows: + data.append({ + "id": row["id"], + "title": row["title"], + "body": row["body"], + "published_at": row["published_at"], + }) + + return jsonify({"data": data}) + + @app.route("/api/v1/reports", methods=["POST"]) + def submit_report() -> Response: + """Submit an abuse report for a tool.""" + data = request.get_json() or {} + + owner = data.get("owner", "").strip() + name = data.get("name", "").strip() + reason = data.get("reason", "").strip() + details = data.get("details", "").strip() if data.get("details") else None + + if not owner or not name: + return error_response("VALIDATION_ERROR", "owner and name required", 400) + if not reason: + return error_response("VALIDATION_ERROR", "reason required", 400) + if len(reason) > 100: + return error_response("VALIDATION_ERROR", "reason too long (max 100)", 400) + if details and len(details) > 2000: + return error_response("VALIDATION_ERROR", "details too long (max 2000)", 400) + + # Find the tool + tool = query_one( + g.db, + "SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY published_at DESC LIMIT 1", + [owner, name], + ) + if not tool: + return error_response("TOOL_NOT_FOUND", f"Tool {owner}/{name} not found", 404) + + # Get reporter info + reporter_id = None + if hasattr(g, "current_publisher") and g.current_publisher: + reporter_id = g.current_publisher["id"] + reporter_ip = request.remote_addr + + # Rate limit: max 5 reports per IP per hour + recent = query_one( + g.db, + """ + SELECT COUNT(*) as cnt FROM reports + WHERE reporter_ip = ? + AND created_at > datetime('now', '-1 hour') + """, + [reporter_ip], + ) + if recent and recent["cnt"] >= 5: + return error_response("RATE_LIMITED", "Too many reports. Try again later.", 429) + + g.db.execute( + """ + INSERT INTO reports (tool_id, reporter_id, reporter_ip, reason, details) + VALUES (?, ?, ?, ?, ?) + """, + [tool["id"], reporter_id, reporter_ip, reason, details], + ) + g.db.commit() + + return jsonify({"data": {"status": "submitted"}}) + + @app.route("/api/v1/consent", methods=["POST"]) + def save_consent() -> Response: + """Save user consent preferences for analytics/ads.""" + try: + data = request.get_json(force=True) or {} + except Exception: + data = {} + + analytics = bool(data.get("analytics", False)) + ads = bool(data.get("ads", False)) + + # Store consent in session (works with our SQLite session interface) + from flask import session + session["consent_analytics"] = analytics + session["consent_ads"] = ads + session["consent_given"] = True + + return jsonify({ + "data": { + "analytics": analytics, + "ads": ads, + "saved": True + } + }) + + @app.route("/api/v1/webhook/gitea", methods=["POST"]) + def webhook_gitea() -> Response: + if request.content_length and request.content_length > MAX_BODY_BYTES: + return error_response( + "PAYLOAD_TOO_LARGE", + "Request body exceeds 512KB limit", + status=413, + details={"limit": MAX_BODY_BYTES}, + ) + secret = os.environ.get("SMARTTOOLS_REGISTRY_WEBHOOK_SECRET", "") + if not secret: + return error_response("UNAUTHORIZED", "Webhook secret not configured", 401) + status, payload = process_webhook(request.data, dict(request.headers), secret) + response = jsonify(payload) + response.status_code = status + return response + + return app + + +def main() -> None: + app = create_app() + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000))) + + +if __name__ == "__main__": + main() diff --git a/src/smarttools/registry/categorize.py b/src/smarttools/registry/categorize.py new file mode 100644 index 0000000..0350be3 --- /dev/null +++ b/src/smarttools/registry/categorize.py @@ -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 diff --git a/src/smarttools/registry/db.py b/src/smarttools/registry/db.py new file mode 100644 index 0000000..d4b134f --- /dev/null +++ b/src/smarttools/registry/db.py @@ -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() diff --git a/src/smarttools/registry/rate_limit.py b/src/smarttools/registry/rate_limit.py new file mode 100644 index 0000000..651fd74 --- /dev/null +++ b/src/smarttools/registry/rate_limit.py @@ -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 diff --git a/src/smarttools/registry/similarity.py b/src/smarttools/registry/similarity.py new file mode 100644 index 0000000..d44f68a --- /dev/null +++ b/src/smarttools/registry/similarity.py @@ -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 diff --git a/src/smarttools/registry/sync.py b/src/smarttools/registry/sync.py new file mode 100644 index 0000000..fa74009 --- /dev/null +++ b/src/smarttools/registry/sync.py @@ -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() diff --git a/src/smarttools/registry_client.py b/src/smarttools/registry_client.py new file mode 100644 index 0000000..84a0ae8 --- /dev/null +++ b/src/smarttools/registry_client.py @@ -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 diff --git a/src/smarttools/resolver.py b/src/smarttools/resolver.py new file mode 100644 index 0000000..9394c99 --- /dev/null +++ b/src/smarttools/resolver.py @@ -0,0 +1,668 @@ +"""Tool resolution with proper search order. + +Implements the tool resolution order: +1. Local project: ./.smarttools///config.yaml +2. Global user: ~/.smarttools///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" + - "summarize@1.0.0" -> 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/summarize@1.0.0") + 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) diff --git a/src/smarttools/runner.py b/src/smarttools/runner.py index 22f3191..b513884 100644 --- a/src/smarttools/runner.py +++ b/src/smarttools/runner.py @@ -5,8 +5,10 @@ import sys from pathlib import Path from typing import Optional -from .tool import load_tool, Tool, PromptStep, CodeStep +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: @@ -226,13 +228,25 @@ def main(): print("Usage: python -m smarttools.runner [args...]", file=sys.stderr) sys.exit(1) - tool_name = sys.argv[1] - tool = load_tool(tool_name) + tool_spec = sys.argv[1] - if tool is None: - print(f"Error: Tool '{tool_name}' not found", file=sys.stderr) + # 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:]) @@ -263,12 +277,15 @@ def main(): 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=args.provider, + provider_override=effective_provider, dry_run=args.dry_run, show_prompt=args.show_prompt, verbose=args.verbose diff --git a/src/smarttools/ui_registry.py b/src/smarttools/ui_registry.py new file mode 100644 index 0000000..9b7f0dd --- /dev/null +++ b/src/smarttools/ui_registry.py @@ -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 diff --git a/src/smarttools/web/__init__.py b/src/smarttools/web/__init__.py new file mode 100644 index 0000000..1defcec --- /dev/null +++ b/src/smarttools/web/__init__.py @@ -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", +) diff --git a/src/smarttools/web/app.py b/src/smarttools/web/app.py new file mode 100644 index 0000000..836e4a1 --- /dev/null +++ b/src/smarttools/web/app.py @@ -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))) diff --git a/src/smarttools/web/auth.py b/src/smarttools/web/auth.py new file mode 100644 index 0000000..d9113a8 --- /dev/null +++ b/src/smarttools/web/auth.py @@ -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")) diff --git a/src/smarttools/web/filters.py b/src/smarttools/web/filters.py new file mode 100644 index 0000000..632c5d4 --- /dev/null +++ b/src/smarttools/web/filters.py @@ -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 diff --git a/src/smarttools/web/routes.py b/src/smarttools/web/routes.py new file mode 100644 index 0000000..0da3bbf --- /dev/null +++ b/src/smarttools/web/routes.py @@ -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("
") + escaped + Markup("
") + + +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/", 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//", 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///versions/", 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/", 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/", 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/", 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"

Documentation for {escape(current)} is coming soon.

", + 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/", 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/", 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.", + ) diff --git a/src/smarttools/web/seo.py b/src/smarttools/web/seo.py new file mode 100644 index 0000000..59e1950 --- /dev/null +++ b/src/smarttools/web/seo.py @@ -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 = "\n" + xml += "\n" + xml += "\n".join(urls) + xml += "\n\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([ + " ", + f" {loc}", + f" {changefreq}", + f" {priority}", + " ", + ]) diff --git a/src/smarttools/web/sessions.py b/src/smarttools/web/sessions.py new file mode 100644 index 0000000..caef973 --- /dev/null +++ b/src/smarttools/web/sessions.py @@ -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() diff --git a/src/smarttools/web/static/css/main.css b/src/smarttools/web/static/css/main.css new file mode 100644 index 0000000..f09b080 --- /dev/null +++ b/src/smarttools/web/static/css/main.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-2{top:.5rem}.top-2\.5{top:.625rem}.top-3{top:.75rem}.top-4{top:1rem}.z-40{z-index:40}.z-50{z-index:50}.col-span-1{grid-column:span 1/span 1}.col-span-full{grid-column:1/-1}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-24{height:6rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-96{max-height:24rem}.max-h-\[calc\(100vh-6rem\)\]{max-height:calc(100vh - 6rem)}.min-h-\[250px\]{min-height:250px}.min-h-\[60vh\]{min-height:60vh}.min-h-\[70vh\]{min-height:70vh}.min-h-\[90px\]{min-height:90px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-24{width:6rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.rotate-180,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.resize-y{resize:vertical}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b-lg{border-bottom-right-radius:.5rem}.rounded-b-lg,.rounded-l-lg{border-bottom-left-radius:.5rem}.rounded-l-lg{border-top-left-radius:.5rem}.rounded-r-lg{border-bottom-right-radius:.5rem}.rounded-r-lg,.rounded-t-lg{border-top-right-radius:.5rem}.rounded-t-lg{border-top-left-radius:.5rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l-4{border-left-width:4px}.border-r-0{border-right-width:0}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-blue-100{--tw-border-opacity:1;border-color:rgb(219 234 254/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-indigo-600{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity,1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-slate-700{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-indigo-500{--tw-gradient-from:#6366f1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(99,102,241,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-cyan-500{--tw-gradient-to:#06b6d4 var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-12{padding-left:3rem}.pr-4{padding-right:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace}.font-sans{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-tight{line-height:1.25}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-cyan-600{--tw-text-opacity:1;color:rgb(8 145 178/var(--tw-text-opacity,1))}.text-cyan-700{--tw-text-opacity:1;color:rgb(14 116 144/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.btn-primary{border-radius:.375rem;--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-primary:hover{opacity:.9}.btn-secondary{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.btn-secondary:hover{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.card{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:border-indigo-500:hover{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-cyan-50:hover{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-50:hover{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-slate-700:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.hover\:text-amber-600:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-400:hover{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.hover\:text-indigo-600:hover{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.hover\:text-indigo-800:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.hover\:text-indigo-900:hover{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-red-900:hover{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-lg:hover,.hover\:shadow-md:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.focus\:not-sr-only:focus{position:static;width:auto;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal}.focus\:absolute:focus{position:absolute}.focus\:left-4:focus{left:1rem}.focus\:top-4:focus{top:1rem}.focus\:border-indigo-500:focus{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-cyan-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(6 182 212/var(--tw-ring-opacity,1))}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity,1))}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:mt-0{margin-top:0}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:py-24{padding-top:6rem;padding-bottom:6rem}.md\:text-5xl{font-size:3rem;line-height:1}.md\:text-base{font-size:1rem;line-height:1.5rem}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:mt-0{margin-top:0}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:gap-8{gap:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file diff --git a/src/smarttools/web/static/js/main.js b/src/smarttools/web/static/js/main.js new file mode 100644 index 0000000..6203c50 --- /dev/null +++ b/src/smarttools/web/static/js/main.js @@ -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 = '

Start typing to search...

'; + 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 => ` + +
${tool.owner}/${tool.name}
+
${tool.description || 'No description'}
+
+ `).join(''); + } else { + searchResults.innerHTML = '

No tools found

'; + } + }) + .catch(() => { + searchResults.innerHTML = '

Search failed

'; + }); + }, 300)); +} diff --git a/src/smarttools/web/static/src/input.css b/src/smarttools/web/static/src/input.css new file mode 100644 index 0000000..2c368a3 --- /dev/null +++ b/src/smarttools/web/static/src/input.css @@ -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; +} diff --git a/src/smarttools/web/templates/base.html b/src/smarttools/web/templates/base.html new file mode 100644 index 0000000..9008267 --- /dev/null +++ b/src/smarttools/web/templates/base.html @@ -0,0 +1,76 @@ + + + + + + {% block title %}SmartTools{% endblock %} - Build Custom AI Commands + + + + {% block meta_extra %}{% endblock %} + + + + + + + {% block og_extra %}{% endblock %} + + + + + + {% block twitter_extra %}{% endblock %} + + + + + + + + + + + + {% block styles %}{% endblock %} + + + {% block schema %} + + {% endblock %} + + + + + Skip to content + + + + {% include "components/header.html" %} + + +
+ {% block content %}{% endblock %} +
+ + + {% include "components/footer.html" %} + + + {% if not session.get('consent_given') %} + {% include "components/consent_banner.html" %} + {% endif %} + + + + {% block scripts %}{% endblock %} + + diff --git a/src/smarttools/web/templates/components/callouts.html b/src/smarttools/web/templates/components/callouts.html new file mode 100644 index 0000000..fd320fe --- /dev/null +++ b/src/smarttools/web/templates/components/callouts.html @@ -0,0 +1,61 @@ +{# Callout/alert macros for documentation and content pages #} + +{# Info callout #} +{% macro info(title='Note', content='') %} +
+
+ + + +
+

{{ title }}

+

{{ content }}

+
+
+
+{% endmacro %} + +{# Warning callout #} +{% macro warning(title='Warning', content='') %} +
+
+ + + +
+

{{ title }}

+

{{ content }}

+
+
+
+{% endmacro %} + +{# Error callout #} +{% macro error(title='Important', content='') %} +
+
+ + + +
+

{{ title }}

+

{{ content }}

+
+
+
+{% endmacro %} + +{# Tip callout #} +{% macro tip(title='Tip', content='') %} +
+
+ + + +
+

{{ title }}

+

{{ content }}

+
+
+
+{% endmacro %} diff --git a/src/smarttools/web/templates/components/code_block.html b/src/smarttools/web/templates/components/code_block.html new file mode 100644 index 0000000..7093f26 --- /dev/null +++ b/src/smarttools/web/templates/components/code_block.html @@ -0,0 +1,47 @@ +{# Code block macro with syntax highlighting and copy button #} + +{% macro code(content, language='', filename='', show_line_numbers=false) %} +
+ {% if filename %} +
+ {{ filename }} +
+ {% endif %} +
+
{{ content }}
+ +
+
+{% endmacro %} + +{# Inline code #} +{% macro inline(content) %} +{{ content }} +{% endmacro %} + +{# Install command (special styling) #} +{% macro install(command) %} +
+ $ + {{ command }} + +
+{% endmacro %} diff --git a/src/smarttools/web/templates/components/consent_banner.html b/src/smarttools/web/templates/components/consent_banner.html new file mode 100644 index 0000000..eec47b8 --- /dev/null +++ b/src/smarttools/web/templates/components/consent_banner.html @@ -0,0 +1,148 @@ +{# Cookie consent banner #} + + + + + + diff --git a/src/smarttools/web/templates/components/contributor_card.html b/src/smarttools/web/templates/components/contributor_card.html new file mode 100644 index 0000000..410e6bb --- /dev/null +++ b/src/smarttools/web/templates/components/contributor_card.html @@ -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 %} + +
+ +
+ {{ contributor.display_name[0]|upper }} +
+ + +
+

{{ contributor.display_name }}

+

@{{ contributor.slug }}

+

{{ contributor.bio or 'Active community member and tool creator.' }}

+
+ + + +
+{% else %} + +
+ +
+ {{ contributor.display_name[0]|upper }} +
+ + +

{{ contributor.display_name }}

+

@{{ contributor.slug }}

+ + {% if contributor.bio %} +

{{ contributor.bio }}

+ {% endif %} + + + + View Profile + +
+{% endif %} +{% endmacro %} diff --git a/src/smarttools/web/templates/components/footer.html b/src/smarttools/web/templates/components/footer.html new file mode 100644 index 0000000..e5f9684 --- /dev/null +++ b/src/smarttools/web/templates/components/footer.html @@ -0,0 +1,59 @@ + diff --git a/src/smarttools/web/templates/components/forms.html b/src/smarttools/web/templates/components/forms.html new file mode 100644 index 0000000..5f890c9 --- /dev/null +++ b/src/smarttools/web/templates/components/forms.html @@ -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) %} +
+ + + {% if help %} +

{{ help }}

+ {% endif %} + {% if error %} + + {% endif %} +
+{% endmacro %} + +{# Textarea field #} +{% macro textarea(name, label, placeholder='', value='', required=false, rows=4, error=none, help=none) %} +
+ + + {% if help %} +

{{ help }}

+ {% endif %} + {% if error %} + + {% endif %} +
+{% endmacro %} + +{# Select dropdown #} +{% macro select(name, label, options, selected='', required=false, error=none, help=none) %} +
+ + + {% if help %} +

{{ help }}

+ {% endif %} + {% if error %} + + {% endif %} +
+{% endmacro %} + +{# Checkbox #} +{% macro checkbox(name, label, checked=false, help=none) %} +
+ +
+ + {% if help %} +

{{ help }}

+ {% endif %} +
+
+{% endmacro %} + +{# Primary button #} +{% macro button_primary(text, type='submit', name=none, disabled=false, full_width=false) %} + +{% endmacro %} + +{# Secondary button #} +{% macro button_secondary(text, type='button', name=none, disabled=false, full_width=false) %} + +{% endmacro %} + +{# Danger button #} +{% macro button_danger(text, type='button', name=none, disabled=false) %} + +{% endmacro %} + +{# Ghost/link button #} +{% macro button_ghost(text, href='#') %} + + {{ text }} + + + + +{% endmacro %} + +{# Form error alert #} +{% macro form_errors(errors) %} +{% if errors %} + +{% endif %} +{% endmacro %} + +{# Success alert #} +{% macro success_alert(message) %} +{% if message %} + +{% endif %} +{% endmacro %} diff --git a/src/smarttools/web/templates/components/header.html b/src/smarttools/web/templates/components/header.html new file mode 100644 index 0000000..ace903f --- /dev/null +++ b/src/smarttools/web/templates/components/header.html @@ -0,0 +1,183 @@ +
+ +
+ + + diff --git a/src/smarttools/web/templates/components/tool_card.html b/src/smarttools/web/templates/components/tool_card.html new file mode 100644 index 0000000..83c1b95 --- /dev/null +++ b/src/smarttools/web/templates/components/tool_card.html @@ -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 %} +
+ + {% if tool.category %} + + {{ tool.category }} + + {% endif %} + + +
+
+ {{ tool.name[0]|upper }} +
+
+

+ + {{ tool.name }} + +

+

by {{ tool.owner }}

+
+
+ + +

+ {{ tool.description or 'No description available.' }} +

+ + +
+ + + + + {{ tool.downloads|default(0) }} downloads + + v{{ tool.version }} +
+ + +
+
+ + smarttools install {{ tool.owner }}/{{ tool.name }} + + +
+
+
+{% endmacro %} diff --git a/src/smarttools/web/templates/components/tutorial_card.html b/src/smarttools/web/templates/components/tutorial_card.html new file mode 100644 index 0000000..4997c87 --- /dev/null +++ b/src/smarttools/web/templates/components/tutorial_card.html @@ -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 %} +
+ + {% if tutorial.thumbnail %} +
+ {{ tutorial.title }} +
+ {% else %} +
+ + + +
+ {% endif %} + +
+ {% if step_number %} +

Step {{ step_number }}

+ {% endif %} +

+ + {{ tutorial.title }} + +

+

+ {{ tutorial.description or 'Learn how to use SmartTools effectively.' }} +

+ + Read More + + + + +
+
+{% endmacro %} diff --git a/src/smarttools/web/templates/dashboard/base.html b/src/smarttools/web/templates/dashboard/base.html new file mode 100644 index 0000000..d435467 --- /dev/null +++ b/src/smarttools/web/templates/dashboard/base.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+ {% block dashboard_header %} +

Dashboard

+

Welcome back, {{ user.display_name }}

+ {% endblock %} +
+
+ +
+
+ + + + +
+ {% block dashboard_content %}{% endblock %} +
+
+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/dashboard/index.html b/src/smarttools/web/templates/dashboard/index.html new file mode 100644 index 0000000..d77f1aa --- /dev/null +++ b/src/smarttools/web/templates/dashboard/index.html @@ -0,0 +1,135 @@ +{% extends "dashboard/base.html" %} +{% set active_page = 'overview' %} + +{% block title %}Dashboard - SmartTools{% endblock %} + +{% block dashboard_content %} + +
+
+
+
+ + + +
+
+

Published Tools

+

{{ stats.tools_count }}

+
+
+
+ +
+
+
+ + + +
+
+

Total Downloads

+

{{ stats.total_downloads|format_number }}

+
+
+
+ +
+
+
+ + + +
+
+

API Tokens

+

{{ stats.tokens_count }}

+
+
+
+
+ + +
+
+

Your Tools

+ + + Publish New Tool + +
+ + {% if tools %} +
    + {% for tool in tools[:5] %} +
  • +
    + + {{ tool.name }} + +

    v{{ tool.version }}

    +
    +
    +

    {{ tool.downloads|format_number }} downloads

    +

    {{ tool.published_at|timeago }}

    +
    +
  • + {% endfor %} +
+ {% if tools|length > 5 %} + + {% endif %} + {% else %} +
+ + + +

You haven't published any tools yet.

+ + Publish Your First Tool + +
+ {% endif %} +
+ + +{% if recent_activity %} +
+
+

Recent Activity

+
+
    + {% for activity in recent_activity[:5] %} +
  • +
    + {% if activity.type == 'download' %} +
    + + + +
    + {% elif activity.type == 'publish' %} +
    + + + +
    + {% endif %} +
    +
    +

    {{ activity.description }}

    +

    {{ activity.timestamp|timeago }}

    +
    +
  • + {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/src/smarttools/web/templates/dashboard/settings.html b/src/smarttools/web/templates/dashboard/settings.html new file mode 100644 index 0000000..c349201 --- /dev/null +++ b/src/smarttools/web/templates/dashboard/settings.html @@ -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 %} +

Settings

+

Manage your profile and account settings

+{% endblock %} + +{% block dashboard_content %} +{{ success_alert(success_message) }} +{{ form_errors(errors) }} + + +
+
+

Profile

+

This information will be displayed on your public profile.

+
+ +
+ + + +
+
+ + +
+ +
+ +
+ + @ + + +
+

Username cannot be changed

+
+
+ +
+ + +

Brief description for your profile. Max 500 characters.

+
+ +
+ + +
+ +
+ +
+
+
+ + +
+
+

Email

+

Your email address for account notifications.

+
+ +
+
+
+

{{ user.email }}

+ {% if user.verified %} +

+ + + + Verified +

+ {% else %} +

+ + + + Not verified +

+ {% endif %} +
+ {% if not user.verified %} + + {% endif %} +
+
+
+ + +
+
+

Change Password

+

Update your password to keep your account secure.

+
+ +
+ + + +
+
+ + +
+ +
+ + +

Minimum 8 characters

+
+ +
+ + +
+
+ +
+ +
+
+
+ + +
+
+

Danger Zone

+

Irreversible actions that affect your account.

+
+ +
+
+
+

Delete Account

+

Permanently delete your account and all associated data.

+
+ +
+
+
+ + +{% endblock %} diff --git a/src/smarttools/web/templates/dashboard/tokens.html b/src/smarttools/web/templates/dashboard/tokens.html new file mode 100644 index 0000000..70cc76e --- /dev/null +++ b/src/smarttools/web/templates/dashboard/tokens.html @@ -0,0 +1,312 @@ +{% extends "dashboard/base.html" %} +{% set active_page = 'tokens' %} + +{% block title %}API Tokens - SmartTools Dashboard{% endblock %} + +{% block dashboard_header %} +
+
+

API Tokens

+

Manage tokens for CLI and API access

+
+ +
+{% endblock %} + +{% block dashboard_content %} + +
+
+ + + +
+

About API Tokens

+

+ API tokens are used to authenticate with the SmartTools registry from the CLI. + Use smarttools auth login to authenticate, + or set the SMARTTOOLS_TOKEN environment variable. +

+
+
+
+ +{% if tokens %} +
+ + + + + + + + + + + + {% for token in tokens %} + + + + + + + + {% endfor %} + +
+ Name + + Created + + Last Used + + Status + + Actions +
+
+
+ + + +
+
+

{{ token.name }}

+

st_...{{ token.token_suffix }}

+
+
+
+ {{ token.created_at|date_format }} + + {{ token.last_used_at|timeago if token.last_used_at else 'Never' }} + + {% if token.revoked_at %} + + Revoked + + {% else %} + + Active + + {% endif %} + + {% if not token.revoked_at %} + + {% else %} + Revoked {{ token.revoked_at|timeago }} + {% endif %} +
+
+{% else %} + +
+ + + +

No API tokens

+

+ Create an API token to authenticate with the registry from the command line. +

+ +
+{% endif %} + + + + + + + + +{% endblock %} diff --git a/src/smarttools/web/templates/dashboard/tools.html b/src/smarttools/web/templates/dashboard/tools.html new file mode 100644 index 0000000..e14ff41 --- /dev/null +++ b/src/smarttools/web/templates/dashboard/tools.html @@ -0,0 +1,234 @@ +{% extends "dashboard/base.html" %} +{% set active_page = 'tools' %} + +{% block title %}My Tools - SmartTools Dashboard{% endblock %} + +{% block dashboard_header %} +
+
+

My Tools

+

Manage your published tools

+
+ + + + + Publish New Tool + +
+{% endblock %} + +{% block dashboard_content %} +{% if tools %} +
+ + + + + + + + + + + + {% for tool in tools %} + + + + + + + + {% endfor %} + +
+ Tool + + Version + + Downloads + + Status + + Actions +
+
+
+ + + +
+
+ + {{ tool.name }} + +

{{ tool.description or 'No description' }}

+
+
+
+ v{{ tool.version }} +

{{ tool.published_at|timeago }}

+
+ {{ tool.downloads|format_number }} + + {% if tool.deprecated %} + + Deprecated + + {% else %} + + Active + + {% endif %} + +
+ View + +
+
+
+ + +{% if pending_prs %} +
+

Pending Publications

+
+
    + {% for pr in pending_prs %} +
  • +
    +

    {{ pr.owner }}/{{ pr.name }} v{{ pr.version }}

    +

    Submitted {{ pr.created_at|timeago }}

    +
    +
    + + {{ pr.status }} + + + View PR + +
    +
  • + {% endfor %} +
+
+
+{% endif %} + +{% else %} + +
+ + + +

No tools yet

+

+ You haven't published any tools. Create your first tool and share it with the community. +

+ + Learn How to Publish + +
+{% endif %} + + + + + +{% endblock %} diff --git a/src/smarttools/web/templates/errors/404.html b/src/smarttools/web/templates/errors/404.html new file mode 100644 index 0000000..dfc4068 --- /dev/null +++ b/src/smarttools/web/templates/errors/404.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found - SmartTools{% endblock %} + +{% block content %} +
+
+

404

+

Page not found

+

+ Sorry, we couldn't find the page you're looking for. It may have been moved or deleted. +

+ +
+
+{% endblock %} diff --git a/src/smarttools/web/templates/errors/500.html b/src/smarttools/web/templates/errors/500.html new file mode 100644 index 0000000..a2816d7 --- /dev/null +++ b/src/smarttools/web/templates/errors/500.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}Server Error - SmartTools{% endblock %} + +{% block content %} +
+
+

500

+

Something went wrong

+

+ We're sorry, but something went wrong on our end. Please try again later or contact support if the problem persists. +

+ +
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/about.html b/src/smarttools/web/templates/pages/about.html new file mode 100644 index 0000000..421f1c0 --- /dev/null +++ b/src/smarttools/web/templates/pages/about.html @@ -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 %} +
+ +
+

About SmartTools

+

+ An open-source platform for building and sharing AI-powered command-line tools. +

+
+ + +
+
+

Our Mission

+
+

+ 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. +

+

+ We're building a universally accessible development ecosystem that empowers + regular people to collaborate and build upon each other's progress rather than compete. +

+

+ 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. +

+
+
+
+ + +
+
+

Our Values

+
+
+
+ + + +
+

Open Source

+

+ SmartTools is MIT licensed and open source. We believe in transparency and community ownership. +

+
+ +
+
+ + + +
+

Community First

+

+ We prioritize collaboration over competition. Share tools, learn from others, build together. +

+
+ +
+
+ + + +
+

Privacy Respecting

+

+ Your data stays yours. We collect minimal analytics and never sell user information. +

+
+ +
+
+ + + +
+

Provider Agnostic

+

+ Works with any AI provider. Use Claude, GPT, local models, or any CLI-accessible AI. +

+
+
+
+
+ + +
+
+

Sustainability

+
+

+ SmartTools is committed to long-term sustainability. Revenue from optional ads + and donations supports: +

+
    +
  • Maintaining and expanding the project
  • +
  • Hosting infrastructure for the registry
  • +
  • Future hosting of AI models for users with less access to paid services
  • +
  • Building a sustainable, community-first platform
  • +
+

+ + Support the project + +

+
+
+
+ + +
+
+

Contribute

+

+ SmartTools is open source and welcomes contributions of all kinds. +

+ + + + + View on GitHub + +
+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/community.html b/src/smarttools/web/templates/pages/community.html new file mode 100644 index 0000000..d6cd8f5 --- /dev/null +++ b/src/smarttools/web/templates/pages/community.html @@ -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 %} +
+ +
+
+

Community

+

+ Collaboration over competition. Share tools, ask questions, and build together. +

+
+
+ +
+ +
+
+

Discussions

+

+ Ask for help, share feedback, and discover best practices. +

+ + Coming soon + + + + +
+ +
+

Contributor Spotlight

+

+ Celebrate creators who publish tools and help others succeed. +

+ + Coming soon + + + + +
+ +
+

Project Showcase

+

+ See how teams use SmartTools in real projects and workflows. +

+ + Coming soon + + + + +
+
+ + +
+

Get Involved

+

+ Want to contribute tools, documentation, or tutorials? We’d love to hear from you. +

+ +
+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/content.html b/src/smarttools/web/templates/pages/content.html new file mode 100644 index 0000000..16e00d2 --- /dev/null +++ b/src/smarttools/web/templates/pages/content.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} - SmartTools{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

{{ body }}

+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/docs.html b/src/smarttools/web/templates/pages/docs.html new file mode 100644 index 0000000..5064c1b --- /dev/null +++ b/src/smarttools/web/templates/pages/docs.html @@ -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 %} +
+
+
+ + + + +
+ +
+ + + +
+ + + + + + + + + +
+ + + +
+
+
+ + +{% endblock %} diff --git a/src/smarttools/web/templates/pages/donate.html b/src/smarttools/web/templates/pages/donate.html new file mode 100644 index 0000000..52f23af --- /dev/null +++ b/src/smarttools/web/templates/pages/donate.html @@ -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 %} +
+ +
+
+

Support SmartTools

+

+ Your support keeps the registry running and funds new features for the community. +

+
+
+ +
+ +
+
+

Infrastructure

+

+ Keep the registry fast, reliable, and available for everyone. +

+
+
+

Open Access

+

+ Fund future hosting of shared AI models and public demos. +

+
+
+

Community Growth

+

+ Support tutorials, examples, and recognition for contributors. +

+
+
+ + +
+

Choose a Way to Contribute

+

+ Placeholder links for donation providers. Replace with real endpoints when ready. +

+ +
+ + +
+

How funds are used

+
    +
  • Registry hosting, backups, and monitoring
  • +
  • Documentation, tutorials, and example projects
  • +
  • Ongoing development and community support
  • +
+
+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/index.html b/src/smarttools/web/templates/pages/index.html new file mode 100644 index 0000000..24c2e3f --- /dev/null +++ b/src/smarttools/web/templates/pages/index.html @@ -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 %} + +
+
+

+ Build Custom AI Commands in YAML +

+

+ Create Unix-style pipeable tools that work with any AI provider. + Provider-agnostic and composable for ultimate flexibility. +

+ + +
+
+ $ + pip install smarttools && smarttools init + +
+
+ + + +
+
+ + +
+
+

+ Why SmartTools? +

+ +
+ +
+
+ + + +
+

Easy to Use

+

+ Simple YAML configuration for quick setup. No complex programming required to get started. +

+
+ + +
+
+ + + +
+

Powerful

+

+ Leverage any AI provider, compose complex multi-step workflows with Python code integration. +

+
+ + +
+
+ + + +
+

Community

+

+ Share, discover, and contribute to a growing ecosystem of tools built by developers like you. +

+
+
+
+
+ + +
+
+
+

+ Featured Tools & Projects +

+ + View All + + + + +
+ +
+ {% 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 %} + +
+

No featured tools yet. Be the first to publish!

+ + Learn how to publish a tool + +
+ {% endfor %} +
+
+
+ + +
+
+

+ Getting Started +

+ +
+ {{ 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 + ) }} +
+
+
+ + +{% if featured_contributor %} +
+
+

+ Featured Contributor +

+ + {{ 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 + ) }} +
+
+{% endif %} + + +{% if show_ads %} +
+
+

Advertisement

+ +
+
+{% endif %} + + +{% endblock %} diff --git a/src/smarttools/web/templates/pages/login.html b/src/smarttools/web/templates/pages/login.html new file mode 100644 index 0000000..ab30ecb --- /dev/null +++ b/src/smarttools/web/templates/pages/login.html @@ -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 %} +
+
+
+ + + + + +

Sign in to SmartTools

+

+ Access your dashboard and manage your tools +

+
+ +
+ {{ form_errors(errors) }} + +
+ + {% if next_url %} + + {% endif %} + + {{ text_input( + name='email', + label='Email address', + type='email', + placeholder='you@example.com', + required=true, + value=email or '' + ) }} + + {{ text_input( + name='password', + label='Password', + type='password', + placeholder='Your password', + required=true + ) }} + +
+
+ + +
+ + Forgot password? + +
+ + {{ button_primary('Sign in', full_width=true) }} +
+
+ +

+ Don't have an account? + + Create one + +

+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/privacy.html b/src/smarttools/web/templates/pages/privacy.html new file mode 100644 index 0000000..d06cc60 --- /dev/null +++ b/src/smarttools/web/templates/pages/privacy.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} + +{% block title %}Privacy Policy - SmartTools{% endblock %} + +{% block meta_description %}SmartTools privacy policy. Learn how we collect, use, and protect your data.{% endblock %} + +{% block content %} +
+
+

Privacy Policy

+

Last updated: January 2025

+ +
+

Introduction

+

+ SmartTools ("we", "our", or "us") respects your privacy and is committed to protecting + your personal data. This privacy policy explains how we collect, use, and safeguard + your information when you use our website and services. +

+ +

Information We Collect

+ +

Account Information

+

When you create an account, we collect:

+
    +
  • Email address
  • +
  • Username (slug)
  • +
  • Display name
  • +
  • Password (stored securely hashed)
  • +
  • Optional: bio and website URL
  • +
+ +

Usage Data

+

We automatically collect certain information when you use our services:

+
    +
  • Tool download counts (anonymized)
  • +
  • Page views and navigation patterns (if analytics consent given)
  • +
  • Browser type and version
  • +
  • IP address (for security and rate limiting)
  • +
+ +

Cookies

+

We use cookies for:

+
    +
  • Essential cookies: Required for the website to function (session management, CSRF protection)
  • +
  • Analytics cookies: Help us understand how visitors use our site (optional, requires consent)
  • +
  • Advertising cookies: Used to show relevant ads (optional, requires consent)
  • +
+

+ You can manage your cookie preferences at any time through our cookie consent banner + or by contacting us. +

+ +

How We Use Your Information

+

We use your information to:

+
    +
  • Provide and maintain our services
  • +
  • Authenticate your identity and secure your account
  • +
  • Display your public profile and published tools
  • +
  • Send important service notifications
  • +
  • Improve our services based on usage patterns
  • +
  • Prevent abuse and enforce our terms of service
  • +
+ +

Information Sharing

+

We do not sell your personal information. We may share data with:

+
    +
  • Service providers: Hosting, analytics, and email services that help us operate
  • +
  • Legal requirements: When required by law or to protect our rights
  • +
  • Public information: Your username, display name, bio, and published tools are publicly visible
  • +
+ +

Data Security

+

+ We implement appropriate security measures to protect your personal information, + including: +

+
    +
  • Encryption of data in transit (HTTPS)
  • +
  • Secure password hashing (bcrypt)
  • +
  • Regular security audits
  • +
  • Limited access to personal data
  • +
+ +

Your Rights

+

You have the right to:

+
    +
  • Access: Request a copy of your personal data
  • +
  • Correction: Update inaccurate information in your account settings
  • +
  • Deletion: Delete your account and associated data
  • +
  • Portability: Export your data in a machine-readable format
  • +
  • Withdraw consent: Opt out of analytics and advertising cookies
  • +
+ +

Data Retention

+

+ We retain your account data for as long as your account is active. If you delete + your account, we will remove your personal data within 30 days, except where we + are required to retain it for legal purposes. +

+

+ Published tools may remain in the registry after account deletion for continuity, + but will be disassociated from your personal information. +

+ +

Children's Privacy

+

+ Our services are not intended for children under 13. We do not knowingly collect + personal information from children under 13. +

+ +

Changes to This Policy

+

+ We may update this privacy policy from time to time. We will notify you of any + significant changes by posting a notice on our website or sending you an email. +

+ +

Contact Us

+

+ If you have questions about this privacy policy or your personal data, please + contact us at: +

+ +
+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/publisher.html b/src/smarttools/web/templates/pages/publisher.html new file mode 100644 index 0000000..abb1810 --- /dev/null +++ b/src/smarttools/web/templates/pages/publisher.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% from "components/tool_card.html" import tool_card %} + +{% block title %}{{ publisher.display_name }} (@{{ publisher.slug }}) - SmartTools{% endblock %} + +{% block meta_description %}{{ publisher.bio or 'SmartTools publisher profile for ' ~ publisher.display_name }}{% endblock %} + +{% block content %} +
+ +
+
+
+ +
+ {{ publisher.display_name[0]|upper }} +
+ + +
+
+

{{ publisher.display_name }}

+ {% if publisher.verified %} + + + + {% endif %} +
+

@{{ publisher.slug }}

+ + {% if publisher.bio %} +

{{ publisher.bio }}

+ {% endif %} + +
+ {% if publisher.website %} + + + + + Website + + {% endif %} + + + + + {{ tools|length }} tool{{ 's' if tools|length != 1 else '' }} + + + + + + Joined {{ publisher.created_at|date_format }} + +
+
+
+
+
+ + +
+

Published Tools

+ + {% if tools %} +
+ {% for tool in tools %} + {{ tool_card( + owner=tool.owner, + name=tool.name, + description=tool.description, + category=tool.category, + downloads=tool.downloads, + version=tool.version + ) }} + {% endfor %} +
+ {% else %} +
+ + + +

No tools published yet.

+
+ {% endif %} +
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/register.html b/src/smarttools/web/templates/pages/register.html new file mode 100644 index 0000000..b500683 --- /dev/null +++ b/src/smarttools/web/templates/pages/register.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% from "components/forms.html" import text_input, button_primary, form_errors, checkbox %} + +{% block title %}Create Account - SmartTools{% endblock %} + +{% block content %} +
+
+
+ + + + + +

Create your account

+

+ Start publishing tools and join the community +

+
+ +
+ {{ form_errors(errors) }} + +
+ + + {{ text_input( + name='email', + label='Email address', + type='email', + placeholder='you@example.com', + required=true, + value=email or '', + help='We\'ll send a verification email to this address' + ) }} + + {{ text_input( + name='slug', + label='Username', + type='text', + placeholder='your-username', + required=true, + value=slug or '', + help='This will be your unique identifier (e.g., your-username/tool-name)' + ) }} + + {{ text_input( + name='display_name', + label='Display name', + type='text', + placeholder='Your Name', + required=true, + value=display_name or '' + ) }} + + {{ text_input( + name='password', + label='Password', + type='password', + placeholder='At least 8 characters', + required=true, + help='Use a mix of letters, numbers, and symbols' + ) }} + + {{ text_input( + name='password_confirm', + label='Confirm password', + type='password', + placeholder='Confirm your password', + required=true + ) }} + +
+
+ + +
+
+ + {{ button_primary('Create account', full_width=true) }} +
+
+ +

+ Already have an account? + + Sign in + +

+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/search.html b/src/smarttools/web/templates/pages/search.html new file mode 100644 index 0000000..b228f44 --- /dev/null +++ b/src/smarttools/web/templates/pages/search.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} +{% from "components/tool_card.html" import tool_card %} + +{% block title %}Search: {{ query }} - SmartTools Registry{% endblock %} + +{% block meta_description %}Search results for "{{ query }}" in the SmartTools Registry.{% endblock %} + +{% block content %} +
+ +
+
+
+
+ + + + + +
+
+
+
+ +
+ {% if query %} + +
+

+ {% if results|length == 1 %} + 1 result for "{{ query }}" + {% else %} + {{ results|length }} results for "{{ query }}" + {% endif %} +

+
+ + {% if results %} + +
+ {% for tool in results %} + {{ tool_card( + owner=tool.owner, + name=tool.name, + description=tool.description, + category=tool.category, + downloads=tool.downloads, + version=tool.version + ) }} + {% endfor %} +
+ + + {% if pagination and pagination.pages > 1 %} + + {% endif %} + + {% else %} + +
+ + + +

No results found

+

+ We couldn't find any tools matching "{{ query }}". +

+ +
+

Suggestions:

+
    +
  • Check your spelling
  • +
  • Try more general keywords
  • +
  • Use fewer keywords
  • +
+
+ + +
+ {% endif %} + + {% else %} + +
+ + + +

Search the Registry

+

+ Find tools by name, description, category, or tags. +

+ + +
+

Popular Categories:

+
+ {% for cat in popular_categories %} + + {{ cat.display_name }} + + {% endfor %} +
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/terms.html b/src/smarttools/web/templates/pages/terms.html new file mode 100644 index 0000000..9e1f0bc --- /dev/null +++ b/src/smarttools/web/templates/pages/terms.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block title %}Terms of Service - SmartTools{% endblock %} + +{% block meta_description %}SmartTools terms of service. Read our terms and conditions for using the platform.{% endblock %} + +{% block content %} +
+
+

Terms of Service

+

Last updated: January 2025

+ +
+

1. Acceptance of Terms

+

+ By accessing or using SmartTools ("the Service"), you agree to be bound by these + Terms of Service ("Terms"). If you do not agree to these Terms, you may not use + the Service. +

+ +

2. Description of Service

+

+ SmartTools is a platform for creating, publishing, and sharing AI-powered + command-line tools. The Service includes: +

+
    +
  • The SmartTools CLI application
  • +
  • The SmartTools Registry (tool hosting and discovery)
  • +
  • Documentation and tutorials
  • +
  • Community features
  • +
+ +

3. User Accounts

+

+ To publish tools or access certain features, you must create an account. You agree to: +

+
    +
  • Provide accurate and complete information
  • +
  • Maintain the security of your account credentials
  • +
  • Notify us immediately of any unauthorized access
  • +
  • Accept responsibility for all activities under your account
  • +
+ +

4. User Content

+ +

4.1 Your Content

+

+ You retain ownership of tools and content you publish ("User Content"). By publishing + to the Registry, you grant SmartTools a non-exclusive, worldwide license to host, + distribute, and display your User Content. +

+ +

4.2 Content Standards

+

You agree not to publish content that:

+
    +
  • Contains malware, viruses, or malicious code
  • +
  • Infringes on intellectual property rights
  • +
  • Is illegal, harmful, or promotes illegal activities
  • +
  • Harasses, threatens, or harms others
  • +
  • Contains spam or deceptive content
  • +
  • Violates the privacy of others
  • +
+ +

4.3 Content Removal

+

+ We reserve the right to remove any content that violates these Terms or that we + determine to be harmful to users or the community. +

+ +

5. Acceptable Use

+

You agree not to:

+
    +
  • Use the Service for any unlawful purpose
  • +
  • Attempt to gain unauthorized access to our systems
  • +
  • Interfere with or disrupt the Service
  • +
  • Scrape or collect data without permission
  • +
  • Impersonate others or misrepresent your affiliation
  • +
  • Use automated systems to access the Service excessively
  • +
+ +

6. API and CLI Usage

+

+ Access to the SmartTools API and CLI is subject to rate limits. Excessive use + that impacts service availability for others may result in temporary or permanent + restrictions. +

+ +

7. Third-Party Services

+

+ SmartTools is designed to work with various AI providers and external services. + Your use of these third-party services is subject to their respective terms and + conditions. We are not responsible for third-party services. +

+ +

8. Intellectual Property

+

+ The SmartTools software is open source and licensed under the MIT License. + The SmartTools name, logo, and branding are trademarks of SmartTools. +

+ +

9. Disclaimer of Warranties

+

+ THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED. + WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE. +

+

+ Tools published by third parties are not endorsed by SmartTools. You use third-party + tools at your own risk. +

+ +

10. Limitation of Liability

+

+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, SMARTTOOLS SHALL NOT BE LIABLE FOR ANY + INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM + YOUR USE OF THE SERVICE. +

+ +

11. Indemnification

+

+ You agree to indemnify and hold harmless SmartTools and its contributors from + any claims, damages, or expenses arising from your use of the Service or + violation of these Terms. +

+ +

12. Termination

+

+ We may terminate or suspend your access to the Service at any time, with or + without cause. Upon termination, your right to use the Service ceases immediately. +

+

+ You may delete your account at any time through your account settings. +

+ +

13. Changes to Terms

+

+ We may modify these Terms at any time. We will notify you of significant changes + by posting a notice on our website. Continued use of the Service after changes + constitutes acceptance of the new Terms. +

+ +

14. Governing Law

+

+ These Terms are governed by the laws of the jurisdiction in which SmartTools + operates, without regard to conflict of law principles. +

+ +

15. Contact

+

+ For questions about these Terms, please contact us at: +

+ +
+
+
+{% endblock %} diff --git a/src/smarttools/web/templates/pages/tool_detail.html b/src/smarttools/web/templates/pages/tool_detail.html new file mode 100644 index 0000000..e63bbaf --- /dev/null +++ b/src/smarttools/web/templates/pages/tool_detail.html @@ -0,0 +1,311 @@ +{% extends "base.html" %} +{% from "components/callouts.html" import warning, info %} + +{% block title %}{{ tool.owner }}/{{ tool.name }} - SmartTools Registry{% endblock %} + +{% block meta_description %}{{ tool.description or 'A SmartTools command-line tool' }}{% endblock %} + +{% block og_title %}{{ tool.owner }}/{{ tool.name }}{% endblock %} +{% block og_description %}{{ tool.description or 'A SmartTools command-line tool' }}{% endblock %} + +{% block content %} +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ + + +
+
+

{{ tool.name }}

+

+ by {{ tool.owner }} +

+
+
+ {% if tool.category %} + + {{ tool.category }} + + {% endif %} +
+ + {% if tool.description %} +

{{ tool.description }}

+ {% endif %} + + {% if tool.tags %} +
+ {% for tag in tool.tags.split(',') %} + {{ tag.strip() }} + {% endfor %} +
+ {% endif %} +
+ + + {% if tool.deprecated %} + {{ warning( + title='This tool is deprecated', + content=tool.deprecated_message or 'This tool is no longer maintained.' + ) }} + {% if tool.replacement %} + {{ info( + title='Recommended replacement', + content='Consider using ' ~ tool.replacement ~ ' instead.' + ) }} + {% endif %} + {% endif %} + + +
+
+ {% if tool.readme %} + {{ tool.readme_html|safe }} + {% else %} +

About {{ tool.name }}

+

{{ tool.description or 'No additional documentation available.' }}

+ +

Installation

+
smarttools install {{ tool.owner }}/{{ tool.name }}
+ +

Usage

+
{{ tool.name }} --help
+ {% endif %} +
+
+
+ + + +
+
+
+ + + + + +{% endblock %} diff --git a/src/smarttools/web/templates/pages/tools.html b/src/smarttools/web/templates/pages/tools.html new file mode 100644 index 0000000..19ec912 --- /dev/null +++ b/src/smarttools/web/templates/pages/tools.html @@ -0,0 +1,224 @@ +{% extends "base.html" %} +{% from "components/tool_card.html" import tool_card %} + +{% block title %}Browse Tools - SmartTools Registry{% endblock %} + +{% block meta_description %}Discover and install community-built AI tools. Browse by category, search by name, or explore the most popular tools.{% endblock %} + +{% block content %} +
+ +
+
+

Browse Tools

+

+ Discover community-built AI tools for your command line. +

+
+
+ +
+
+ + + + +
+ + + + +
+ + +
+ +
+

+ {% if query %} + {{ tools|length }} results for "{{ query }}" + {% elif current_category %} + {{ tools|length }} tools in {{ current_category }} + {% else %} + {{ tools|length }} tools available + {% endif %} +

+
+ + {% if tools %} +
+ {% for tool in tools %} + {{ tool_card( + owner=tool.owner, + name=tool.name, + description=tool.description, + category=tool.category, + downloads=tool.downloads, + version=tool.version + ) }} + {% endfor %} +
+ + + {% if pagination %} + + {% endif %} + + {% else %} + +
+ + + +

No tools found

+

+ {% if query %} + No tools match your search. Try different keywords. + {% else %} + Be the first to publish a tool in this category! + {% endif %} +

+ + Learn to Publish + +
+ {% endif %} +
+
+
+
+ + +{% endblock %} diff --git a/src/smarttools/web/templates/pages/tutorials.html b/src/smarttools/web/templates/pages/tutorials.html new file mode 100644 index 0000000..1770dad --- /dev/null +++ b/src/smarttools/web/templates/pages/tutorials.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} +{% from "components/tutorial_card.html" import tutorial_card %} + +{% block title %}Tutorials - SmartTools{% endblock %} + +{% block meta_description %}Learn how to use SmartTools with step-by-step tutorials. From basic setup to advanced workflows.{% endblock %} + +{% block content %} +
+ +
+
+

Tutorials

+

+ Learn SmartTools from the ground up with step-by-step guides. +

+
+
+ +
+ +
+

Getting Started

+

+ New to SmartTools? Start here with the fundamentals. +

+ +
+ {{ tutorial_card( + title="Installation & Setup", + description="Install SmartTools and configure your first AI provider.", + href=url_for('web.tutorials_path', path='installation'), + step_number=1 + ) }} + + {{ tutorial_card( + title="Your First Tool", + description="Create a simple AI-powered command in under 5 minutes.", + href=url_for('web.tutorials_path', path='first-tool'), + step_number=2 + ) }} + + {{ tutorial_card( + title="Understanding YAML Config", + description="Learn the structure of SmartTools configuration files.", + href=url_for('web.tutorials_path', path='yaml-config'), + step_number=3 + ) }} +
+
+ + +
+

Core Concepts

+

+ Understand the key concepts that power SmartTools. +

+ + +
+ + +
+

Advanced Topics

+

+ Take your tools to the next level with advanced techniques. +

+ + +
+ + + {% if video_tutorials %} +
+

Video Tutorials

+

+ Prefer watching? Check out our video guides. +

+ +
+ {% for video in video_tutorials %} + +
+ {{ video.title }} +
+
+ + + +
+
+
+
+

{{ video.title }}

+

{{ video.duration }}

+
+
+ {% endfor %} +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..5be9494 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,22 @@ +module.exports = { + content: [ + './src/smarttools/web/templates/**/*.html', + './src/smarttools/web/static/**/*.js' + ], + theme: { + extend: { + colors: { + primary: '#6366F1', + secondary: '#06B6D4', + header: '#2C3E50', + ad: '#DBEAFE', + sponsored: '#FEF3C7' + }, + fontFamily: { + sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'], + mono: ['JetBrains Mono', 'ui-monospace', 'Cascadia Code', 'monospace'] + } + } + }, + plugins: [] +}; diff --git a/tests/test_registry_integration.py b/tests/test_registry_integration.py new file mode 100644 index 0000000..8317dfc --- /dev/null +++ b/tests/test_registry_integration.py @@ -0,0 +1,618 @@ +"""Integration tests for SmartTools Registry. + +These tests verify the CLI and server work together correctly. +Run with: pytest tests/test_registry_integration.py -v + +Note: These tests require the registry server to be running locally: + python -m smarttools.registry.app +""" + +import json +import os +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest +import yaml + +# Test without requiring server for unit tests +from smarttools.config import Config, RegistryConfig, load_config, save_config +from smarttools.manifest import ( + Manifest, Dependency, ToolOverride, + load_manifest, save_manifest, create_manifest, + parse_version_constraint +) +from smarttools.resolver import ( + ToolSpec, ToolResolver, ResolvedTool, + resolve_tool, find_tool, list_installed_tools +) +from smarttools.registry_client import ( + RegistryClient, RegistryError, PaginatedResponse, + ToolInfo, DownloadResult +) + + +class TestToolSpec: + """Tests for ToolSpec parsing.""" + + def test_parse_simple_name(self): + spec = ToolSpec.parse("summarize") + assert spec.owner is None + assert spec.name == "summarize" + assert spec.version is None + + def test_parse_qualified_name(self): + spec = ToolSpec.parse("rob/summarize") + assert spec.owner == "rob" + assert spec.name == "summarize" + assert spec.version is None + + def test_parse_with_version(self): + spec = ToolSpec.parse("rob/summarize@1.0.0") + assert spec.owner == "rob" + assert spec.name == "summarize" + assert spec.version == "1.0.0" + + def test_parse_constraint_version(self): + spec = ToolSpec.parse("summarize@^1.0.0") + assert spec.owner is None + assert spec.name == "summarize" + assert spec.version == "^1.0.0" + + def test_full_name_qualified(self): + spec = ToolSpec.parse("rob/summarize") + assert spec.full_name == "rob/summarize" + + def test_full_name_unqualified(self): + spec = ToolSpec.parse("summarize") + assert spec.full_name == "summarize" + + +class TestManifest: + """Tests for project manifest handling.""" + + def test_create_manifest(self): + manifest = Manifest(name="test-project", version="1.0.0") + assert manifest.name == "test-project" + assert manifest.version == "1.0.0" + assert manifest.dependencies == [] + + def test_add_dependency(self): + manifest = Manifest() + manifest.add_dependency("rob/summarize", "^1.0.0") + assert len(manifest.dependencies) == 1 + assert manifest.dependencies[0].name == "rob/summarize" + assert manifest.dependencies[0].version == "^1.0.0" + + def test_add_duplicate_dependency_updates(self): + manifest = Manifest() + manifest.add_dependency("rob/summarize", "^1.0.0") + manifest.add_dependency("rob/summarize", "^2.0.0") + assert len(manifest.dependencies) == 1 + assert manifest.dependencies[0].version == "^2.0.0" + + def test_get_override(self): + manifest = Manifest( + overrides={"rob/summarize": ToolOverride(provider="ollama")} + ) + override = manifest.get_override("rob/summarize") + assert override is not None + assert override.provider == "ollama" + + def test_get_override_short_name(self): + manifest = Manifest( + overrides={"rob/summarize": ToolOverride(provider="ollama")} + ) + # Should match by short name + override = manifest.get_override("summarize") + assert override is not None + assert override.provider == "ollama" + + def test_to_dict_roundtrip(self): + manifest = Manifest( + name="test", + version="2.0.0", + dependencies=[Dependency("rob/summarize", "^1.0.0")], + overrides={"rob/summarize": ToolOverride(provider="claude")} + ) + data = manifest.to_dict() + restored = Manifest.from_dict(data) + assert restored.name == manifest.name + assert restored.version == manifest.version + assert len(restored.dependencies) == 1 + assert restored.dependencies[0].name == "rob/summarize" + + +class TestVersionConstraint: + """Tests for version constraint parsing.""" + + def test_exact_version(self): + result = parse_version_constraint("1.2.3") + assert result["operator"] == "=" + assert result["version"] == "1.2.3" + + def test_any_version(self): + result = parse_version_constraint("*") + assert result["operator"] == "*" + assert result["version"] is None + + def test_caret_constraint(self): + result = parse_version_constraint("^1.2.3") + assert result["operator"] == "^" + assert result["version"] == "1.2.3" + + def test_tilde_constraint(self): + result = parse_version_constraint("~1.2.3") + assert result["operator"] == "~" + assert result["version"] == "1.2.3" + + def test_gte_constraint(self): + result = parse_version_constraint(">=1.0.0") + assert result["operator"] == ">=" + assert result["version"] == "1.0.0" + + +class TestDependency: + """Tests for Dependency parsing.""" + + def test_from_dict_object(self): + dep = Dependency.from_dict({"name": "rob/tool", "version": "^1.0.0"}) + assert dep.name == "rob/tool" + assert dep.version == "^1.0.0" + + def test_from_dict_string_simple(self): + dep = Dependency.from_dict("rob/tool") + assert dep.name == "rob/tool" + assert dep.version == "*" + + def test_from_dict_string_with_version(self): + dep = Dependency.from_dict("rob/tool@^2.0.0") + assert dep.name == "rob/tool" + assert dep.version == "^2.0.0" + + def test_owner_property(self): + dep = Dependency(name="rob/summarize") + assert dep.owner == "rob" + + def test_tool_name_property(self): + dep = Dependency(name="rob/summarize") + assert dep.tool_name == "summarize" + + +class TestConfig: + """Tests for configuration handling.""" + + def test_default_config(self): + config = Config() + assert config.registry.url == "https://gitea.brrd.tech/api/v1" + assert config.auto_fetch_from_registry is True + assert config.client_id.startswith("anon_") + + def test_config_to_dict_roundtrip(self): + config = Config( + registry=RegistryConfig(token="test_token"), + auto_fetch_from_registry=False, + default_provider="claude" + ) + data = config.to_dict() + restored = Config.from_dict(data) + assert restored.registry.token == "test_token" + assert restored.auto_fetch_from_registry is False + assert restored.default_provider == "claude" + + +class TestRegistryClient: + """Tests for the registry client (mocked).""" + + def test_tool_info_from_dict(self): + data = { + "owner": "rob", + "name": "summarize", + "version": "1.0.0", + "description": "Test tool", + "downloads": 100 + } + info = ToolInfo.from_dict(data) + assert info.owner == "rob" + assert info.name == "summarize" + assert info.full_name == "rob/summarize" + assert info.downloads == 100 + + def test_paginated_response(self): + response = PaginatedResponse( + data=[{"name": "tool1"}, {"name": "tool2"}], + page=1, + per_page=20, + total=2, + total_pages=1 + ) + assert len(response.data) == 2 + assert response.total == 2 + + +class TestToolResolver: + """Tests for tool resolution.""" + + def test_deterministic_owner_order(self, tmp_path): + """Test that official namespace is preferred over others.""" + # Create fake tool directories + tools_dir = tmp_path / ".smarttools" + + # Create alice/mytool + alice_tool = tools_dir / "alice" / "mytool" + alice_tool.mkdir(parents=True) + (alice_tool / "config.yaml").write_text(yaml.dump({ + "name": "mytool", + "description": "Alice's version" + })) + + # Create official/mytool + official_tool = tools_dir / "official" / "mytool" + official_tool.mkdir(parents=True) + (official_tool / "config.yaml").write_text(yaml.dump({ + "name": "mytool", + "description": "Official version" + })) + + # Create zebra/mytool (should come after official alphabetically) + zebra_tool = tools_dir / "zebra" / "mytool" + zebra_tool.mkdir(parents=True) + (zebra_tool / "config.yaml").write_text(yaml.dump({ + "name": "mytool", + "description": "Zebra's version" + })) + + # Test resolution prefers official + resolver = ToolResolver(project_dir=tmp_path, auto_fetch=False) + result = resolver._find_in_local(ToolSpec.parse("mytool"), []) + + assert result is not None + assert result.owner == "official" + assert result.tool.description == "Official version" + + +# Integration tests (require server) +@pytest.mark.integration +class TestRegistryIntegration: + """Integration tests requiring a running registry server. + + Run with: pytest tests/test_registry_integration.py -v -m integration + """ + + @pytest.fixture + def client(self): + return RegistryClient(base_url="http://localhost:5000/api/v1") + + def test_list_tools(self, client): + """Test listing tools from registry.""" + result = client.list_tools(per_page=5) + assert isinstance(result, PaginatedResponse) + # May be empty if no tools seeded + + def test_search_tools(self, client): + """Test searching for tools.""" + result = client.search_tools("test", per_page=5) + assert isinstance(result, PaginatedResponse) + + def test_get_categories(self, client): + """Test getting categories.""" + categories = client.get_categories() + assert isinstance(categories, list) + + def test_get_index(self, client): + """Test getting full index.""" + index = client.get_index(force_refresh=True) + assert "tools" in index + assert "tool_count" in index + + +@pytest.mark.integration +class TestAuthIntegration: + """Integration tests for authentication endpoints. + + Run with: pytest tests/test_registry_integration.py -v -m integration + """ + + @pytest.fixture + def base_url(self): + return "http://localhost:5000/api/v1" + + @pytest.fixture + def session(self): + import requests + return requests.Session() + + def test_register_validation(self, session, base_url): + """Test registration validation errors.""" + # Missing fields + resp = session.post(f"{base_url}/register", json={}) + assert resp.status_code == 400 + data = resp.json() + assert data["error"]["code"] == "VALIDATION_ERROR" + + # Invalid email + resp = session.post(f"{base_url}/register", json={ + "email": "invalid", + "password": "testpass123", + "slug": "testuser", + "display_name": "Test" + }) + assert resp.status_code == 400 + assert "email" in resp.json()["error"]["message"].lower() + + # Short password + resp = session.post(f"{base_url}/register", json={ + "email": "test@example.com", + "password": "short", + "slug": "testuser", + "display_name": "Test" + }) + assert resp.status_code == 400 + assert "password" in resp.json()["error"]["message"].lower() + + # Invalid slug + resp = session.post(f"{base_url}/register", json={ + "email": "test@example.com", + "password": "testpass123", + "slug": "A", # Too short, wrong case + "display_name": "Test" + }) + assert resp.status_code == 400 + + def test_login_validation(self, session, base_url): + """Test login validation errors.""" + # Missing fields + resp = session.post(f"{base_url}/login", json={}) + assert resp.status_code == 400 + data = resp.json() + assert data["error"]["code"] == "VALIDATION_ERROR" + + # Invalid credentials + resp = session.post(f"{base_url}/login", json={ + "email": "nonexistent@example.com", + "password": "wrongpass" + }) + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "UNAUTHORIZED" + + def test_protected_endpoints_require_auth(self, session, base_url): + """Test that protected endpoints require authentication.""" + # No auth header + resp = session.get(f"{base_url}/tokens") + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "UNAUTHORIZED" + + resp = session.get(f"{base_url}/me/tools") + assert resp.status_code == 401 + + resp = session.post(f"{base_url}/tools", json={}) + assert resp.status_code == 401 + + # Invalid token + headers = {"Authorization": "Bearer invalid_token"} + resp = session.get(f"{base_url}/tokens", headers=headers) + assert resp.status_code == 401 + + def test_full_auth_flow(self, session, base_url): + """Test complete registration -> login -> token flow.""" + import uuid + + # Generate unique test user + unique = uuid.uuid4().hex[:8] + email = f"test_{unique}@example.com" + slug = f"testuser{unique}" + + # Register + resp = session.post(f"{base_url}/register", json={ + "email": email, + "password": "testpass123", + "slug": slug, + "display_name": "Test User" + }) + # May fail if user already exists from previous test run + if resp.status_code == 201: + data = resp.json()["data"] + assert data["slug"] == slug + assert data["email"] == email + + # Login + resp = session.post(f"{base_url}/login", json={ + "email": email, + "password": "testpass123" + }) + assert resp.status_code == 200 + data = resp.json()["data"] + assert "token" in data + assert data["token"].startswith("reg_") + token = data["token"] + + # Use token to access protected endpoint + headers = {"Authorization": f"Bearer {token}"} + resp = session.get(f"{base_url}/me/tools", headers=headers) + assert resp.status_code == 200 + assert "data" in resp.json() + + # List tokens + resp = session.get(f"{base_url}/tokens", headers=headers) + assert resp.status_code == 200 + tokens = resp.json()["data"] + assert len(tokens) >= 1 + + # Create another token + resp = session.post(f"{base_url}/tokens", headers=headers, json={ + "name": "CLI token" + }) + assert resp.status_code == 201 + new_token = resp.json()["data"] + assert new_token["name"] == "CLI token" + assert "token" in new_token + + +@pytest.mark.integration +class TestPublishIntegration: + """Integration tests for publishing tools. + + Run with: pytest tests/test_registry_integration.py -v -m integration + """ + + @pytest.fixture + def base_url(self): + return "http://localhost:5000/api/v1" + + @pytest.fixture + def session(self): + import requests + return requests.Session() + + @pytest.fixture + def auth_headers(self, session, base_url): + """Get auth headers for a test user.""" + import uuid + unique = uuid.uuid4().hex[:8] + email = f"pub_{unique}@example.com" + slug = f"publisher{unique}" + + # Register + session.post(f"{base_url}/register", json={ + "email": email, + "password": "testpass123", + "slug": slug, + "display_name": "Publisher" + }) + + # Login + resp = session.post(f"{base_url}/login", json={ + "email": email, + "password": "testpass123" + }) + token = resp.json()["data"]["token"] + return {"Authorization": f"Bearer {token}"}, slug + + def test_publish_validation(self, session, base_url, auth_headers): + """Test publish validation errors.""" + headers, slug = auth_headers + + # Empty config + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": "", + "readme": "" + }) + assert resp.status_code == 400 + + # Invalid YAML + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": "{{invalid yaml", + "readme": "" + }) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "VALIDATION_ERROR" + + # Missing required fields + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": "description: no name or version", + "readme": "" + }) + assert resp.status_code == 400 + + # Invalid version (not semver) + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": "name: test\nversion: bad", + "readme": "" + }) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "INVALID_VERSION" + + def test_publish_dry_run(self, session, base_url, auth_headers): + """Test publish dry run mode.""" + headers, slug = auth_headers + import uuid + tool_name = f"testtool{uuid.uuid4().hex[:8]}" + + config = f"""name: {tool_name} +version: 1.0.0 +description: A test tool +category: text-processing +""" + + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": config, + "readme": "# Test Tool", + "dry_run": True + }) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["status"] == "validated" + assert data["name"] == tool_name + assert data["owner"] == slug + + def test_publish_and_retrieve(self, session, base_url, auth_headers): + """Test publishing a tool and retrieving it.""" + headers, slug = auth_headers + import uuid + tool_name = f"testtool{uuid.uuid4().hex[:8]}" + + config = f"""name: {tool_name} +version: 1.0.0 +description: A test tool for integration testing +category: text-processing +tags: + - test + - integration +""" + + # Publish + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": config, + "readme": "# Test Tool\n\nThis is a test." + }) + assert resp.status_code == 201 + data = resp.json()["data"] + assert data["owner"] == slug + assert data["name"] == tool_name + assert data["version"] == "1.0.0" + + # Retrieve + resp = session.get(f"{base_url}/tools/{slug}/{tool_name}") + assert resp.status_code == 200 + tool = resp.json()["data"] + assert tool["name"] == tool_name + assert tool["description"] == "A test tool for integration testing" + assert "test" in tool["tags"] + + # Check my-tools includes it + resp = session.get(f"{base_url}/me/tools", headers=headers) + assert resp.status_code == 200 + my_tools = resp.json()["data"] + assert any(t["name"] == tool_name for t in my_tools) + + def test_publish_duplicate_version(self, session, base_url, auth_headers): + """Test that publishing duplicate version fails.""" + headers, slug = auth_headers + import uuid + tool_name = f"testtool{uuid.uuid4().hex[:8]}" + + config = f"""name: {tool_name} +version: 1.0.0 +description: First version +""" + + # First publish + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": config, + "readme": "" + }) + assert resp.status_code == 201 + + # Duplicate publish + resp = session.post(f"{base_url}/tools", headers=headers, json={ + "config": config, + "readme": "" + }) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "VERSION_EXISTS" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..709cb1d --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,353 @@ +# SmartTools Wiki + +Welcome to SmartTools - a lightweight personal tool builder for AI-powered CLI commands. + +SmartTools lets you turn any AI model into a Unix-style pipe command. Build your own AI tools once, use them forever - no coding required. + +## What is SmartTools? + +Think of SmartTools as a universal adapter between you and AI. Instead of opening a chat window and copy-pasting text, you create reusable command-line tools: + +```bash +# Before: Copy text, open browser, paste into ChatGPT, wait, copy result +# After: +cat article.txt | summarize + +# Before: Manually ask AI to fix grammar +# After: +echo "teh cat sat on teh mat" | fix-grammar +# Output: The cat sat on the mat. + +# Chain tools together like any Unix command +cat report.txt | summarize | translate --lang Spanish +``` + +### Core Philosophy + +SmartTools follows the **Unix philosophy**: + +1. **Each tool does one thing well** - `summarize` summarizes, `translate` translates +2. **Tools are composable** - Chain them with pipes: `cat file | tool1 | tool2` +3. **Text is the interface** - Works with `grep`, `awk`, `sed`, and every other Unix tool +4. **You own everything** - Tools are simple YAML files, no vendor lock-in + +--- + +## Quick Start Options + +### Option 1: Try It Now (60 seconds, no install) + +```bash +# Pull the pre-built container +docker pull gitea.brrd.tech/rob/smarttools:latest + +# Run it +docker run -it --rm gitea.brrd.tech/rob/smarttools bash + +# Inside container - install OpenCode (includes FREE AI models) +smarttools providers install # Select option 4, then 'y' +source ~/.bashrc + +# Try a tool! Uses free Big Pickle model +cat README.md | eli5 +``` + +### Option 2: Native 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 +``` + +--- + +## Understanding Providers + +SmartTools doesn't include any AI - it connects to AI **providers**. A provider is any CLI tool that reads text from stdin and outputs a response. + +### Available Providers + +| Provider | Models | Cost | Best For | +|----------|--------|------|----------| +| **OpenCode** | 75+ models | 4 FREE, others paid | Best starting point | +| **Claude** | Haiku, Sonnet, Opus | Pay-per-use | High quality | +| **Codex** | GPT models | Pay-per-use | Reliable | +| **Gemini** | Flash, Pro | Free tier | Large documents (1M tokens) | +| **Ollama** | Llama, Mistral, etc. | FREE (local) | Privacy, offline use | + +### Free Models in OpenCode + +When you install OpenCode, these models are available immediately with no sign-up: + +- **Big Pickle** - High quality, used by `eli5` and other tools by default +- **GLM-4.7** - Chinese AI lab model +- **Grok Code Fast 1** - Fast but less accurate +- **MiniMax M2.1** - Alternative free option + +### Installing a Provider + +```bash +smarttools providers install +``` + +This interactive guide shows all options and runs the installation for you. After installing, run the provider once to authenticate (opens browser). + +--- + +## The 27 Built-in Tools + +SmartTools comes with ready-to-use tools organized by category: + +### Text Processing + +| Tool | What it does | Example | +|------|--------------|---------| +| `summarize` | Condense documents | `cat article.txt \| summarize` | +| `translate` | Translate to any language | `echo "Hello" \| translate --lang Spanish` | +| `fix-grammar` | Fix spelling and grammar | `cat draft.txt \| fix-grammar` | +| `simplify` | Rewrite for clarity | `cat legal.txt \| simplify --level "5th grade"` | +| `tone-shift` | Change writing 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 | What it does | 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 | `git 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 messages | `git diff --staged \| commit-msg` | + +### Data Tools + +| Tool | What it does | 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` | +| `csv-insights` | Analyze CSV data | `cat sales.csv \| csv-insights --question "trends?"` | + +### Multi-Step Tools + +These tools combine AI prompts with code for validation: + +| Tool | Pattern | What it does | +|------|---------|--------------| +| `log-errors` | Code→AI | Extract and explain errors from huge logs | +| `diff-focus` | Code→AI | Extract only added lines, then review | +| `changelog` | Code→AI→Code | Parse git log, generate changelog, format it | +| `code-validate` | AI→Code | Generate code, validate syntax | + +--- + +## Creating Your Own Tools + +### Using the TUI + +The easiest way to create tools: + +```bash +smarttools ui +``` + +Navigate with arrow keys, create tools visually, edit prompts, test with mock provider. + +### Tool Anatomy (YAML) + +Every tool is a YAML file in `~/.smarttools//config.yaml`: + +```yaml +name: my-tool +description: What this tool does +arguments: + - flag: --style + variable: style + default: "casual" +steps: + - type: prompt + prompt: | + Rewrite this in a {style} style: + + {input} + provider: opencode-pickle + output_var: response +output: "{response}" +``` + +### Variables + +| Variable | Description | +|----------|-------------| +| `{input}` | The piped/file input | +| `{argname}` | Value of `--argname` flag | +| `{previous_output}` | Output from previous step | + +### Multi-Step Example + +Chain AI with code for validated output: + +```yaml +name: safe-json +description: Extract data as validated JSON +steps: + # Step 1: AI extracts data + - type: prompt + prompt: "Extract {fields} as JSON: {input}" + provider: opencode-pickle + output_var: raw_json + + # Step 2: Python validates + - type: code + code: | + import json + try: + parsed = json.loads(raw_json) + result = json.dumps(parsed, indent=2) + except: + result = '{"error": "Invalid JSON"}' + output_var: result + +output: "{result}" +``` + +--- + +## Provider Selection Strategy + +### Choosing the Right Provider + +| Use Case | Recommended Provider | +|----------|---------------------| +| Quick tasks, free | `opencode-pickle` | +| Daily use, cheap | `opencode-deepseek` | +| High quality | `claude-haiku` or `claude-opus` | +| Large documents | `gemini` (1M token context) | +| Complex reasoning | `opencode-reasoner` | +| Offline/private | `ollama` | + +### Override at Runtime + +Any tool can use a different provider for a single run: + +```bash +# Tool defaults to opencode-pickle, but use claude this time +cat important.txt | summarize --provider claude-opus +``` + +### Change Tool Default + +Edit the tool's config or use the TUI: + +```bash +smarttools ui +# Select tool → Edit → Change provider in step +``` + +--- + +## Troubleshooting + +### "Command 'X' not found" + +The AI CLI isn't installed: + +```bash +smarttools providers install +# Select the provider and follow instructions +``` + +### "Model 'X' is not available" + +The model isn't connected in your provider: + +```bash +# For OpenCode models: +opencode +# Connect the required provider in the menu + +# Or use a different model: +cat file.txt | tool --provider opencode-pickle +``` + +### Tool returns empty output + +1. Check the provider is configured: `smarttools providers check` +2. Test the provider directly: `smarttools providers test opencode-pickle` +3. Try a different provider: `--provider opencode-pickle` + +### Provider is slow + +- Use `gemini-flash` instead of `gemini` +- Use `claude-haiku` instead of `claude-opus` +- Use `opencode-deepseek` for best speed/quality ratio + +--- + +## Advanced Usage + +### Shell Integration + +Add to `~/.bashrc`: + +```bash +export PATH="$HOME/.local/bin:$PATH" + +# Aliases +alias wtf='explain-error' +alias fixme='fix-grammar' +alias gc='git diff --staged | commit-msg' +``` + +### Git Hooks + +Auto-generate commit messages: + +```bash +# .git/hooks/prepare-commit-msg +#!/bin/bash +git diff --cached | commit-msg > "$1" +``` + +### Vim Integration + +```vim +" Fix grammar in selection +vnoremap fg :!fix-grammar + +" Explain selected code +vnoremap ec :!explain-code +``` + +### Pipeline Examples + +```bash +# Review pipeline: focus on changes, review, summarize +git diff | diff-focus | review-code | tldr + +# Translation pipeline: summarize then translate +cat article.txt | summarize | translate --lang Japanese + +# Code review: only check large changes +git diff --stat | awk '$3 > 100 {print $1}' | xargs cat | review-code +``` + +--- + +## Links + +- **Repository**: https://gitea.brrd.tech/rob/SmartTools +- **Docker Image**: `gitea.brrd.tech/rob/smarttools:latest` +- **Issues**: https://gitea.brrd.tech/rob/SmartTools/issues + +--- + +*SmartTools is MIT licensed. Use it, modify it, share it.*