Add SmartTools Registry and Web UI (Phases 1-7)

Registry Features (Phases 1-6):
- Tool manifest format and validation
- Registry client for API communication
- Resolver integration for tool installation
- CLI commands: install, search, browse, publish, auth
- TUI browse interface with urwid
- Smart features: auto-categorization, similarity suggestions

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-31 19:03:41 -04:00
parent 284e9981bc
commit 34428f0e72
61 changed files with 14492 additions and 6 deletions

1896
docs/REGISTRY.md Normal file

File diff suppressed because it is too large Load Diff

1315
docs/WEB_UI.md Normal file

File diff suppressed because it is too large Load Diff

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"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"
}
}

View File

@ -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)",
]

108
scripts/sync_to_db.py Normal file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Sync registry tools into the database.
Usage: python scripts/sync_to_db.py /path/to/registry/repo
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Dict, Any
import yaml
# Allow running from repo root
sys.path.append(str(Path(__file__).resolve().parents[1] / "src"))
from smarttools.registry.db import connect_db, query_one
from smarttools.registry.sync import ensure_publisher, normalize_tags
def load_yaml(path: Path) -> Dict[str, Any]:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def sync_repo(repo_root: Path) -> int:
tools_root = repo_root / "tools"
if not tools_root.exists():
print(f"Missing tools directory at {tools_root}")
return 1
conn = connect_db()
synced = 0
skipped = 0
try:
for config_path in tools_root.glob("*/*/config.yaml"):
owner = config_path.parent.parent.name
name = config_path.parent.name
config_text = config_path.read_text(encoding="utf-8")
data = load_yaml(config_path)
version = (data.get("version") or "").strip()
if not version:
skipped += 1
continue
existing = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
if existing:
skipped += 1
continue
readme_path = config_path.parent / "README.md"
readme_text = readme_path.read_text(encoding="utf-8") if readme_path.exists() else ""
publisher_id = ensure_publisher(conn, owner)
tags = normalize_tags(data.get("tags"))
conn.execute(
"""
INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
name,
version,
data.get("description"),
data.get("category"),
tags,
config_text,
readme_text,
publisher_id,
int(bool(data.get("deprecated"))),
data.get("deprecated_message"),
data.get("replacement"),
int((data.get("registry") or {}).get("downloads", 0) or 0),
(data.get("registry") or {}).get("published_at"),
],
)
synced += 1
conn.commit()
finally:
conn.close()
print(f"Synced: {synced}")
print(f"Skipped (existing/invalid): {skipped}")
return 0
def main() -> int:
if len(sys.argv) < 2:
print("Usage: python scripts/sync_to_db.py /path/to/registry/repo")
return 1
repo_root = Path(sys.argv[1])
return sync_repo(repo_root)
if __name__ == "__main__":
raise SystemExit(main())

114
scripts/validate_tool.py Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Validate a registry tool submission.
Usage: python scripts/validate_tool.py path/to/tool
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import List
import yaml
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+.+)?$")
REQUIRED_README_SECTIONS = ["## Usage", "## Examples"]
def find_repo_root(start: Path) -> Path | None:
current = start.resolve()
while current != current.parent:
if (current / "categories" / "categories.yaml").exists():
return current
current = current.parent
if (current / "categories" / "categories.yaml").exists():
return current
return None
def load_categories(repo_root: Path) -> List[str]:
categories_path = repo_root / "categories" / "categories.yaml"
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
categories = data.get("categories", [])
return [c.get("name") for c in categories if c.get("name")]
def validate_tool(tool_path: Path) -> List[str]:
errors: List[str] = []
if tool_path.is_dir():
config_path = tool_path / "config.yaml"
readme_path = tool_path / "README.md"
else:
config_path = tool_path
readme_path = tool_path.parent / "README.md"
if not config_path.exists():
return [f"Missing config.yaml at {config_path}"]
try:
config_text = config_path.read_text(encoding="utf-8")
data = yaml.safe_load(config_text) or {}
except Exception as exc:
return [f"Invalid YAML in config.yaml: {exc}"]
name = (data.get("name") or "").strip()
version = (data.get("version") or "").strip()
description = (data.get("description") or "").strip()
category = (data.get("category") or "").strip()
if not name:
errors.append("Missing required field: name")
elif not TOOL_NAME_RE.match(name):
errors.append("Tool name must match ^[A-Za-z0-9-]{1,64}$")
if not version:
errors.append("Missing required field: version")
elif not SEMVER_RE.match(version):
errors.append("Version must be valid semver (MAJOR.MINOR.PATCH)")
if not description:
errors.append("Missing required field: description")
repo_root = find_repo_root(tool_path)
if repo_root:
categories = load_categories(repo_root)
if category and category not in categories:
errors.append(f"Unknown category '{category}' (not in categories.yaml)")
else:
if category:
errors.append("Cannot validate category (categories.yaml not found)")
if not readme_path.exists():
errors.append(f"Missing README.md at {readme_path}")
else:
readme_text = readme_path.read_text(encoding="utf-8")
for section in REQUIRED_README_SECTIONS:
if section not in readme_text:
errors.append(f"README.md missing section: {section}")
return errors
def main() -> int:
if len(sys.argv) < 2:
print("Usage: python scripts/validate_tool.py path/to/tool")
return 1
tool_path = Path(sys.argv[1])
errors = validate_tool(tool_path)
if errors:
print("Validation failed:")
for err in errors:
print(f"- {err}")
return 1
print("Validation passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -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 <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 <query>", file=sys.stderr)
return 1
else:
# Default: show registry help
print("Registry commands:")
print(" search <query> Search for tools")
print(" install <tool> Install a tool")
print(" uninstall <tool> Uninstall a tool")
print(" info <tool> 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 <owner/name>")
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 <owner/name>")
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 <token> Set registry authentication token")
print(" set <key> <value> 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

135
src/smarttools/config.py Normal file
View File

@ -0,0 +1,135 @@
"""Global configuration handling for SmartTools.
Manages ~/.smarttools/config.yaml with registry settings, tokens, and preferences.
"""
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
# Default configuration directory
CONFIG_DIR = Path.home() / ".smarttools"
CONFIG_FILE = CONFIG_DIR / "config.yaml"
# Default registry URL (canonical base path)
DEFAULT_REGISTRY_URL = "https://gitea.brrd.tech/api/v1"
@dataclass
class RegistryConfig:
"""Registry-related configuration."""
url: str = DEFAULT_REGISTRY_URL
token: Optional[str] = None
def to_dict(self) -> dict:
d = {"url": self.url}
if self.token:
d["token"] = self.token
return d
@classmethod
def from_dict(cls, data: dict) -> "RegistryConfig":
return cls(
url=data.get("url", DEFAULT_REGISTRY_URL),
token=data.get("token")
)
@dataclass
class Config:
"""Global SmartTools configuration."""
registry: RegistryConfig = field(default_factory=RegistryConfig)
client_id: str = ""
auto_fetch_from_registry: bool = True
default_provider: Optional[str] = None
def __post_init__(self):
# Generate client_id if not set
if not self.client_id:
self.client_id = f"anon_{uuid.uuid4().hex[:16]}"
def to_dict(self) -> dict:
d = {
"registry": self.registry.to_dict(),
"client_id": self.client_id,
"auto_fetch_from_registry": self.auto_fetch_from_registry,
}
if self.default_provider:
d["default_provider"] = self.default_provider
return d
@classmethod
def from_dict(cls, data: dict) -> "Config":
registry_data = data.get("registry", {})
return cls(
registry=RegistryConfig.from_dict(registry_data),
client_id=data.get("client_id", ""),
auto_fetch_from_registry=data.get("auto_fetch_from_registry", True),
default_provider=data.get("default_provider")
)
def get_config_dir() -> Path:
"""Get the config directory, creating it if needed."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
return CONFIG_DIR
def load_config() -> Config:
"""Load configuration from disk, creating defaults if needed."""
config_path = get_config_dir() / "config.yaml"
if not config_path.exists():
# Create default config
config = Config()
save_config(config)
return config
try:
data = yaml.safe_load(config_path.read_text()) or {}
return Config.from_dict(data)
except Exception as e:
print(f"Warning: Error loading config, using defaults: {e}")
return Config()
def save_config(config: Config) -> Path:
"""Save configuration to disk."""
config_path = get_config_dir() / "config.yaml"
config_path.write_text(yaml.dump(config.to_dict(), default_flow_style=False, sort_keys=False))
return config_path
def get_registry_url() -> str:
"""Get the configured registry URL."""
config = load_config()
return config.registry.url
def get_registry_token() -> Optional[str]:
"""Get the configured registry token."""
config = load_config()
return config.registry.token
def set_registry_token(token: str) -> None:
"""Set and save the registry token."""
config = load_config()
config.registry.token = token
save_config(config)
def get_client_id() -> str:
"""Get the client ID for anonymous usage tracking."""
config = load_config()
return config.client_id
def is_auto_fetch_enabled() -> bool:
"""Check if auto-fetch from registry is enabled."""
config = load_config()
return config.auto_fetch_from_registry

276
src/smarttools/manifest.py Normal file
View File

@ -0,0 +1,276 @@
"""Project manifest (smarttools.yaml) handling.
Manages project-level tool dependencies and overrides.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List, Dict
import yaml
MANIFEST_FILENAME = "smarttools.yaml"
@dataclass
class Dependency:
"""A tool dependency declaration."""
name: str # owner/name format (e.g., "rob/summarize")
version: str = "*" # Version constraint (e.g., ">=1.0.0", "^1.2.0")
@property
def owner(self) -> Optional[str]:
"""Extract owner from name if present."""
if "/" in self.name:
return self.name.split("/")[0]
return None
@property
def tool_name(self) -> str:
"""Extract tool name without owner."""
if "/" in self.name:
return self.name.split("/")[1]
return self.name
def to_dict(self) -> dict:
return {
"name": self.name,
"version": self.version
}
@classmethod
def from_dict(cls, data: dict) -> "Dependency":
if isinstance(data, str):
# Simple string format: "rob/summarize" or "rob/summarize@^1.0.0"
if "@" in data:
name, version = data.rsplit("@", 1)
return cls(name=name, version=version)
return cls(name=data)
return cls(
name=data["name"],
version=data.get("version", "*")
)
@dataclass
class ToolOverride:
"""Runtime overrides for a tool."""
provider: Optional[str] = None
# Future: other overrides like timeout, retries, etc.
def to_dict(self) -> dict:
d = {}
if self.provider:
d["provider"] = self.provider
return d
@classmethod
def from_dict(cls, data: dict) -> "ToolOverride":
return cls(
provider=data.get("provider")
)
@dataclass
class Manifest:
"""Project manifest (smarttools.yaml)."""
name: str = "my-project"
version: str = "1.0.0"
dependencies: List[Dependency] = field(default_factory=list)
overrides: Dict[str, ToolOverride] = field(default_factory=dict)
def to_dict(self) -> dict:
d = {
"name": self.name,
"version": self.version,
}
if self.dependencies:
d["dependencies"] = [dep.to_dict() for dep in self.dependencies]
if self.overrides:
d["overrides"] = {
name: override.to_dict()
for name, override in self.overrides.items()
}
return d
@classmethod
def from_dict(cls, data: dict) -> "Manifest":
dependencies = []
for dep in data.get("dependencies", []):
dependencies.append(Dependency.from_dict(dep))
overrides = {}
for name, override_data in data.get("overrides", {}).items():
overrides[name] = ToolOverride.from_dict(override_data)
return cls(
name=data.get("name", "my-project"),
version=data.get("version", "1.0.0"),
dependencies=dependencies,
overrides=overrides
)
def get_override(self, tool_name: str) -> Optional[ToolOverride]:
"""Get override for a tool by name (checks both full and short names)."""
# Try exact match first
if tool_name in self.overrides:
return self.overrides[tool_name]
# Try matching just the tool name part (without owner)
short_name = tool_name.split("/")[-1] if "/" in tool_name else tool_name
for override_name, override in self.overrides.items():
override_short = override_name.split("/")[-1] if "/" in override_name else override_name
if override_short == short_name:
return override
return None
def add_dependency(self, name: str, version: str = "*") -> None:
"""Add or update a dependency."""
# Check if already exists
for dep in self.dependencies:
if dep.name == name:
dep.version = version
return
self.dependencies.append(Dependency(name=name, version=version))
def find_manifest(start_dir: Optional[Path] = None) -> Optional[Path]:
"""
Find smarttools.yaml by searching up from start_dir.
Args:
start_dir: Directory to start searching from (default: cwd)
Returns:
Path to manifest file, or None if not found
"""
if start_dir is None:
start_dir = Path.cwd()
current = start_dir.resolve()
while current != current.parent:
manifest_path = current / MANIFEST_FILENAME
if manifest_path.exists():
return manifest_path
current = current.parent
# Check root
manifest_path = current / MANIFEST_FILENAME
if manifest_path.exists():
return manifest_path
return None
def load_manifest(path: Optional[Path] = None) -> Optional[Manifest]:
"""
Load a project manifest.
Args:
path: Path to manifest file, or None to search
Returns:
Manifest object, or None if not found
"""
if path is None:
path = find_manifest()
if path is None or not path.exists():
return None
try:
data = yaml.safe_load(path.read_text()) or {}
return Manifest.from_dict(data)
except Exception as e:
print(f"Warning: Error loading manifest: {e}")
return None
def save_manifest(manifest: Manifest, path: Optional[Path] = None) -> Path:
"""
Save a manifest to disk.
Args:
manifest: Manifest to save
path: Path to save to (default: ./smarttools.yaml)
Returns:
Path where manifest was saved
"""
if path is None:
path = Path.cwd() / MANIFEST_FILENAME
path.write_text(yaml.dump(manifest.to_dict(), default_flow_style=False, sort_keys=False))
return path
def create_manifest(
name: str = "my-project",
version: str = "1.0.0",
path: Optional[Path] = None
) -> Manifest:
"""
Create a new manifest.
Args:
name: Project name
version: Project version
path: Path to save to (optional)
Returns:
Created Manifest object
"""
manifest = Manifest(name=name, version=version)
if path is not None:
save_manifest(manifest, path)
return manifest
def parse_version_constraint(constraint: str) -> dict:
"""
Parse a version constraint string.
Args:
constraint: Version constraint (e.g., ">=1.0.0", "^1.2.3", "~1.2.0")
Returns:
Dict with operator and version parts
"""
constraint = constraint.strip()
# Exact version
if re.match(r'^\d+\.\d+\.\d+', constraint):
return {"operator": "=", "version": constraint}
# Any version
if constraint == "*" or constraint == "latest":
return {"operator": "*", "version": None}
# Range operators
patterns = [
(r'^>=(.+)$', ">="),
(r'^<=(.+)$', "<="),
(r'^>(.+)$', ">"),
(r'^<(.+)$', "<"),
(r'^\^(.+)$', "^"), # Compatible (same major)
(r'^~(.+)$', "~"), # Approximately (same minor)
]
for pattern, operator in patterns:
match = re.match(pattern, constraint)
if match:
return {"operator": operator, "version": match.group(1)}
# Default to exact match
return {"operator": "=", "version": constraint}

View File

@ -0,0 +1,3 @@
"""Registry API server package."""
__all__ = ["app"]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
"""Category suggestion helpers for registry tools."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Tuple
import yaml
def load_categories(categories_path: Path) -> List[Dict]:
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
return data.get("categories", [])
def suggest_categories(
name: str,
description: str,
tags: List[str],
categories_path: Path,
) -> List[Tuple[str, float]]:
"""Suggest categories ranked by confidence.
Uses keyword matching against name/description/tags.
Returns a list of (category_name, confidence).
"""
categories = load_categories(categories_path)
text = f"{name} {description} {' '.join(tags)}".lower()
suggestions: List[Tuple[str, float]] = []
for cat in categories:
cat_name = cat.get("name")
keywords = [str(k).lower() for k in cat.get("keywords", []) if k]
if not cat_name or not keywords:
continue
hits = sum(1 for k in keywords if k in text)
confidence = hits / max(len(keywords), 1)
if hits:
suggestions.append((cat_name, round(confidence, 3)))
suggestions.sort(key=lambda item: item[1], reverse=True)
return suggestions

View File

@ -0,0 +1,256 @@
"""SQLite storage and schema setup for the registry API."""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from typing import Iterable
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS publishers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
bio TEXT,
website TEXT,
verified BOOLEAN DEFAULT FALSE,
locked_until TIMESTAMP,
failed_login_attempts INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
token_hash TEXT NOT NULL,
name TEXT NOT NULL,
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT,
category TEXT,
tags TEXT,
config_yaml TEXT NOT NULL,
readme TEXT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
deprecated BOOLEAN DEFAULT FALSE,
deprecated_message TEXT,
replacement TEXT,
downloads INTEGER DEFAULT 0,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);
CREATE TABLE IF NOT EXISTS download_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id),
client_id TEXT NOT NULL,
downloaded_at DATE NOT NULL,
UNIQUE(tool_id, client_id, downloaded_at)
);
CREATE VIRTUAL TABLE IF NOT EXISTS tools_fts USING fts5(
name, description, tags, readme,
content='tools',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS tools_ai AFTER INSERT ON tools BEGIN
INSERT INTO tools_fts(rowid, name, description, tags, readme)
VALUES (new.id, new.name, new.description, new.tags, new.readme);
END;
CREATE TRIGGER IF NOT EXISTS tools_ad AFTER DELETE ON tools BEGIN
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
END;
CREATE TRIGGER IF NOT EXISTS tools_au AFTER UPDATE ON tools BEGIN
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
INSERT INTO tools_fts(rowid, name, description, tags, readme)
VALUES (new.id, new.name, new.description, new.tags, new.readme);
END;
CREATE TABLE IF NOT EXISTS pending_prs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
owner TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
pr_number INTEGER NOT NULL,
pr_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);
CREATE TABLE IF NOT EXISTS webhook_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
delivery_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS web_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
publisher_id INTEGER REFERENCES publishers(id),
data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name);
CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category);
CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC);
CREATE INDEX IF NOT EXISTS idx_tools_owner_name_version ON tools(owner, name, version);
CREATE INDEX IF NOT EXISTS idx_tools_sort_stable ON tools(downloads DESC, published_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_publishers_slug ON publishers(slug);
CREATE INDEX IF NOT EXISTS idx_publishers_email ON publishers(email);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_publisher ON api_tokens(publisher_id);
CREATE INDEX IF NOT EXISTS idx_web_sessions_id ON web_sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at);
-- Web UI tables (Phase 7)
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMP,
created_by INTEGER REFERENCES publishers(id),
updated_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS featured_tools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id),
placement TEXT NOT NULL DEFAULT 'homepage',
priority INTEGER DEFAULT 0,
start_at TIMESTAMP,
end_at TIMESTAMP,
status TEXT DEFAULT 'active',
created_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS featured_contributors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
bio_override TEXT,
placement TEXT NOT NULL DEFAULT 'homepage',
start_at TIMESTAMP,
end_at TIMESTAMP,
status TEXT DEFAULT 'active',
created_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id),
reporter_id INTEGER REFERENCES publishers(id),
reporter_ip TEXT,
reason TEXT NOT NULL,
details TEXT,
status TEXT DEFAULT 'pending',
resolved_by INTEGER REFERENCES publishers(id),
resolved_at TIMESTAMP,
resolution_note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT,
publisher_id INTEGER REFERENCES publishers(id),
analytics_consent BOOLEAN DEFAULT FALSE,
ads_consent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id),
UNIQUE(publisher_id)
);
CREATE TABLE IF NOT EXISTS content_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
content_type TEXT NOT NULL DEFAULT 'doc',
body TEXT,
published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMP,
created_by INTEGER REFERENCES publishers(id),
updated_by INTEGER REFERENCES publishers(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_announcements_published ON announcements(published, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_featured_tools_placement ON featured_tools(placement, status, priority DESC);
CREATE INDEX IF NOT EXISTS idx_featured_contributors_placement ON featured_contributors(placement, status);
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_content_pages_type ON content_pages(content_type, published);
"""
def get_db_path() -> Path:
default_path = Path.home() / ".smarttools" / "registry" / "registry.db"
return Path(os.environ.get("SMARTTOOLS_REGISTRY_DB", default_path))
def ensure_db_directory(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def connect_db(path: Path | None = None) -> sqlite3.Connection:
db_path = path or get_db_path()
ensure_db_directory(db_path)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
return conn
def init_db(conn: sqlite3.Connection) -> None:
conn.executescript(SCHEMA_SQL)
conn.commit()
def query_one(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
cur = conn.execute(sql, params or [])
return cur.fetchone()
def query_all(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
cur = conn.execute(sql, params or [])
return cur.fetchall()
def execute(conn: sqlite3.Connection, sql: str, params: Iterable | None = None) -> None:
conn.execute(sql, params or [])
conn.commit()

View File

@ -0,0 +1,29 @@
"""In-memory rate limiting for the registry API."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Dict, Tuple
@dataclass
class RateLimitState:
reset_at: float
count: int
class RateLimiter:
def __init__(self) -> None:
self._state: Dict[Tuple[str, str], RateLimitState] = {}
def check(self, scope_key: str, limit: int, window_seconds: int) -> Tuple[bool, RateLimitState]:
now = time.time()
key = (scope_key, str(window_seconds))
state = self._state.get(key)
if not state or now >= state.reset_at:
state = RateLimitState(reset_at=now + window_seconds, count=0)
self._state[key] = state
state.count += 1
allowed = state.count <= limit
return allowed, state

View File

@ -0,0 +1,77 @@
"""Similarity detection for registry tools."""
from __future__ import annotations
from difflib import SequenceMatcher
from typing import Dict, List, Tuple
def _tokenize(text: str) -> List[str]:
return [t for t in re_split_nonword(text.lower()) if t]
def re_split_nonword(text: str) -> List[str]:
token = ""
tokens = []
for ch in text:
if ch.isalnum():
token += ch
else:
if token:
tokens.append(token)
token = ""
if token:
tokens.append(token)
return tokens
def jaccard(a: List[str], b: List[str]) -> float:
set_a = set(a)
set_b = set(b)
if not set_a and not set_b:
return 0.0
return len(set_a & set_b) / max(len(set_a | set_b), 1)
def name_similarity(name_a: str, name_b: str) -> float:
return SequenceMatcher(None, name_a.lower(), name_b.lower()).ratio()
def description_similarity(desc_a: str, desc_b: str) -> float:
return jaccard(_tokenize(desc_a), _tokenize(desc_b))
def tags_similarity(tags_a: List[str], tags_b: List[str]) -> float:
return jaccard([t.lower() for t in tags_a], [t.lower() for t in tags_b])
def score_similarity(
candidate: Dict,
name: str,
description: str,
tags: List[str],
category: str | None,
) -> float:
name_score = name_similarity(name, candidate.get("name", ""))
desc_score = description_similarity(description, candidate.get("description", ""))
tags_score = tags_similarity(tags, candidate.get("tags", []))
category_bonus = 0.1 if category and candidate.get("category") == category else 0.0
score = (0.5 * name_score) + (0.3 * desc_score) + (0.2 * tags_score) + category_bonus
return min(score, 1.0)
def find_similar_tools(
tools: List[Dict],
name: str,
description: str,
tags: List[str],
category: str | None,
threshold: float = 0.6,
) -> List[Tuple[Dict, float]]:
results = []
for tool in tools:
score = score_similarity(tool, name, description, tags, category)
if score >= threshold:
results.append((tool, round(score, 3)))
results.sort(key=lambda item: item[1], reverse=True)
return results

View File

@ -0,0 +1,268 @@
"""Registry repository sync and webhook processing."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import shutil
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, Tuple
import yaml
from .db import connect_db, query_one
def get_repo_dir() -> Path:
default_dir = Path.home() / ".smarttools" / "registry" / "repo"
return Path(os.environ.get("SMARTTOOLS_REGISTRY_REPO_DIR", default_dir))
def get_repo_url() -> str:
return os.environ.get("SMARTTOOLS_REGISTRY_REPO_URL", "https://gitea.brrd.tech/rob/SmartTools-Registry.git")
def get_repo_branch() -> str:
return os.environ.get("SMARTTOOLS_REGISTRY_REPO_BRANCH", "main")
def get_categories_cache_path() -> Path:
return Path(os.environ.get(
"SMARTTOOLS_REGISTRY_CATEGORIES_CACHE",
Path.home() / ".smarttools" / "registry" / "categories_cache.json",
))
def verify_hmac(body: bytes, signature: str | None, secret: str) -> bool:
if not signature:
return False
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
def clone_or_update_repo(repo_dir: Path) -> None:
repo_dir.parent.mkdir(parents=True, exist_ok=True)
if not repo_dir.exists():
subprocess.run(
["git", "clone", "--depth", "1", "--branch", get_repo_branch(), get_repo_url(), str(repo_dir)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
subprocess.run(["git", "-C", str(repo_dir), "fetch", "origin"], check=True)
subprocess.run(
["git", "-C", str(repo_dir), "reset", "--hard", f"origin/{get_repo_branch()}"]
, check=True
)
def load_yaml(path: Path) -> Dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
def ensure_publisher(conn, owner: str) -> int:
row = query_one(conn, "SELECT id FROM publishers WHERE slug = ?", [owner])
if row:
return int(row["id"])
placeholder_email = f"{owner}@registry.local"
conn.execute(
"""
INSERT INTO publishers (email, password_hash, slug, display_name, verified)
VALUES (?, ?, ?, ?, ?)
""",
[placeholder_email, "", owner, owner, False],
)
return int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
def normalize_tags(tags_value: Any) -> str:
if not tags_value:
return "[]"
if isinstance(tags_value, list):
return json.dumps(tags_value)
return json.dumps([str(tags_value)])
def upsert_tool(conn, owner: str, name: str, data: Dict[str, Any], config_text: str, readme_text: str | None) -> None:
version = data.get("version")
if not version:
return
publisher_id = ensure_publisher(conn, owner)
registry_meta = data.get("registry", {}) or {}
tags = normalize_tags(data.get("tags"))
description = data.get("description")
category = data.get("category")
deprecated = bool(data.get("deprecated", False))
deprecated_message = data.get("deprecated_message")
replacement = data.get("replacement")
downloads = registry_meta.get("downloads")
published_at = registry_meta.get("published_at")
existing = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
if existing:
conn.execute(
"""
UPDATE tools
SET description = ?, category = ?, tags = ?, config_yaml = ?, readme = ?,
deprecated = ?, deprecated_message = ?, replacement = ?,
downloads = COALESCE(?, downloads), published_at = COALESCE(?, published_at)
WHERE id = ?
""",
[
description,
category,
tags,
config_text,
readme_text,
int(deprecated),
deprecated_message,
replacement,
downloads,
published_at,
existing["id"],
],
)
else:
conn.execute(
"""
INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
name,
version,
description,
category,
tags,
config_text,
readme_text,
publisher_id,
int(deprecated),
deprecated_message,
replacement,
downloads or 0,
published_at,
],
)
def sync_categories(repo_dir: Path) -> None:
categories_path = repo_dir / "categories" / "categories.yaml"
if not categories_path.exists():
return
payload = load_yaml(categories_path)
cache_path = get_categories_cache_path()
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def sync_from_repo() -> Tuple[bool, str]:
repo_dir = get_repo_dir()
clone_or_update_repo(repo_dir)
tools_root = repo_dir / "tools"
if not tools_root.exists():
return False, "missing_tools_dir"
conn = connect_db()
try:
conn.execute("BEGIN")
for config_path in tools_root.glob("*/*/config.yaml"):
owner = config_path.parent.parent.name
name = config_path.parent.name
try:
config_text = config_path.read_text(encoding="utf-8")
data = yaml.safe_load(config_text) or {}
readme_path = config_path.parent / "README.md"
readme_text = readme_path.read_text(encoding="utf-8") if readme_path.exists() else None
upsert_tool(conn, owner, name, data, config_text, readme_text)
except Exception:
continue
conn.commit()
finally:
conn.close()
sync_categories(repo_dir)
return True, "ok"
def record_webhook_delivery(conn, delivery_id: str, event_type: str) -> None:
conn.execute(
"INSERT INTO webhook_log (delivery_id, event_type, processed_at) VALUES (?, ?, ?)",
[delivery_id, event_type, datetime.utcnow().isoformat()],
)
def is_delivery_processed(conn, delivery_id: str) -> bool:
row = query_one(conn, "SELECT 1 FROM webhook_log WHERE delivery_id = ?", [delivery_id])
return bool(row)
def acquire_lock(lock_path: Path, timeout: int) -> bool:
lock_path.parent.mkdir(parents=True, exist_ok=True)
start = time.time()
while time.time() - start < timeout:
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
return True
except FileExistsError:
time.sleep(0.1)
return False
def release_lock(lock_path: Path) -> None:
if lock_path.exists():
lock_path.unlink()
def process_webhook(body: bytes, headers: Dict[str, str], secret: str, timeout: int = 300) -> Tuple[int, Dict[str, Any]]:
delivery_id = headers.get("X-Gitea-Delivery")
signature = headers.get("X-Gitea-Signature")
event_type = headers.get("X-Gitea-Event", "unknown")
if not delivery_id:
return 400, {"error": {"code": "VALIDATION_ERROR", "message": "Missing X-Gitea-Delivery"}}
if not verify_hmac(body, signature, secret):
return 401, {"error": {"code": "UNAUTHORIZED", "message": "Invalid webhook signature"}}
conn = connect_db()
try:
if is_delivery_processed(conn, delivery_id):
return 200, {"data": {"status": "already_processed"}}
lock_path = Path.home() / ".smarttools" / "registry" / "locks" / "webhook.lock"
if not acquire_lock(lock_path, timeout):
return 200, {"data": {"status": "skipped", "reason": "sync_in_progress"}}
try:
if is_delivery_processed(conn, delivery_id):
return 200, {"data": {"status": "already_processed"}}
ok, reason = sync_from_repo()
if ok:
record_webhook_delivery(conn, delivery_id, event_type)
conn.commit()
return 200, {"data": {"status": "processed"}}
return 500, {"error": {"code": "SERVER_ERROR", "message": f"Sync failed: {reason}"}}
finally:
release_lock(lock_path)
finally:
conn.close()

View File

@ -0,0 +1,732 @@
"""Registry API client for SmartTools.
Handles all HTTP communication with the registry server.
"""
import hashlib
import json
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, List, Dict, Any
from urllib.parse import urljoin, urlencode
import requests
from .config import (
load_config,
get_registry_url,
get_registry_token,
get_client_id,
CONFIG_DIR
)
# Local cache directory
CACHE_DIR = CONFIG_DIR / "registry"
INDEX_CACHE_FILE = CACHE_DIR / "index.json"
INDEX_CACHE_MAX_AGE = timedelta(hours=24)
@dataclass
class RegistryError(Exception):
"""Base exception for registry errors."""
code: str
message: str
details: Optional[Dict] = None
http_status: int = 0
def __str__(self):
return f"{self.code}: {self.message}"
@dataclass
class RateLimitError(RegistryError):
"""Raised when rate limited by the registry."""
retry_after: int = 60
def __init__(self, retry_after: int = 60):
super().__init__(
code="RATE_LIMITED",
message=f"Rate limited. Retry after {retry_after} seconds.",
http_status=429
)
self.retry_after = retry_after
@dataclass
class PaginatedResponse:
"""Paginated API response."""
data: List[Dict]
page: int = 1
per_page: int = 20
total: int = 0
total_pages: int = 0
@dataclass
class ToolInfo:
"""Tool information from the registry."""
owner: str
name: str
version: str
description: str = ""
category: str = ""
tags: List[str] = field(default_factory=list)
downloads: int = 0
deprecated: bool = False
deprecated_message: str = ""
replacement: str = ""
published_at: str = ""
readme: str = ""
@property
def full_name(self) -> str:
return f"{self.owner}/{self.name}"
@classmethod
def from_dict(cls, data: dict) -> "ToolInfo":
return cls(
owner=data.get("owner", ""),
name=data.get("name", ""),
version=data.get("version", ""),
description=data.get("description", ""),
category=data.get("category", ""),
tags=data.get("tags", []),
downloads=data.get("downloads", 0),
deprecated=data.get("deprecated", False),
deprecated_message=data.get("deprecated_message", ""),
replacement=data.get("replacement", ""),
published_at=data.get("published_at", ""),
readme=data.get("readme", "")
)
@dataclass
class DownloadResult:
"""Result of downloading a tool."""
owner: str
name: str
resolved_version: str
config_yaml: str
readme: str = ""
class RegistryClient:
"""Client for interacting with the SmartTools registry API."""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
timeout: int = 30,
max_retries: int = 3
):
"""
Initialize the registry client.
Args:
base_url: Registry API base URL (default: from config)
token: Auth token for authenticated requests (default: from config)
timeout: Request timeout in seconds
max_retries: Maximum number of retries for failed requests
"""
self.base_url = base_url or get_registry_url()
self.token = token or get_registry_token()
self.timeout = timeout
self.max_retries = max_retries
self.client_id = get_client_id()
# Session for connection pooling
self._session = requests.Session()
self._session.headers.update({
"User-Agent": "SmartTools-CLI/1.0",
"X-SmartTools-Client": "cli/1.0.0",
"Accept": "application/json"
})
# Add client ID header
if self.client_id:
self._session.headers["X-Client-ID"] = self.client_id
def _url(self, path: str) -> str:
"""Build full URL from path."""
# Ensure base_url ends without /api/v1 duplication
base = self.base_url.rstrip("/")
if not path.startswith("/"):
path = "/" + path
return base + path
def _auth_headers(self) -> Dict[str, str]:
"""Get authentication headers if token is available."""
if self.token:
return {"Authorization": f"Bearer {self.token}"}
return {}
def _request(
self,
method: str,
path: str,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None,
require_auth: bool = False,
etag: Optional[str] = None
) -> requests.Response:
"""
Make an HTTP request with retry logic.
Args:
method: HTTP method
path: API path
params: Query parameters
json_data: JSON body data
require_auth: Whether auth is required
etag: ETag for conditional requests
Returns:
Response object
Raises:
RegistryError: On API errors
RateLimitError: When rate limited
"""
url = self._url(path)
headers = {}
if require_auth:
if not self.token:
raise RegistryError(
code="UNAUTHORIZED",
message="Authentication required. Set registry token with 'smarttools config set-token'",
http_status=401
)
headers.update(self._auth_headers())
if etag:
headers["If-None-Match"] = etag
last_error = None
for attempt in range(self.max_retries):
try:
response = self._session.request(
method=method,
url=url,
params=params,
json=json_data,
headers=headers,
timeout=self.timeout
)
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
if attempt < self.max_retries - 1:
time.sleep(min(retry_after, 30)) # Cap wait at 30s per attempt
continue
raise RateLimitError(retry_after=retry_after)
# Handle server errors with retry
if response.status_code >= 500:
if attempt < self.max_retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
return response
except requests.exceptions.Timeout:
last_error = RegistryError(
code="TIMEOUT",
message="Request timed out"
)
if attempt < self.max_retries - 1:
time.sleep(2 ** attempt)
continue
except requests.exceptions.ConnectionError:
last_error = RegistryError(
code="CONNECTION_ERROR",
message="Could not connect to registry"
)
if attempt < self.max_retries - 1:
time.sleep(2 ** attempt)
continue
raise last_error or RegistryError(
code="REQUEST_FAILED",
message="Request failed after retries"
)
def _handle_error_response(self, response: requests.Response) -> None:
"""Parse and raise appropriate error from error response."""
try:
data = response.json()
error = data.get("error", {})
raise RegistryError(
code=error.get("code", "UNKNOWN_ERROR"),
message=error.get("message", "Unknown error"),
details=error.get("details"),
http_status=response.status_code
)
except (json.JSONDecodeError, KeyError):
raise RegistryError(
code="UNKNOWN_ERROR",
message=f"HTTP {response.status_code}: {response.text[:200]}",
http_status=response.status_code
)
# -------------------------------------------------------------------------
# Public API Methods
# -------------------------------------------------------------------------
def list_tools(
self,
category: Optional[str] = None,
page: int = 1,
per_page: int = 20,
sort: str = "downloads",
order: str = "desc"
) -> PaginatedResponse:
"""
List tools from the registry.
Args:
category: Filter by category
page: Page number (1-indexed)
per_page: Items per page (max 100)
sort: Sort field (downloads, published_at, name)
order: Sort order (asc, desc)
Returns:
PaginatedResponse with tool data
"""
params = {
"page": page,
"per_page": min(per_page, 100),
"sort": sort,
"order": order
}
if category:
params["category"] = category
response = self._request("GET", "/tools", params=params)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json()
meta = data.get("meta", {})
return PaginatedResponse(
data=data.get("data", []),
page=meta.get("page", page),
per_page=meta.get("per_page", per_page),
total=meta.get("total", 0),
total_pages=meta.get("total_pages", 0)
)
def search_tools(
self,
query: str,
category: Optional[str] = None,
page: int = 1,
per_page: int = 20,
sort: str = "relevance"
) -> PaginatedResponse:
"""
Search for tools in the registry.
Args:
query: Search query
category: Filter by category
page: Page number
per_page: Items per page
sort: Sort field (relevance, downloads, published_at)
Returns:
PaginatedResponse with matching tools
"""
params = {
"q": query,
"page": page,
"per_page": min(per_page, 100),
"sort": sort
}
if category:
params["category"] = category
response = self._request("GET", "/tools/search", params=params)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json()
meta = data.get("meta", {})
return PaginatedResponse(
data=data.get("data", []),
page=meta.get("page", page),
per_page=meta.get("per_page", per_page),
total=meta.get("total", 0),
total_pages=meta.get("total_pages", 0)
)
def get_tool(self, owner: str, name: str) -> ToolInfo:
"""
Get detailed information about a tool.
Args:
owner: Tool owner (namespace)
name: Tool name
Returns:
ToolInfo object
"""
response = self._request("GET", f"/tools/{owner}/{name}")
if response.status_code == 404:
raise RegistryError(
code="TOOL_NOT_FOUND",
message=f"Tool '{owner}/{name}' not found",
http_status=404
)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json().get("data", {})
return ToolInfo.from_dict(data)
def get_tool_versions(self, owner: str, name: str) -> List[str]:
"""
Get all versions of a tool.
Args:
owner: Tool owner
name: Tool name
Returns:
List of version strings (sorted newest first)
"""
response = self._request("GET", f"/tools/{owner}/{name}/versions")
if response.status_code == 404:
raise RegistryError(
code="TOOL_NOT_FOUND",
message=f"Tool '{owner}/{name}' not found",
http_status=404
)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json()
return data.get("data", {}).get("versions", [])
def download_tool(
self,
owner: str,
name: str,
version: Optional[str] = None,
install: bool = True
) -> DownloadResult:
"""
Download a tool's configuration.
Args:
owner: Tool owner
name: Tool name
version: Version or constraint (default: latest)
install: Whether to count as install for stats
Returns:
DownloadResult with config YAML
"""
params = {"install": str(install).lower()}
if version:
params["version"] = version
response = self._request(
"GET",
f"/tools/{owner}/{name}/download",
params=params
)
if response.status_code == 404:
error_data = {}
try:
error_data = response.json().get("error", {})
except json.JSONDecodeError:
pass
code = error_data.get("code", "TOOL_NOT_FOUND")
message = error_data.get("message", f"Tool '{owner}/{name}' not found")
raise RegistryError(
code=code,
message=message,
details=error_data.get("details"),
http_status=404
)
if response.status_code != 200:
self._handle_error_response(response)
data = response.json().get("data", {})
return DownloadResult(
owner=data.get("owner", owner),
name=data.get("name", name),
resolved_version=data.get("resolved_version", ""),
config_yaml=data.get("config", ""),
readme=data.get("readme", "")
)
def get_categories(self) -> List[Dict[str, Any]]:
"""
Get list of tool categories.
Returns:
List of category dicts with name, description, icon
"""
response = self._request("GET", "/categories")
if response.status_code != 200:
self._handle_error_response(response)
return response.json().get("data", [])
def publish_tool(
self,
config_yaml: str,
readme: str = "",
dry_run: bool = False
) -> Dict[str, Any]:
"""
Publish a tool to the registry.
Args:
config_yaml: Tool configuration YAML content
readme: README.md content
dry_run: If True, validate without publishing
Returns:
Dict with PR URL or validation results
"""
payload = {
"config": config_yaml,
"readme": readme,
"dry_run": dry_run
}
response = self._request(
"POST",
"/tools",
json_data=payload,
require_auth=True
)
if response.status_code == 409:
# Version already exists
self._handle_error_response(response)
if response.status_code not in (200, 201):
self._handle_error_response(response)
return response.json().get("data", {})
def get_my_tools(self) -> List[ToolInfo]:
"""
Get tools published by the authenticated user.
Returns:
List of ToolInfo objects
"""
response = self._request("GET", "/me/tools", require_auth=True)
if response.status_code != 200:
self._handle_error_response(response)
tools = response.json().get("data", [])
return [ToolInfo.from_dict(t) for t in tools]
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
"""
Get most popular tools.
Args:
limit: Maximum number of tools to return
Returns:
List of ToolInfo objects
"""
response = self._request(
"GET",
"/stats/popular",
params={"limit": limit}
)
if response.status_code != 200:
self._handle_error_response(response)
tools = response.json().get("data", [])
return [ToolInfo.from_dict(t) for t in tools]
# -------------------------------------------------------------------------
# Index Caching
# -------------------------------------------------------------------------
def get_index(self, force_refresh: bool = False) -> Dict[str, Any]:
"""
Get the full tool index, using cache when possible.
Args:
force_refresh: Force refresh from server
Returns:
Index dict with tools list
"""
# Check cache first
if not force_refresh:
cached = self._load_cached_index()
if cached:
return cached
# Fetch from server
etag = self._get_cached_etag()
response = self._request("GET", "/index.json", etag=etag)
if response.status_code == 304:
# Not modified, use cache
cached = self._load_cached_index()
if cached:
return cached
if response.status_code != 200:
# Try to use stale cache on error
cached = self._load_cached_index(ignore_age=True)
if cached:
return cached
self._handle_error_response(response)
data = response.json()
# Cache the response
new_etag = response.headers.get("ETag")
self._save_cached_index(data, new_etag)
return data
def _load_cached_index(self, ignore_age: bool = False) -> Optional[Dict]:
"""Load cached index if valid."""
if not INDEX_CACHE_FILE.exists():
return None
try:
cache_data = json.loads(INDEX_CACHE_FILE.read_text())
# Check age
if not ignore_age:
cached_at = datetime.fromisoformat(cache_data.get("_cached_at", ""))
if datetime.now() - cached_at > INDEX_CACHE_MAX_AGE:
return None
# Verify checksum
if not self._verify_index_checksum(cache_data):
return None
return cache_data
except (json.JSONDecodeError, KeyError, ValueError):
return None
def _save_cached_index(self, data: Dict, etag: Optional[str] = None) -> None:
"""Save index to cache."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
data["_cached_at"] = datetime.now().isoformat()
if etag:
data["_etag"] = etag
INDEX_CACHE_FILE.write_text(json.dumps(data, indent=2))
def _get_cached_etag(self) -> Optional[str]:
"""Get ETag from cached index."""
if not INDEX_CACHE_FILE.exists():
return None
try:
cache_data = json.loads(INDEX_CACHE_FILE.read_text())
return cache_data.get("_etag")
except (json.JSONDecodeError, KeyError):
return None
def _verify_index_checksum(self, data: Dict) -> bool:
"""Verify cached index integrity."""
checksum = data.get("checksum", "")
if not checksum:
return True # No checksum to verify
# Compute checksum of tools list
tools = data.get("tools", [])
content = json.dumps(tools, sort_keys=True)
computed = "sha256:" + hashlib.sha256(content.encode()).hexdigest()
return computed == checksum
def clear_cache(self) -> None:
"""Clear the local index cache."""
if INDEX_CACHE_FILE.exists():
INDEX_CACHE_FILE.unlink()
# -------------------------------------------------------------------------
# Convenience functions
# -------------------------------------------------------------------------
def get_client() -> RegistryClient:
"""Get a configured registry client instance."""
return RegistryClient()
def search(query: str, **kwargs) -> PaginatedResponse:
"""Search the registry for tools."""
return get_client().search_tools(query, **kwargs)
def install_tool(tool_spec: str, version: Optional[str] = None) -> DownloadResult:
"""
Download a tool for installation.
Args:
tool_spec: Tool specification (owner/name or just name)
version: Version constraint
Returns:
DownloadResult with config YAML
"""
client = get_client()
# Parse tool spec
if "/" in tool_spec:
owner, name = tool_spec.split("/", 1)
else:
# Shorthand - try official namespace first
owner = "official"
name = tool_spec
try:
return client.download_tool(owner, name, version=version, install=True)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND" and owner == "official":
# Fall back to searching for most popular tool with this name
results = client.search_tools(name, per_page=1)
if results.data:
first = results.data[0]
return client.download_tool(
first["owner"],
first["name"],
version=version,
install=True
)
raise

668
src/smarttools/resolver.py Normal file
View File

@ -0,0 +1,668 @@
"""Tool resolution with proper search order.
Implements the tool resolution order:
1. Local project: ./.smarttools/<owner>/<name>/config.yaml
2. Global user: ~/.smarttools/<owner>/<name>/config.yaml
3. Registry: Fetch from API, install to global, then run
4. Error if not found
"""
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
import yaml
from .tool import Tool, TOOLS_DIR, get_bin_dir, BIN_DIR
from .config import is_auto_fetch_enabled, load_config
from .manifest import load_manifest
# Local project tools directories (support both legacy and documented paths)
LOCAL_TOOLS_DIRS = [Path(".smarttools"), Path("smarttools")]
@dataclass
class ToolSpec:
"""Parsed tool specification."""
owner: Optional[str] # None for unqualified names like "summarize"
name: str
version: Optional[str] = None # Version constraint
@property
def full_name(self) -> str:
"""Get full owner/name format."""
if self.owner:
return f"{self.owner}/{self.name}"
return self.name
@property
def is_qualified(self) -> bool:
"""Check if this is a fully qualified name (owner/name)."""
return self.owner is not None
@classmethod
def parse(cls, spec: str) -> "ToolSpec":
"""
Parse a tool specification string.
Formats:
- "summarize" -> owner=None, name="summarize"
- "rob/summarize" -> owner="rob", name="summarize"
- "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)

View File

@ -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 <tool_name> [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

View File

@ -0,0 +1,496 @@
"""TUI for browsing the SmartTools Registry using urwid.
Uses threading for non-blocking network operations.
"""
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, List, Dict, Any, Callable
import urwid
from .registry_client import (
RegistryClient, RegistryError, ToolInfo,
get_client, PaginatedResponse
)
from .resolver import install_from_registry
# Color palette - matching the main UI style
PALETTE = [
('body', 'white', 'dark blue'),
('header', 'white', 'dark red', 'bold'),
('footer', 'black', 'light gray'),
('button', 'black', 'light gray'),
('button_focus', 'white', 'dark red', 'bold'),
('edit', 'black', 'light gray'),
('edit_focus', 'black', 'yellow'),
('listbox', 'black', 'light gray'),
('listbox_focus', 'white', 'dark red'),
('dialog', 'black', 'light gray'),
('label', 'yellow', 'dark blue', 'bold'),
('error', 'white', 'dark red', 'bold'),
('success', 'light green', 'dark blue', 'bold'),
('info', 'light cyan', 'dark blue'),
('downloads', 'light green', 'light gray'),
('category', 'dark cyan', 'light gray'),
('version', 'brown', 'light gray'),
('loading', 'yellow', 'dark blue'),
]
class ToolListItem(urwid.WidgetWrap):
"""A selectable tool item in the browse list."""
def __init__(self, tool_data: Dict[str, Any], on_select=None, on_install=None):
self.tool_data = tool_data
self.on_select = on_select
self.on_install = on_install
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "")[:50]
downloads = tool_data.get("downloads", 0)
category = tool_data.get("category", "")
# Format: owner/name v1.0.0 [category] ↓123
main_line = urwid.Text([
('listbox', f" {owner}/"),
('listbox', f"{name} "),
('version', f"v{version}"),
])
desc_line = urwid.Text([
('listbox', f" {description}{'...' if len(tool_data.get('description', '')) > 50 else ''}"),
])
meta_line = urwid.Text([
('category', f" [{category}]" if category else ""),
('downloads', f"{downloads}"),
])
pile = urwid.Pile([main_line, desc_line, meta_line])
self.attr_map = urwid.AttrMap(pile, 'listbox', 'listbox_focus')
super().__init__(self.attr_map)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.tool_data)
return None
if key == 'i' and self.on_install:
self.on_install(self.tool_data)
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if event == 'mouse press' and button == 1 and self.on_select:
self.on_select(self.tool_data)
return True
return False
class SearchEdit(urwid.Edit):
"""Search box that triggers callback on enter."""
def __init__(self, on_search=None):
self.on_search = on_search
super().__init__(caption="Search: ", edit_text="")
def keypress(self, size, key):
if key == 'enter' and self.on_search:
self.on_search(self.edit_text)
return None
return super().keypress(size, key)
class AsyncOperation:
"""Manages async operations with UI callbacks."""
def __init__(self, executor: ThreadPoolExecutor):
self.executor = executor
self._write_fd: Optional[int] = None
self._read_fd: Optional[int] = None
self._pending_callbacks: List[Callable] = []
self._lock = threading.Lock()
def setup_pipe(self, loop: urwid.MainLoop):
"""Setup a pipe for thread-safe UI updates."""
self._read_fd, self._write_fd = os.pipe()
loop.watch_file(self._read_fd, self._handle_callback)
def cleanup(self):
"""Cleanup pipe file descriptors."""
if self._read_fd is not None:
os.close(self._read_fd)
if self._write_fd is not None:
os.close(self._write_fd)
def _handle_callback(self):
"""Handle pending callbacks from worker threads."""
# Read and discard the notification byte
os.read(self._read_fd, 1)
# Process pending callbacks
with self._lock:
callbacks = self._pending_callbacks[:]
self._pending_callbacks.clear()
for callback in callbacks:
callback()
def _schedule_callback(self, callback: Callable):
"""Schedule a callback to run on the main thread."""
with self._lock:
self._pending_callbacks.append(callback)
# Wake up the main loop
if self._write_fd is not None:
os.write(self._write_fd, b'x')
def run_async(
self,
operation: Callable,
on_success: Callable[[Any], None],
on_error: Callable[[Exception], None]
):
"""Run an operation asynchronously."""
def worker():
try:
result = operation()
self._schedule_callback(lambda: on_success(result))
except Exception as e:
self._schedule_callback(lambda: on_error(e))
self.executor.submit(worker)
class RegistryBrowser:
"""TUI browser for the SmartTools Registry."""
def __init__(self):
self.client = get_client()
self.tools: List[Dict] = []
self.categories: List[Dict] = []
self.current_category: Optional[str] = None
self.current_query: str = ""
self.current_page: int = 1
self.total_pages: int = 1
self.status_message: str = ""
self.loop: Optional[urwid.MainLoop] = None
self.loading: bool = False
# Thread pool for async operations
self.executor = ThreadPoolExecutor(max_workers=2)
self.async_ops = AsyncOperation(self.executor)
# Build UI
self._build_ui()
def _build_ui(self):
"""Build the main UI layout."""
# Header
self.header = urwid.AttrMap(
urwid.Text(" SmartTools Registry Browser ", align='center'),
'header'
)
# Search box
self.search_edit = SearchEdit(on_search=self._do_search)
search_widget = urwid.AttrMap(self.search_edit, 'edit', 'edit_focus')
# Category selector
self.category_text = urwid.Text("Category: All")
category_widget = urwid.AttrMap(self.category_text, 'info')
# Top bar with search and category
top_bar = urwid.Columns([
('weight', 2, search_widget),
('weight', 1, category_widget),
], dividechars=2)
# Tools list
self.list_walker = urwid.SimpleFocusListWalker([])
self.listbox = urwid.ListBox(self.list_walker)
list_frame = urwid.LineBox(self.listbox, title="Tools")
# Detail panel (right side)
self.detail_text = urwid.Text("Select a tool to view details\n\nPress 'i' to install")
self.detail_box = urwid.LineBox(
urwid.Filler(self.detail_text, valign='top'),
title="Details"
)
# Main content area with list and details
self.main_columns = urwid.Columns([
('weight', 2, list_frame),
('weight', 1, self.detail_box),
], dividechars=1)
# Status bar
self.status_text = urwid.Text(" Loading...")
self.footer = urwid.AttrMap(
urwid.Columns([
self.status_text,
urwid.Text("↑↓:Navigate Enter:Details i:Install /:Search c:Category q:Quit", align='right'),
]),
'footer'
)
# Main frame
body = urwid.Pile([
('pack', urwid.AttrMap(top_bar, 'body')),
('pack', urwid.Divider()),
self.main_columns,
])
self.frame = urwid.Frame(
urwid.AttrMap(body, 'body'),
header=self.header,
footer=self.footer
)
def _load_tools(self, query: str = "", category: str = None, page: int = 1):
"""Load tools from the registry asynchronously."""
if self.loading:
return
self.loading = True
self._set_status("Loading...", loading=True)
def fetch():
if query:
return self.client.search_tools(
query=query,
category=category,
page=page,
per_page=20
)
else:
return self.client.list_tools(
category=category,
page=page,
per_page=20
)
def on_success(result: PaginatedResponse):
self.loading = False
self.tools = result.data
self.current_page = result.page
self.total_pages = result.total_pages
self._update_list()
self._set_status(f"Found {result.total} tools (page {result.page}/{result.total_pages})")
def on_error(e: Exception):
self.loading = False
if isinstance(e, RegistryError):
self._set_status(f"Error: {e.message}")
else:
self._set_status(f"Error: {e}")
self.async_ops.run_async(fetch, on_success, on_error)
def _load_categories(self):
"""Load categories from the registry asynchronously."""
def fetch():
return self.client.get_categories()
def on_success(categories):
self.categories = categories
def on_error(e):
self.categories = []
self.async_ops.run_async(fetch, on_success, on_error)
def _update_list(self):
"""Update the tool list display."""
self.list_walker.clear()
if not self.tools:
self.list_walker.append(urwid.Text(" No tools found"))
return
for tool in self.tools:
item = ToolListItem(
tool,
on_select=self._show_detail,
on_install=self._install_tool
)
self.list_walker.append(item)
self.list_walker.append(urwid.Divider(''))
def _show_detail(self, tool_data: Dict):
"""Show tool details in the detail panel."""
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "No description")
category = tool_data.get("category", "")
tags = tool_data.get("tags", [])
downloads = tool_data.get("downloads", 0)
detail = f"""{owner}/{name}
Version: {version}
Category: {category}
Downloads: {downloads}
{description}
Tags: {', '.join(tags) if tags else 'None'}
Install command:
smarttools registry install {owner}/{name}
Press 'i' to install this tool
"""
self.detail_text.set_text(detail)
def _install_tool(self, tool_data: Dict):
"""Install the selected tool asynchronously."""
if self.loading:
return
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
self.loading = True
self._set_status(f"Installing {owner}/{name}...", loading=True)
def install():
return install_from_registry(f"{owner}/{name}")
def on_success(resolved):
self.loading = False
self._set_status(f"Installed: {resolved.full_name}@{resolved.version}")
def on_error(e):
self.loading = False
self._set_status(f"Install failed: {e}")
self.async_ops.run_async(install, on_success, on_error)
def _do_search(self, query: str):
"""Perform search."""
self.current_query = query
self.current_page = 1
self._load_tools(query=query, category=self.current_category)
def _cycle_category(self):
"""Cycle through categories."""
if not self.categories:
self._load_categories()
# Schedule the cycle after categories load
return
if not self.categories:
return
cat_names = [None] + [c.get("name") for c in self.categories]
try:
idx = cat_names.index(self.current_category)
idx = (idx + 1) % len(cat_names)
except ValueError:
idx = 0
self.current_category = cat_names[idx]
cat_display = self.current_category or "All"
self.category_text.set_text(f"Category: {cat_display}")
self._load_tools(query=self.current_query, category=self.current_category)
def _next_page(self):
"""Go to next page."""
if self.current_page < self.total_pages:
self.current_page += 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _prev_page(self):
"""Go to previous page."""
if self.current_page > 1:
self.current_page -= 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _set_status(self, message: str, loading: bool = False):
"""Update status bar message."""
if loading:
self.status_text.set_text(('loading', f"{message}"))
else:
self.status_text.set_text(f" {message}")
def _handle_input(self, key):
"""Handle global key input."""
if key in ('q', 'Q'):
raise urwid.ExitMainLoop()
elif key == '/':
# Focus search box
self.frame.body.base_widget.set_focus(0)
return None
elif key == 'c':
self._cycle_category()
return None
elif key == 'n':
self._next_page()
return None
elif key == 'p':
self._prev_page()
return None
elif key == 'r':
# Refresh current view
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
return None
return key
def run(self):
"""Run the TUI browser."""
# Create main loop
self.loop = urwid.MainLoop(
self.frame,
palette=PALETTE,
unhandled_input=self._handle_input,
handle_mouse=True
)
# Setup async pipe for thread-safe callbacks
self.async_ops.setup_pipe(self.loop)
try:
# Initial load (async)
self._load_categories()
self._load_tools()
# Run main loop
self.loop.run()
finally:
# Cleanup
self.async_ops.cleanup()
self.executor.shutdown(wait=False)
def run_registry_browser():
"""Entry point for the registry browser TUI."""
try:
browser = RegistryBrowser()
browser.run()
except RegistryError as e:
print(f"Error connecting to registry: {e.message}")
return 1
except Exception as e:
print(f"Error: {e}")
return 1
return 0

View File

@ -0,0 +1,11 @@
"""Web UI blueprint for SmartTools."""
from flask import Blueprint
web_bp = Blueprint(
"web",
__name__,
template_folder="templates",
static_folder="static",
static_url_path="/static",
)

58
src/smarttools/web/app.py Normal file
View File

@ -0,0 +1,58 @@
"""Web app factory for SmartTools UI."""
from __future__ import annotations
import os
import secrets
from flask import Flask, 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()
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)
# Ensure routes are registered
from . import routes # noqa: F401
# Auth routes are registered via blueprint import side effects
return app
if __name__ == "__main__":
create_web_app().run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))

107
src/smarttools/web/auth.py Normal file
View File

@ -0,0 +1,107 @@
"""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"],
csrf_token=_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],
csrf_token=_csrf_token(),
email=email,
next_url=next_url,
)
return render_template("pages/login.html", csrf_token=_csrf_token(), 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"],
csrf_token=_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],
csrf_token=_csrf_token(),
email=payload["email"],
slug=payload["slug"],
display_name=payload["display_name"],
)
return render_template("pages/register.html", csrf_token=_csrf_token())
@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"))

View File

@ -0,0 +1,137 @@
"""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

View File

@ -0,0 +1,548 @@
"""Public web routes for the SmartTools UI."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
from markupsafe import Markup, escape
from flask import current_app, redirect, render_template, request, session, url_for
from smarttools.registry.db import connect_db, query_all, query_one
from . import web_bp
def _api_get(path: str, params: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]:
client = current_app.test_client()
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
response = client.get(path, query_string=params or {}, headers=headers)
return response.status_code, response.get_json(silent=True) or {}
def _title_case(value: str) -> str:
return value.replace("-", " ").title()
def _build_pagination(meta: Dict[str, Any]) -> SimpleNamespace:
page = int(meta.get("page", 1))
total_pages = int(meta.get("total_pages", 1))
return SimpleNamespace(
page=page,
pages=total_pages,
has_prev=page > 1,
has_next=page < total_pages,
prev_num=page - 1,
next_num=page + 1,
)
def _render_readme(readme: Optional[str]) -> str:
if not readme:
return ""
escaped = escape(readme)
return Markup("<pre class=\"whitespace-pre-wrap\">") + escaped + Markup("</pre>")
def _load_categories() -> List[SimpleNamespace]:
status, payload = _api_get("/api/v1/categories", params={"per_page": 100})
if status != 200:
return []
categories = []
for item in payload.get("data", []):
name = item.get("name")
if not name:
continue
categories.append(SimpleNamespace(
name=name,
display_name=_title_case(name),
count=item.get("tool_count", 0),
description=item.get("description"),
icon=item.get("icon"),
))
return categories
def _load_publisher(slug: str) -> Optional[Dict[str, Any]]:
conn = connect_db()
try:
row = query_one(
conn,
"SELECT slug, display_name, bio, website, verified, created_at FROM publishers WHERE slug = ?",
[slug],
)
return dict(row) if row else None
finally:
conn.close()
def _load_publisher_tools(slug: str) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"""
WITH latest AS (
SELECT owner, name, MAX(id) AS max_id
FROM tools
WHERE owner = ? AND version NOT LIKE '%-%'
GROUP BY owner, name
)
SELECT t.owner, t.name, t.version, t.description, t.category, t.downloads, t.published_at
FROM tools t
JOIN latest ON t.owner = latest.owner AND t.name = latest.name AND t.id = latest.max_id
ORDER BY t.downloads DESC, t.published_at DESC
""",
[slug],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _load_tool_versions(owner: str, name: str) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"SELECT version, published_at FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC",
[owner, name],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _load_tool_id(owner: str, name: str, version: str) -> Optional[int]:
conn = connect_db()
try:
row = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
return int(row["id"]) if row else None
finally:
conn.close()
def _load_current_publisher() -> Optional[Dict[str, Any]]:
slug = session.get("user", {}).get("slug")
if not slug:
return None
conn = connect_db()
try:
row = query_one(
conn,
"""
SELECT id, slug, display_name, email, verified, bio, website, created_at
FROM publishers
WHERE slug = ?
""",
[slug],
)
return dict(row) if row else None
finally:
conn.close()
def _load_pending_prs(publisher_id: int) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"""
SELECT owner, name, version, pr_url, status, created_at
FROM pending_prs
WHERE publisher_id = ?
ORDER BY created_at DESC
""",
[publisher_id],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _require_login() -> Optional[Any]:
if not session.get("auth_token"):
next_url = request.path
if request.query_string:
next_url = f"{next_url}?{request.query_string.decode('utf-8')}"
return redirect(url_for("web.login", next=next_url))
return None
@web_bp.route("/", endpoint="home")
def home():
status, payload = _api_get("/api/v1/stats/popular", params={"limit": 6})
featured_tools = payload.get("data", []) if status == 200 else []
show_ads = current_app.config.get("SHOW_ADS", False)
return render_template(
"pages/index.html",
featured_tools=featured_tools,
featured_contributor=None,
show_ads=show_ads,
)
def _home_alias():
return home()
web_bp.add_url_rule("/", endpoint="index", view_func=_home_alias)
@web_bp.route("/tools", endpoint="tools")
def tools():
return _render_tools()
def _render_tools(category_override: Optional[str] = None):
page = request.args.get("page", 1)
sort = request.args.get("sort", "downloads")
category = category_override or request.args.get("category")
query = request.args.get("q")
params = {"page": page, "per_page": 12, "sort": sort}
if category:
params["category"] = category
status, payload = _api_get("/api/v1/tools", params=params)
if status != 200:
return render_template("errors/500.html"), 500
meta = payload.get("meta", {})
categories = _load_categories()
return render_template(
"pages/tools.html",
tools=payload.get("data", []),
categories=categories,
current_category=category,
total_count=meta.get("total", 0),
sort=sort,
query=query,
pagination=_build_pagination(meta),
)
def _tools_alias():
return tools()
web_bp.add_url_rule("/tools", endpoint="tools_browse", view_func=_tools_alias)
@web_bp.route("/category/<name>", endpoint="category")
def category(name: str):
query = request.args.get("q")
if query:
return redirect(url_for("web.search", q=query, category=name))
return _render_tools(category_override=name)
@web_bp.route("/search", endpoint="search")
def search():
query = request.args.get("q", "").strip()
page = request.args.get("page", 1)
category = request.args.get("category")
if query:
params = {"q": query, "page": page, "per_page": 12}
if category:
params["category"] = category
status, payload = _api_get("/api/v1/tools/search", params=params)
if status != 200:
return render_template("errors/500.html"), 500
meta = payload.get("meta", {})
return render_template(
"pages/search.html",
query=query,
results=payload.get("data", []),
pagination=_build_pagination(meta),
popular_categories=[],
)
categories = sorted(_load_categories(), key=lambda c: c.count, reverse=True)[:8]
return render_template(
"pages/search.html",
query="",
results=[],
pagination=None,
popular_categories=categories,
)
@web_bp.route("/tools/<owner>/<name>", endpoint="tool_detail")
def tool_detail(owner: str, name: str):
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
tool["tags"] = ", ".join(tool.get("tags", [])) if isinstance(tool.get("tags"), list) else tool.get("tags")
tool["readme_html"] = _render_readme(tool.get("readme"))
tool_id = _load_tool_id(owner, name, tool.get("version", ""))
tool["id"] = tool_id
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
)
@web_bp.route("/tools/<owner>/<name>/versions/<version>", endpoint="tool_version")
def tool_version(owner: str, name: str, version: str):
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}", params={"version": version})
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
tool["tags"] = ", ".join(tool.get("tags", [])) if isinstance(tool.get("tags"), list) else tool.get("tags")
tool["readme_html"] = _render_readme(tool.get("readme"))
tool["id"] = _load_tool_id(owner, name, tool.get("version", version))
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
)
@web_bp.route("/publishers/<slug>", endpoint="publisher")
def publisher(slug: str):
publisher_row = _load_publisher(slug)
if not publisher_row:
return render_template("errors/404.html"), 404
tools = _load_publisher_tools(slug)
return render_template(
"pages/publisher.html",
publisher=publisher_row,
tools=tools,
)
def _publisher_alias(slug: str):
return publisher(slug)
web_bp.add_url_rule("/publishers/<slug>", endpoint="publisher_profile", view_func=_publisher_alias)
def _render_dashboard_overview():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
status, payload = _api_get("/api/v1/me/tools", token=token)
tools = payload.get("data", []) if status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/index.html",
user=user,
tools=tools,
stats=stats,
)
@web_bp.route("/dashboard", endpoint="dashboard")
def dashboard():
return _render_dashboard_overview()
@web_bp.route("/dashboard/tools", endpoint="dashboard_tools")
def dashboard_tools():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
status, payload = _api_get("/api/v1/me/tools", token=token)
tools = payload.get("data", []) if status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
pending_prs = []
if user and user.get("id"):
pending_prs = _load_pending_prs(int(user["id"]))
return render_template(
"dashboard/tools.html",
user=user,
tools=tools,
stats=stats,
pending_prs=pending_prs,
)
@web_bp.route("/dashboard/tokens", endpoint="dashboard_tokens")
def dashboard_tokens():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
tools = tools_payload.get("data", []) if tools_status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
for item in tokens:
token_id = str(item.get("id", ""))
item["token_suffix"] = token_id[-6:] if token_id else ""
item["revoked_at"] = item.get("revoked_at")
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/tokens.html",
user=user,
tools=tools,
stats=stats,
tokens=tokens,
)
@web_bp.route("/dashboard/settings", endpoint="dashboard_settings")
def dashboard_settings():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
tools = tools_payload.get("data", []) if tools_status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/settings.html",
user=user,
tools=tools,
stats=stats,
tokens=tokens,
errors=[],
success_message=None,
)
@web_bp.route("/docs", defaults={"path": ""}, endpoint="docs")
@web_bp.route("/docs/<path:path>", endpoint="docs")
def docs(path: str):
toc = [
SimpleNamespace(slug="getting-started", title="Getting Started", children=[
SimpleNamespace(slug="installation", title="Installation"),
SimpleNamespace(slug="first-tool", title="Your First Tool"),
]),
SimpleNamespace(slug="publishing", title="Publishing", children=[]),
SimpleNamespace(slug="providers", title="Providers", children=[]),
]
current = path or "getting-started"
page = SimpleNamespace(
title=_title_case(current),
description="SmartTools documentation",
content_html=f"<p>Documentation for <strong>{escape(current)}</strong> is coming soon.</p>",
headings=[],
parent=None,
)
show_ads = current_app.config.get("SHOW_ADS", False)
return render_template(
"pages/docs.html",
page=page,
toc=toc,
current_path=current,
prev_page=None,
next_page=None,
show_ads=show_ads,
)
@web_bp.route("/tutorials", endpoint="tutorials")
def tutorials():
core_tutorials = []
video_tutorials = []
return render_template(
"pages/tutorials.html",
core_tutorials=core_tutorials,
video_tutorials=video_tutorials,
)
@web_bp.route("/tutorials/<path:path>", endpoint="tutorials_path")
def tutorials_path(path: str):
return render_template(
"pages/content.html",
title=_title_case(path),
body="Tutorial content for this topic is coming soon.",
)
web_bp.add_url_rule("/tutorials/<path:path>", endpoint="tutorial", view_func=tutorials_path)
@web_bp.route("/community", endpoint="community")
def community():
return render_template(
"pages/content.html",
title="Community",
body="Community features will live here. For now, join the discussion and share ideas.",
)
@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/content.html",
title="Support SmartTools",
body="Donations help fund infrastructure, development, and broader access to AI tools.",
)
@web_bp.route("/privacy", endpoint="privacy")
def privacy():
return render_template("pages/privacy.html")
@web_bp.route("/terms", endpoint="terms")
def terms():
return render_template("pages/terms.html")
@web_bp.route("/forgot-password", endpoint="forgot_password")
def forgot_password():
return render_template(
"pages/content.html",
title="Reset Password",
body="Password resets are not available yet. Please contact support if needed.",
)

78
src/smarttools/web/seo.py Normal file
View File

@ -0,0 +1,78 @@
"""SEO helpers for sitemap and robots.txt."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import List
from flask import Response, current_app, url_for
from smarttools.registry.db import connect_db, query_all
SITEMAP_TTL = timedelta(hours=6)
_sitemap_cache = {"generated_at": None, "xml": ""}
def generate_sitemap() -> str:
now = datetime.utcnow()
cached_at = _sitemap_cache.get("generated_at")
if cached_at and now - cached_at < SITEMAP_TTL:
return _sitemap_cache["xml"]
urls: List[str] = []
static_paths = ["/", "/tools", "/docs", "/tutorials", "/about"]
for path in static_paths:
urls.append(_url_entry(path, "daily", "1.0"))
conn = connect_db()
try:
rows = query_all(conn, "SELECT DISTINCT owner, name FROM tools")
for row in rows:
tool_path = url_for("web.tool_detail", owner=row["owner"], name=row["name"], _external=True)
urls.append(_url_entry(tool_path, "daily", "0.9"))
categories = query_all(conn, "SELECT DISTINCT category FROM tools WHERE category IS NOT NULL")
for row in categories:
cat_path = url_for("web.category", name=row["category"], _external=True)
urls.append(_url_entry(cat_path, "weekly", "0.7"))
finally:
conn.close()
xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
xml += "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"
xml += "\n".join(urls)
xml += "\n</urlset>\n"
_sitemap_cache["generated_at"] = now
_sitemap_cache["xml"] = xml
return xml
def sitemap_response() -> Response:
xml = generate_sitemap()
return Response(xml, mimetype="application/xml")
def robots_txt() -> Response:
lines = [
"User-agent: *",
"Allow: /",
"Disallow: /login",
"Disallow: /register",
"Disallow: /dashboard",
"Disallow: /api/",
f"Sitemap: {url_for('web.sitemap', _external=True)}",
]
return Response("\n".join(lines) + "\n", mimetype="text/plain")
def _url_entry(loc: str, changefreq: str, priority: str) -> str:
if not loc.startswith("http"):
loc = url_for("web.index", _external=True).rstrip("/") + loc
return "\n".join([
" <url>",
f" <loc>{loc}</loc>",
f" <changefreq>{changefreq}</changefreq>",
f" <priority>{priority}</priority>",
" </url>",
])

View File

@ -0,0 +1,109 @@
"""SQLite-backed server-side sessions for the web UI."""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timedelta
from typing import Optional
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
from smarttools.registry.db import connect_db
SESSION_TTL = timedelta(days=7)
class SQLiteSession(CallbackDict, SessionMixin):
def __init__(self, initial=None, session_id: Optional[str] = None):
super().__init__(initial or {})
self.session_id = session_id
class SQLiteSessionInterface(SessionInterface):
def __init__(self, cookie_name: str = "smarttools_session"):
self.cookie_name = cookie_name
def open_session(self, app, request):
session_id = request.cookies.get(self.cookie_name)
if not session_id:
return SQLiteSession(session_id=self._new_session_id())
conn = connect_db()
try:
row = conn.execute(
"SELECT data, expires_at FROM web_sessions WHERE session_id = ?",
[session_id],
).fetchone()
if not row:
return SQLiteSession(session_id=self._new_session_id())
expires_at = self._parse_dt(row["expires_at"])
if expires_at and expires_at < datetime.utcnow():
conn.execute("DELETE FROM web_sessions WHERE session_id = ?", [session_id])
conn.commit()
return SQLiteSession(session_id=self._new_session_id())
data = json.loads(row["data"] or "{}")
return SQLiteSession(initial=data, session_id=session_id)
finally:
conn.close()
def save_session(self, app, session, response):
if session is None:
return
session_id = session.session_id or self._new_session_id()
expires_at = datetime.utcnow() + SESSION_TTL
data = json.dumps(dict(session))
conn = connect_db()
try:
conn.execute(
"""
INSERT INTO web_sessions (session_id, data, created_at, expires_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET data = excluded.data, expires_at = excluded.expires_at
""",
[session_id, data, datetime.utcnow().isoformat(), expires_at.isoformat()],
)
conn.commit()
finally:
conn.close()
response.set_cookie(
self.cookie_name,
session_id,
httponly=True,
samesite="Lax",
secure=app.config.get("SESSION_COOKIE_SECURE", False),
max_age=int(SESSION_TTL.total_seconds()),
)
def rotate_session(self, session) -> None:
session.session_id = self._new_session_id()
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()
@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

View File

@ -0,0 +1,213 @@
/**
* 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 overlay = document.getElementById('mobile-menu-overlay');
if (menu && overlay) {
menu.classList.toggle('hidden');
overlay.classList.toggle('hidden');
document.body.classList.toggle('overflow-hidden');
}
}
function closeMobileMenu() {
const menu = document.getElementById('mobile-menu');
const overlay = document.getElementById('mobile-menu-overlay');
if (menu && overlay) {
menu.classList.add('hidden');
overlay.classList.add('hidden');
document.body.classList.remove('overflow-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;
}

View File

@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom component classes */
.btn-primary {
@apply bg-primary text-white font-semibold px-4 py-2 rounded-md shadow-sm hover:opacity-90;
}
.btn-secondary {
@apply border border-secondary text-secondary font-semibold px-4 py-2 rounded-md hover:bg-secondary hover:text-white;
}
.card {
@apply bg-white border border-gray-200 rounded-md shadow-sm p-4;
}

View File

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SmartTools{% endblock %} - Build Custom AI Commands</title>
<!-- Meta tags -->
<meta name="description" content="{% block meta_description %}Create Unix-style pipeable tools that work with any AI provider. Provider-agnostic, composable, and community-driven.{% endblock %}">
{% block meta_extra %}{% endblock %}
<!-- Open Graph -->
<meta property="og:title" content="{% block og_title %}SmartTools{% endblock %}">
<meta property="og:description" content="{% block og_description %}Build custom AI commands in YAML{% endblock %}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ request.url }}">
{% block og_extra %}{% endblock %}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{% block twitter_title %}SmartTools{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}Build custom AI commands in YAML{% endblock %}">
{% block twitter_extra %}{% endblock %}
<!-- Canonical URL -->
<link rel="canonical" href="{% block canonical %}{{ request.url }}{% endblock %}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="{{ url_for('web.static', filename='css/main.css') }}">
{% block styles %}{% endblock %}
<!-- Schema.org -->
{% block schema %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "SmartTools",
"url": "{{ request.host_url }}",
"description": "Build custom AI commands in YAML"
}
</script>
{% endblock %}
</head>
<body class="min-h-screen bg-gray-50 text-gray-900 font-sans antialiased">
<!-- Skip to content link for accessibility -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-indigo-600 text-white px-4 py-2 rounded-md z-50">
Skip to content
</a>
<!-- Header -->
{% include "components/header.html" %}
<!-- Main content -->
<main id="main-content" class="{% block main_class %}{% endblock %}">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
{% include "components/footer.html" %}
<!-- Cookie consent banner (if not consented) -->
{% if not session.get('consent_given') %}
{% include "components/consent_banner.html" %}
{% endif %}
<!-- Scripts -->
<script src="{{ url_for('web.static', filename='js/main.js') }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,61 @@
{# Callout/alert macros for documentation and content pages #}
{# Info callout #}
{% macro info(title='Note', content='') %}
<div class="my-4 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-blue-800">{{ title }}</h4>
<p class="mt-1 text-sm text-blue-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Warning callout #}
{% macro warning(title='Warning', content='') %}
<div class="my-4 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-amber-800">{{ title }}</h4>
<p class="mt-1 text-sm text-amber-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Error callout #}
{% macro error(title='Important', content='') %}
<div class="my-4 p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-red-800">{{ title }}</h4>
<p class="mt-1 text-sm text-red-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Tip callout #}
{% macro tip(title='Tip', content='') %}
<div class="my-4 p-4 bg-green-50 border-l-4 border-green-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-green-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-green-800">{{ title }}</h4>
<p class="mt-1 text-sm text-green-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}

View File

@ -0,0 +1,47 @@
{# Code block macro with syntax highlighting and copy button #}
{% macro code(content, language='', filename='', show_line_numbers=false) %}
<div class="my-4 relative group">
{% if filename %}
<div class="bg-gray-800 text-gray-400 text-xs px-4 py-2 rounded-t-lg border-b border-gray-700">
{{ filename }}
</div>
{% endif %}
<div class="relative">
<pre class="{% if filename %}rounded-t-none{% endif %} bg-gray-900 text-gray-100 text-sm p-4 rounded-lg overflow-x-auto"><code class="language-{{ language }}">{{ content }}</code></pre>
<button type="button"
onclick="copyCode(this)"
data-code="{{ content|e }}"
class="absolute top-2 right-2 p-2 text-gray-400 hover:text-white bg-gray-800 rounded opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy code">
<svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
{% endmacro %}
{# Inline code #}
{% macro inline(content) %}
<code class="px-1.5 py-0.5 bg-gray-100 text-gray-800 text-sm font-mono rounded">{{ content }}</code>
{% endmacro %}
{# Install command (special styling) #}
{% macro install(command) %}
<div class="my-4 flex items-center bg-gray-100 rounded-lg p-4 group">
<span class="text-gray-400 mr-2 select-none">$</span>
<code class="flex-1 text-gray-800 font-mono text-sm">{{ command }}</code>
<button type="button"
onclick="copyToClipboard('{{ command }}')"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy command">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
{% endmacro %}

View File

@ -0,0 +1,148 @@
{# Cookie consent banner #}
<div id="consent-banner"
class="fixed bottom-0 inset-x-0 z-50 bg-white border-t border-gray-200 shadow-lg transform transition-transform duration-300"
role="dialog"
aria-labelledby="consent-title"
aria-describedby="consent-description">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="flex-1">
<h3 id="consent-title" class="text-sm font-semibold text-gray-900">
We value your privacy
</h3>
<p id="consent-description" class="mt-1 text-sm text-gray-600">
We use cookies to improve your experience and for analytics.
You can choose to accept all cookies or manage your preferences.
<a href="{{ url_for('web.privacy') }}" class="text-indigo-600 hover:text-indigo-800 underline">Learn more</a>
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<button type="button"
onclick="openConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Manage preferences
</button>
<button type="button"
onclick="rejectConsent()"
class="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
Reject all
</button>
<button type="button"
onclick="acceptAllConsent()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Accept all
</button>
</div>
</div>
</div>
</div>
<!-- Consent preferences modal -->
<div id="consent-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-modal="true"
role="dialog"
aria-labelledby="consent-modal-title">
<div class="min-h-screen px-4 text-center">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeConsentPreferences()"></div>
<!-- Modal -->
<div class="inline-block w-full max-w-lg my-8 text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="p-6">
<h3 id="consent-modal-title" class="text-lg font-semibold text-gray-900">
Cookie Preferences
</h3>
<p class="mt-2 text-sm text-gray-600">
Choose which cookies you want to accept. Essential cookies cannot be disabled as they are required for the site to function.
</p>
<div class="mt-6 space-y-4">
<!-- Essential cookies (always on) -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h4 class="text-sm font-medium text-gray-900">Essential cookies</h4>
<p class="mt-1 text-sm text-gray-500">Required for the website to function properly.</p>
</div>
<input type="checkbox" checked disabled class="mt-1 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<!-- Analytics cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-analytics" class="text-sm font-medium text-gray-900 cursor-pointer">Analytics cookies</label>
<p class="mt-1 text-sm text-gray-500">Help us understand how visitors use our site.</p>
</div>
<input type="checkbox"
id="consent-analytics"
name="analytics"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
<!-- Advertising cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-ads" class="text-sm font-medium text-gray-900 cursor-pointer">Advertising cookies</label>
<p class="mt-1 text-sm text-gray-500">Used to show relevant ads and support the project.</p>
</div>
<input type="checkbox"
id="consent-ads"
name="ads"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Cancel
</button>
<button type="button"
onclick="saveConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Save preferences
</button>
</div>
</div>
</div>
</div>
<script>
function acceptAllConsent() {
saveConsent(true, true);
}
function rejectConsent() {
saveConsent(false, false);
}
function openConsentPreferences() {
document.getElementById('consent-modal').classList.remove('hidden');
}
function closeConsentPreferences() {
document.getElementById('consent-modal').classList.add('hidden');
}
function saveConsentPreferences() {
const analytics = document.getElementById('consent-analytics').checked;
const ads = document.getElementById('consent-ads').checked;
saveConsent(analytics, ads);
closeConsentPreferences();
}
function saveConsent(analytics, ads) {
fetch('/api/v1/consent', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({analytics: analytics, ads: ads})
}).then(() => {
document.getElementById('consent-banner').style.transform = 'translateY(100%)';
setTimeout(() => {
document.getElementById('consent-banner').remove();
}, 300);
});
}
</script>

View File

@ -0,0 +1,58 @@
{# Contributor card macro #}
{% macro contributor_card(contributor=none, display_name=none, slug=none, bio=none, website=none, verified=none, featured=false) %}
{% if contributor is none %}
{% set contributor = {
"display_name": display_name,
"slug": slug,
"bio": bio,
"website": website,
"verified": verified
} %}
{% endif %}
{% if featured %}
<!-- Featured contributor (horizontal layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 flex items-center space-x-6">
<!-- Avatar -->
<div class="flex-shrink-0 w-16 h-16 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center">
<span class="text-white text-2xl font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
<p class="mt-2 text-gray-600 line-clamp-2">{{ contributor.bio or 'Active community member and tool creator.' }}</p>
</div>
<!-- Actions -->
<div class="flex-shrink-0">
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="inline-flex items-center px-4 py-2 border border-indigo-600 text-sm font-medium rounded-md text-indigo-600 hover:bg-indigo-50 transition-colors">
View Profile
</a>
</div>
</article>
{% else %}
<!-- Regular contributor card (vertical layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-4 text-center">
<!-- Avatar -->
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center mx-auto">
<span class="text-white text-lg font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<h3 class="mt-3 font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
{% if contributor.bio %}
<p class="mt-2 text-sm text-gray-600 line-clamp-2">{{ contributor.bio }}</p>
{% endif %}
<!-- Link -->
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="mt-3 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
View Profile
</a>
</article>
{% endif %}
{% endmacro %}

View File

@ -0,0 +1,59 @@
<footer class="bg-slate-800 text-white mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="{{ url_for('web.index') }}" class="text-xl font-bold">SmartTools</a>
<p class="mt-2 text-gray-400 text-sm max-w-md">
Build custom AI commands in YAML. Create Unix-style pipeable tools that work with any AI provider.
Provider-agnostic, composable, and community-driven.
</p>
<div class="mt-4 flex space-x-4">
<a href="https://github.com/rob/smarttools" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-white transition-colors" aria-label="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
</a>
<a href="https://twitter.com/smarttools" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-white transition-colors" aria-label="Twitter">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
</svg>
</a>
</div>
</div>
<!-- Resources -->
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider">Resources</h3>
<ul class="mt-4 space-y-2">
<li><a href="{{ url_for('web.docs') }}" class="text-gray-400 hover:text-white text-sm">Documentation</a></li>
<li><a href="{{ url_for('web.tutorials') }}" class="text-gray-400 hover:text-white text-sm">Tutorials</a></li>
<li><a href="{{ url_for('web.tools_browse') }}" class="text-gray-400 hover:text-white text-sm">Registry</a></li>
<li><a href="{{ url_for('web.community') }}" class="text-gray-400 hover:text-white text-sm">Community</a></li>
</ul>
</div>
<!-- Legal -->
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider">Legal</h3>
<ul class="mt-4 space-y-2">
<li><a href="{{ url_for('web.about') }}" class="text-gray-400 hover:text-white text-sm">About</a></li>
<li><a href="{{ url_for('web.privacy') }}" class="text-gray-400 hover:text-white text-sm">Privacy Policy</a></li>
<li><a href="{{ url_for('web.terms') }}" class="text-gray-400 hover:text-white text-sm">Terms of Service</a></li>
<li><a href="{{ url_for('web.donate') }}" class="text-gray-400 hover:text-white text-sm">Donate</a></li>
</ul>
</div>
</div>
<div class="mt-8 pt-8 border-t border-slate-700 flex flex-col sm:flex-row justify-between items-center">
<p class="text-gray-400 text-sm">
&copy; {{ now().year }} SmartTools. Open source under MIT License.
</p>
<p class="text-gray-500 text-xs mt-2 sm:mt-0">
Made with care for the developer community.
</p>
</div>
</div>
</footer>

View File

@ -0,0 +1,169 @@
{# Reusable form macros #}
{# Text input field #}
{% macro text_input(name, label, type='text', placeholder='', value='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<input type="{{ type }}"
name="{{ name }}"
id="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Textarea field #}
{% macro textarea(name, label, placeholder='', value='', required=false, rows=4, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<textarea name="{{ name }}"
id="{{ name }}"
rows="{{ rows }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-y
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">{{ value }}</textarea>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Select dropdown #}
{% macro select(name, label, options, selected='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<select name="{{ name }}"
id="{{ name }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors bg-white
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% for value, label in options %}
<option value="{{ value }}" {% if value == selected %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Checkbox #}
{% macro checkbox(name, label, checked=false, help=none) %}
<div class="mb-4 flex items-start">
<input type="checkbox"
name="{{ name }}"
id="{{ name }}"
{% if checked %}checked{% endif %}
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<div class="ml-3">
<label for="{{ name }}" class="text-sm font-medium text-gray-700">{{ label }}</label>
{% if help %}
<p class="text-sm text-gray-500">{{ help }}</p>
{% endif %}
</div>
</div>
{% endmacro %}
{# Primary button #}
{% macro button_primary(text, type='submit', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Secondary button #}
{% macro button_secondary(text, type='button', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border-2 border-cyan-500 text-base font-medium rounded-md text-cyan-600 bg-transparent hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Danger button #}
{% macro button_danger(text, type='button', name=none, disabled=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Ghost/link button #}
{% macro button_ghost(text, href='#') %}
<a href="{{ href }}"
class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
{{ text }}
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% endmacro %}
{# Form error alert #}
{% macro form_errors(errors) %}
{% if errors %}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">There were errors with your submission</h3>
<ul class="mt-2 text-sm text-red-700 list-disc list-inside">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% endmacro %}
{# Success alert #}
{% macro success_alert(message) %}
{% if message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg" role="alert">
<div class="flex items-center">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="ml-3 text-sm font-medium text-green-800">{{ message }}</p>
</div>
</div>
{% endif %}
{% endmacro %}

View File

@ -0,0 +1,183 @@
<header class="bg-slate-800 text-white sticky top-0 z-40">
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" aria-label="Main navigation">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex-shrink-0">
<a href="{{ url_for('web.index') }}" class="text-xl font-bold hover:text-indigo-400 transition-colors">
SmartTools
</a>
</div>
<!-- Desktop navigation -->
<div class="hidden md:flex items-center space-x-1">
<a href="{{ url_for('web.docs') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/docs') %}bg-slate-700{% endif %}">
Docs
</a>
<a href="{{ url_for('web.tutorials') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/tutorials') %}bg-slate-700{% endif %}">
Tutorials
</a>
<a href="{{ url_for('web.tools_browse') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}">
Registry
</a>
<a href="{{ url_for('web.community') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
</a>
<a href="{{ url_for('web.about') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path == '/about' %}bg-slate-700{% endif %}">
About
</a>
</div>
<!-- Right side: Search + Auth -->
<div class="hidden md:flex items-center space-x-4">
<!-- Search button -->
<button type="button"
onclick="openSearch()"
class="p-2 rounded-md hover:bg-slate-700 transition-colors"
aria-label="Search">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
<!-- Donate button -->
<a href="{{ url_for('web.donate') }}"
class="px-3 py-2 rounded-md text-sm font-medium bg-indigo-600 hover:bg-indigo-700 transition-colors">
Donate
</a>
<!-- Auth links -->
{% if session.get('user') %}
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-slate-700 transition-colors">
<span class="text-sm font-medium">{{ session.user.display_name }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div x-show="open"
@click.away="open = false"
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<a href="{{ url_for('web.dashboard') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
<a href="{{ url_for('web.dashboard_settings') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a>
<hr class="my-1">
<form action="{{ url_for('web.logout') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Sign out
</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('web.login') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors">
Sign in
</a>
{% endif %}
</div>
<!-- Mobile menu button -->
<div class="md:hidden">
<button type="button"
onclick="toggleMobileMenu()"
class="p-2 rounded-md hover:bg-slate-700 transition-colors"
aria-expanded="false"
aria-controls="mobile-menu"
aria-label="Toggle menu">
<svg id="menu-icon-open" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg id="menu-icon-close" class="w-6 h-6 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden pb-4">
<div class="space-y-1">
<a href="{{ url_for('web.docs') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/docs') %}bg-slate-700{% endif %}">
Docs
</a>
<a href="{{ url_for('web.tutorials') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/tutorials') %}bg-slate-700{% endif %}">
Tutorials
</a>
<a href="{{ url_for('web.tools_browse') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}">
Registry
</a>
<a href="{{ url_for('web.community') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
</a>
<a href="{{ url_for('web.about') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path == '/about' %}bg-slate-700{% endif %}">
About
</a>
<hr class="border-slate-600 my-2">
<a href="{{ url_for('web.donate') }}"
class="block px-3 py-2 rounded-md text-base font-medium bg-indigo-600 hover:bg-indigo-700 mx-3">
Donate
</a>
{% if session.get('user') %}
<a href="{{ url_for('web.dashboard') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700">
Dashboard
</a>
<form action="{{ url_for('web.logout') }}" method="POST" class="px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-base font-medium hover:text-indigo-400">
Sign out
</button>
</form>
{% else %}
<a href="{{ url_for('web.login') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700">
Sign in
</a>
{% endif %}
</div>
</div>
</nav>
</header>
<!-- Search modal (hidden by default) -->
<div id="search-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true">
<div class="min-h-screen px-4 text-center">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeSearch()"></div>
<!-- Modal content -->
<div class="inline-block w-full max-w-2xl my-8 text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="p-4">
<div class="relative">
<svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text"
id="search-input"
placeholder="Search tools, docs, tutorials..."
class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autocomplete="off">
</div>
<div id="search-results" class="mt-4 max-h-96 overflow-y-auto">
<!-- Search results will be inserted here -->
<p class="text-sm text-gray-500 text-center py-8">Start typing to search...</p>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 rounded-b-lg flex justify-between items-center text-sm text-gray-500">
<span>Press <kbd class="px-2 py-1 bg-gray-200 rounded">ESC</kbd> to close</span>
<button onclick="closeSearch()" class="text-gray-500 hover:text-gray-700">Close</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,70 @@
{# Tool card macro #}
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none) %}
{% if tool is none %}
{% set tool = {
"owner": owner,
"name": name,
"description": description,
"category": category,
"downloads": downloads,
"version": version
} %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-4 relative">
<!-- Category badge -->
{% if tool.category %}
<span class="absolute top-3 right-3 px-2 py-1 bg-cyan-500 text-white text-xs font-medium rounded">
{{ tool.category }}
</span>
{% endif %}
<!-- Tool icon and name -->
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold text-sm">{{ tool.name[0]|upper }}</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="hover:text-indigo-600 transition-colors">
{{ tool.name }}
</a>
</h3>
<p class="text-sm text-gray-500">by {{ tool.owner }}</p>
</div>
</div>
<!-- Description -->
<p class="mt-3 text-sm text-gray-600 line-clamp-2">
{{ tool.description or 'No description available.' }}
</p>
<!-- Meta info -->
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ tool.downloads|default(0) }} downloads
</span>
<span class="text-xs text-gray-400">v{{ tool.version }}</span>
</div>
<!-- Install command -->
<div class="mt-4">
<div class="flex items-center bg-gray-100 rounded px-3 py-2 group">
<code class="text-xs text-gray-700 flex-1 truncate font-mono">
smarttools install {{ tool.owner }}/{{ tool.name }}
</code>
<button type="button"
onclick="copyToClipboard('smarttools install {{ tool.owner }}/{{ tool.name }}')"
class="ml-2 p-1 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy install command">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
</article>
{% endmacro %}

View File

@ -0,0 +1,53 @@
{# Tutorial card macro #}
{% macro tutorial_card(tutorial=none, title=none, description=none, href=none, slug=none, thumbnail=none, step_number=none) %}
{% if tutorial is none %}
{% set tutorial = {
"slug": slug,
"title": title,
"description": description,
"thumbnail": thumbnail
} %}
{% endif %}
{% if href is none and tutorial.slug %}
{% set href = url_for('web.tutorial', slug=tutorial.slug) %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<!-- Thumbnail (optional) -->
{% if tutorial.thumbnail %}
<div class="aspect-video bg-gray-100 relative">
<img src="{{ tutorial.thumbnail }}"
alt="{{ tutorial.title }}"
class="w-full h-full object-cover"
loading="lazy">
</div>
{% else %}
<div class="aspect-video bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center">
<svg class="w-12 h-12 text-white opacity-75" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
{% endif %}
<div class="p-4">
{% if step_number %}
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-500 mb-2">Step {{ step_number }}</p>
{% endif %}
<h3 class="text-lg font-semibold text-gray-900">
<a href="{{ href or '#' }}"
class="hover:text-indigo-600 transition-colors">
{{ tutorial.title }}
</a>
</h3>
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
{{ tutorial.description or 'Learn how to use SmartTools effectively.' }}
</p>
<a href="{{ href or '#' }}"
class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
Read More
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</article>
{% endmacro %}

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Dashboard Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-1 text-gray-600">Welcome back, {{ user.display_name }}</p>
{% endblock %}
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Sidebar Navigation -->
<aside>
<nav class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<a href="{{ url_for('web.dashboard') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'overview' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
Overview
</a>
<a href="{{ url_for('web.dashboard_tools') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tools' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
My Tools
</a>
<a href="{{ url_for('web.dashboard_tokens') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tokens' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
API Tokens
</a>
<a href="{{ url_for('web.dashboard_settings') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'settings' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Settings
</a>
</nav>
<!-- Quick Stats (mobile hidden) -->
<div class="hidden lg:block mt-6 bg-white rounded-lg border border-gray-200 p-4">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Quick Stats</h3>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Tools</dt>
<dd class="text-sm font-medium text-gray-900">{{ stats.tools_count if stats else 0 }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Downloads</dt>
<dd class="text-sm font-medium text-gray-900">{{ (stats.total_downloads if stats else 0)|format_number }}</dd>
</div>
</dl>
</div>
</aside>
<!-- Main Content -->
<main class="lg:col-span-3">
{% block dashboard_content %}{% endblock %}
</main>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,135 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'overview' %}
{% block title %}Dashboard - SmartTools{% endblock %}
{% block dashboard_content %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-indigo-100 rounded-lg">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Published Tools</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tools_count }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Downloads</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.total_downloads|format_number }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-cyan-100 rounded-lg">
<svg class="w-6 h-6 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">API Tokens</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tokens_count }}</p>
</div>
</div>
</div>
</div>
<!-- Recent Tools -->
<div class="bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Your Tools</h2>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
+ Publish New Tool
</a>
</div>
{% if tools %}
<ul class="divide-y divide-gray-200">
{% for tool in tools[:5] %}
<li class="px-6 py-4 flex items-center justify-between">
<div>
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="font-medium text-gray-900 hover:text-indigo-600">
{{ tool.name }}
</a>
<p class="text-sm text-gray-500">v{{ tool.version }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-900">{{ tool.downloads|format_number }} downloads</p>
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
{% if tools|length > 5 %}
<div class="px-6 py-4 border-t border-gray-200">
<a href="{{ url_for('web.dashboard_tools') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
View all {{ tools|length }} tools
</a>
</div>
{% endif %}
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<p class="mt-4 text-gray-600">You haven't published any tools yet.</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Publish Your First Tool
</a>
</div>
{% endif %}
</div>
<!-- Recent Activity -->
{% if recent_activity %}
<div class="mt-6 bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Recent Activity</h2>
</div>
<ul class="divide-y divide-gray-200">
{% for activity in recent_activity[:5] %}
<li class="px-6 py-4 flex items-start">
<div class="flex-shrink-0">
{% if activity.type == 'download' %}
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
{% elif activity.type == 'publish' %}
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-4">
<p class="text-sm text-gray-900">{{ activity.description }}</p>
<p class="text-xs text-gray-500">{{ activity.timestamp|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,254 @@
{% extends "dashboard/base.html" %}
{% from "components/forms.html" import text_input, textarea, button_primary, form_errors, success_alert %}
{% set active_page = 'settings' %}
{% block title %}Settings - SmartTools Dashboard{% endblock %}
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
<p class="mt-1 text-gray-600">Manage your profile and account settings</p>
{% endblock %}
{% block dashboard_content %}
{{ success_alert(success_message) }}
{{ form_errors(errors) }}
<!-- Profile Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Profile</h2>
<p class="text-sm text-gray-600">This information will be displayed on your public profile.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="profile">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">
Display name
</label>
<input type="text"
name="display_name"
id="display_name"
value="{{ user.display_name }}"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<div class="flex">
<span class="inline-flex items-center px-3 text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-lg">
@
</span>
<input type="text"
name="slug"
id="slug"
value="{{ user.slug }}"
disabled
class="flex-1 px-4 py-2 border border-gray-300 rounded-r-lg bg-gray-50 text-gray-500">
</div>
<p class="mt-1 text-xs text-gray-500">Username cannot be changed</p>
</div>
</div>
<div class="mt-6">
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">
Bio
</label>
<textarea name="bio"
id="bio"
rows="3"
placeholder="Tell others a bit about yourself..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y">{{ user.bio or '' }}</textarea>
<p class="mt-1 text-xs text-gray-500">Brief description for your profile. Max 500 characters.</p>
</div>
<div class="mt-6">
<label for="website" class="block text-sm font-medium text-gray-700 mb-1">
Website
</label>
<input type="url"
name="website"
id="website"
value="{{ user.website or '' }}"
placeholder="https://example.com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Save Profile
</button>
</div>
</form>
</div>
<!-- Email Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Email</h2>
<p class="text-sm text-gray-600">Your email address for account notifications.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ user.email }}</p>
{% if user.verified %}
<p class="text-xs text-green-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
Verified
</p>
{% else %}
<p class="text-xs text-amber-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Not verified
</p>
{% endif %}
</div>
{% if not user.verified %}
<button type="button"
onclick="resendVerification()"
class="text-sm text-indigo-600 hover:text-indigo-800">
Resend verification
</button>
{% endif %}
</div>
</div>
</div>
<!-- Password Change -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Change Password</h2>
<p class="text-sm text-gray-600">Update your password to keep your account secure.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="password">
<div class="space-y-4 max-w-md">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-1">
Current password
</label>
<input type="password"
name="current_password"
id="current_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-1">
New password
</label>
<input type="password"
name="new_password"
id="new_password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<p class="mt-1 text-xs text-gray-500">Minimum 8 characters</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">
Confirm new password
</label>
<input type="password"
name="confirm_password"
id="confirm_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Update Password
</button>
</div>
</form>
</div>
<!-- Danger Zone -->
<div class="bg-white rounded-lg border border-red-200 overflow-hidden">
<div class="px-6 py-4 border-b border-red-200 bg-red-50">
<h2 class="text-lg font-semibold text-red-800">Danger Zone</h2>
<p class="text-sm text-red-600">Irreversible actions that affect your account.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">Delete Account</p>
<p class="text-sm text-gray-500">Permanently delete your account and all associated data.</p>
</div>
<button type="button"
onclick="confirmDeleteAccount()"
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-md hover:bg-red-50">
Delete Account
</button>
</div>
</div>
</div>
<script>
async function resendVerification() {
try {
const response = await fetch('/api/v1/me/resend-verification', {method: 'POST'});
if (response.ok) {
alert('Verification email sent! Please check your inbox.');
} else {
alert('Failed to send verification email. Please try again later.');
}
} catch (err) {
alert('Failed to send verification email. Please try again later.');
}
}
function confirmDeleteAccount() {
const confirmed = confirm(
'Are you absolutely sure you want to delete your account?\n\n' +
'This will permanently delete:\n' +
'- Your profile\n' +
'- All your published tools\n' +
'- All your API tokens\n\n' +
'This action cannot be undone.'
);
if (confirmed) {
const doubleConfirm = prompt('Type "DELETE" to confirm account deletion:');
if (doubleConfirm === 'DELETE') {
deleteAccount();
}
}
}
async function deleteAccount() {
try {
const response = await fetch('/api/v1/me', {method: 'DELETE'});
if (response.ok) {
window.location.href = '/?deleted=1';
} else {
alert('Failed to delete account. Please try again or contact support.');
}
} catch (err) {
alert('Failed to delete account. Please try again or contact support.');
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,312 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tokens' %}
{% block title %}API Tokens - SmartTools Dashboard{% endblock %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">API Tokens</h1>
<p class="mt-1 text-gray-600">Manage tokens for CLI and API access</p>
</div>
<button type="button"
onclick="openCreateTokenModal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Create Token
</button>
</div>
{% endblock %}
{% block dashboard_content %}
<!-- Info Banner -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-blue-800">About API Tokens</h4>
<p class="mt-1 text-sm text-blue-700">
API tokens are used to authenticate with the SmartTools registry from the CLI.
Use <code class="px-1 bg-blue-100 rounded">smarttools auth login</code> to authenticate,
or set the <code class="px-1 bg-blue-100 rounded">SMARTTOOLS_TOKEN</code> environment variable.
</p>
</div>
</div>
</div>
{% if tokens %}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Used
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for token in tokens %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-10 h-10 bg-cyan-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">{{ token.name }}</p>
<p class="text-xs text-gray-500 font-mono">st_...{{ token.token_suffix }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ token.created_at|date_format }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ token.last_used_at|timeago if token.last_used_at else 'Never' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if token.revoked_at %}
<span class="px-2 py-1 text-xs font-medium text-red-800 bg-red-100 rounded-full">
Revoked
</span>
{% else %}
<span class="px-2 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Active
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{% if not token.revoked_at %}
<button type="button"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
class="text-red-600 hover:text-red-900">
Revoke
</button>
{% else %}
<span class="text-gray-400">Revoked {{ token.revoked_at|timeago }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<!-- Empty State -->
<div class="bg-white rounded-lg border border-gray-200 text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No API tokens</h3>
<p class="mt-2 text-gray-600 max-w-sm mx-auto">
Create an API token to authenticate with the registry from the command line.
</p>
<button type="button"
onclick="openCreateTokenModal()"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Create Your First Token
</button>
</div>
{% endif %}
<!-- Create Token Modal -->
<div id="create-token-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeCreateTokenModal()"></div>
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<form id="create-token-form" onsubmit="createToken(event)">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900">Create API Token</h3>
<p class="mt-2 text-sm text-gray-600">
Give your token a descriptive name so you can identify it later.
</p>
<div class="mt-4">
<label for="token-name" class="block text-sm font-medium text-gray-700 mb-1">
Token name
</label>
<input type="text"
name="name"
id="token-name"
required
placeholder="e.g., Laptop CLI, CI/CD Pipeline"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeCreateTokenModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Create Token
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Token Created Modal (shows the token once) -->
<div id="token-created-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50"></div>
<div class="inline-block w-full max-w-lg my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<div class="p-6">
<div class="flex items-center mb-4">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h3 class="ml-3 text-lg font-semibold text-gray-900">Token Created</h3>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="ml-3 text-sm text-amber-800">
<strong>Copy your token now.</strong> You won't be able to see it again!
</p>
</div>
</div>
<div class="bg-gray-100 rounded-lg p-4">
<div class="flex items-center justify-between">
<code id="new-token-value" class="text-sm font-mono text-gray-800 break-all"></code>
<button type="button"
onclick="copyNewToken()"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 flex-shrink-0">
<svg id="copy-token-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg id="check-token-icon" class="w-5 h-5 hidden text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
<p class="mt-4 text-sm text-gray-600">
To use this token, run:
</p>
<pre class="mt-2 bg-gray-900 text-gray-100 text-sm p-3 rounded-lg overflow-x-auto"><code>export SMARTTOOLS_TOKEN="<span id="token-in-export"></span>"</code></pre>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end">
<button type="button"
onclick="closeTokenCreatedModal()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Done
</button>
</div>
</div>
</div>
</div>
<script>
function openCreateTokenModal() {
document.getElementById('create-token-modal').classList.remove('hidden');
document.getElementById('token-name').focus();
}
function closeCreateTokenModal() {
document.getElementById('create-token-modal').classList.add('hidden');
document.getElementById('token-name').value = '';
}
async function createToken(event) {
event.preventDefault();
const form = event.target;
const name = form.querySelector('[name="name"]').value;
try {
const response = await fetch('/api/v1/tokens', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (response.ok) {
const data = await response.json();
closeCreateTokenModal();
showNewToken(data.token);
} else {
const error = await response.json();
alert(error.error || 'Failed to create token');
}
} catch (err) {
alert('Failed to create token. Please try again.');
}
}
function showNewToken(token) {
document.getElementById('new-token-value').textContent = token;
document.getElementById('token-in-export').textContent = token;
document.getElementById('token-created-modal').classList.remove('hidden');
}
function closeTokenCreatedModal() {
document.getElementById('token-created-modal').classList.add('hidden');
window.location.reload();
}
function copyNewToken() {
const token = document.getElementById('new-token-value').textContent;
navigator.clipboard.writeText(token).then(() => {
document.getElementById('copy-token-icon').classList.add('hidden');
document.getElementById('check-token-icon').classList.remove('hidden');
setTimeout(() => {
document.getElementById('copy-token-icon').classList.remove('hidden');
document.getElementById('check-token-icon').classList.add('hidden');
}, 2000);
});
}
async function revokeToken(tokenId, tokenName) {
if (!confirm(`Are you sure you want to revoke "${tokenName}"? This cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert(error.error || 'Failed to revoke token');
}
} catch (err) {
alert('Failed to revoke token. Please try again.');
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,234 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tools' %}
{% block title %}My Tools - SmartTools Dashboard{% endblock %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">My Tools</h1>
<p class="mt-1 text-gray-600">Manage your published tools</p>
</div>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Publish New Tool
</a>
</div>
{% endblock %}
{% block dashboard_content %}
{% if tools %}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tool
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Version
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Downloads
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for tool in tools %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div class="ml-4">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="text-sm font-medium text-gray-900 hover:text-indigo-600">
{{ tool.name }}
</a>
<p class="text-sm text-gray-500 truncate max-w-xs">{{ tool.description or 'No description' }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-900">v{{ tool.version }}</span>
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ tool.downloads|format_number }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if tool.deprecated %}
<span class="px-2 py-1 text-xs font-medium text-amber-800 bg-amber-100 rounded-full">
Deprecated
</span>
{% else %}
<span class="px-2 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Active
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="text-indigo-600 hover:text-indigo-900">View</a>
<button type="button"
onclick="openDeprecateModal('{{ tool.owner }}', '{{ tool.name }}', {{ 'true' if tool.deprecated else 'false' }})"
class="text-gray-400 hover:text-amber-600">
{% if tool.deprecated %}Restore{% else %}Deprecate{% endif %}
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pending PRs Section -->
{% if pending_prs %}
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Pending Publications</h2>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<ul class="divide-y divide-gray-200">
{% for pr in pending_prs %}
<li class="px-6 py-4 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ pr.owner }}/{{ pr.name }} v{{ pr.version }}</p>
<p class="text-xs text-gray-500">Submitted {{ pr.created_at|timeago }}</p>
</div>
<div class="flex items-center gap-3">
<span class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
{{ pr.status }}
</span>
<a href="{{ pr.pr_url }}"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-indigo-600 hover:text-indigo-800">
View PR
</a>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% else %}
<!-- Empty State -->
<div class="bg-white rounded-lg border border-gray-200 text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No tools yet</h3>
<p class="mt-2 text-gray-600 max-w-sm mx-auto">
You haven't published any tools. Create your first tool and share it with the community.
</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Learn How to Publish
</a>
</div>
{% endif %}
<!-- Deprecate Modal -->
<div id="deprecate-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeDeprecateModal()"></div>
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<form id="deprecate-form" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="owner" id="deprecate-owner">
<input type="hidden" name="name" id="deprecate-name">
<div class="p-6">
<h3 id="deprecate-title" class="text-lg font-semibold text-gray-900">Deprecate Tool</h3>
<p id="deprecate-desc" class="mt-2 text-sm text-gray-600">
Mark this tool as deprecated. Users will see a warning when viewing or installing it.
</p>
<div id="deprecate-fields" class="mt-4 space-y-4">
<div>
<label for="deprecated_message" class="block text-sm font-medium text-gray-700 mb-1">
Deprecation message (optional)
</label>
<textarea name="deprecated_message"
id="deprecated_message"
rows="2"
placeholder="e.g., This tool has been superseded by..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"></textarea>
</div>
<div>
<label for="replacement" class="block text-sm font-medium text-gray-700 mb-1">
Replacement tool (optional)
</label>
<input type="text"
name="replacement"
id="replacement"
placeholder="owner/tool-name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeDeprecateModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="submit"
id="deprecate-submit"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-md hover:bg-amber-700">
Deprecate
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openDeprecateModal(owner, name, isDeprecated) {
document.getElementById('deprecate-owner').value = owner;
document.getElementById('deprecate-name').value = name;
if (isDeprecated) {
document.getElementById('deprecate-title').textContent = 'Restore Tool';
document.getElementById('deprecate-desc').textContent = 'Remove the deprecation notice from this tool.';
document.getElementById('deprecate-fields').classList.add('hidden');
document.getElementById('deprecate-submit').textContent = 'Restore';
document.getElementById('deprecate-submit').classList.remove('bg-amber-600', 'hover:bg-amber-700');
document.getElementById('deprecate-submit').classList.add('bg-green-600', 'hover:bg-green-700');
document.getElementById('deprecate-form').action = `/api/v1/tools/${owner}/${name}/undeprecate`;
} else {
document.getElementById('deprecate-title').textContent = 'Deprecate Tool';
document.getElementById('deprecate-desc').textContent = 'Mark this tool as deprecated. Users will see a warning.';
document.getElementById('deprecate-fields').classList.remove('hidden');
document.getElementById('deprecate-submit').textContent = 'Deprecate';
document.getElementById('deprecate-submit').classList.remove('bg-green-600', 'hover:bg-green-700');
document.getElementById('deprecate-submit').classList.add('bg-amber-600', 'hover:bg-amber-700');
document.getElementById('deprecate-form').action = `/api/v1/tools/${owner}/${name}/deprecate`;
}
document.getElementById('deprecate-modal').classList.remove('hidden');
}
function closeDeprecateModal() {
document.getElementById('deprecate-modal').classList.add('hidden');
}
</script>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Page Not Found - SmartTools{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center px-4">
<div class="text-center max-w-md">
<p class="text-6xl font-bold text-indigo-600">404</p>
<h1 class="mt-4 text-3xl font-bold text-gray-900">Page not found</h1>
<p class="mt-4 text-gray-600">
Sorry, we couldn't find the page you're looking for. It may have been moved or deleted.
</p>
<div class="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.home') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Go Home
</a>
<a href="{{ url_for('web.tools') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Browse Tools
</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Server Error - SmartTools{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center px-4">
<div class="text-center max-w-md">
<p class="text-6xl font-bold text-red-600">500</p>
<h1 class="mt-4 text-3xl font-bold text-gray-900">Something went wrong</h1>
<p class="mt-4 text-gray-600">
We're sorry, but something went wrong on our end. Please try again later or contact support if the problem persists.
</p>
<div class="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.home') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Go Home
</a>
<a href="https://github.com/your-org/smarttools/issues"
target="_blank"
rel="noopener noreferrer"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Report Issue
</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}About - SmartTools{% endblock %}
{% block meta_description %}Learn about SmartTools, the open-source platform for building custom AI-powered command-line tools.{% endblock %}
{% block content %}
<div class="bg-white">
<!-- Hero -->
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<h1 class="text-4xl font-bold text-gray-900">About SmartTools</h1>
<p class="mt-6 text-xl text-gray-600">
An open-source platform for building and sharing AI-powered command-line tools.
</p>
</div>
<!-- Mission -->
<div class="bg-gray-50 py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Our Mission</h2>
<div class="prose prose-lg prose-indigo max-w-none">
<p>
SmartTools was created with a simple belief: powerful AI tools should be accessible to everyone,
not just those with extensive programming knowledge or expensive API subscriptions.
</p>
<p>
We're building a <strong>universally accessible development ecosystem</strong> that empowers
regular people to collaborate and build upon each other's progress rather than compete.
</p>
<p>
Our platform follows the Unix philosophy: simple, composable tools that do one thing well.
With SmartTools, you can create custom AI commands using simple YAML configuration,
chain them together, and share them with the community.
</p>
</div>
</div>
</div>
<!-- Values -->
<div class="py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-2xl font-bold text-gray-900 mb-8">Our Values</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Open Source</h3>
<p class="text-gray-600">
SmartTools is MIT licensed and open source. We believe in transparency and community ownership.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-cyan-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Community First</h3>
<p class="text-gray-600">
We prioritize collaboration over competition. Share tools, learn from others, build together.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Privacy Respecting</h3>
<p class="text-gray-600">
Your data stays yours. We collect minimal analytics and never sell user information.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Provider Agnostic</h3>
<p class="text-gray-600">
Works with any AI provider. Use Claude, GPT, local models, or any CLI-accessible AI.
</p>
</div>
</div>
</div>
</div>
<!-- Sustainability -->
<div class="bg-gray-50 py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Sustainability</h2>
<div class="prose prose-lg prose-indigo max-w-none">
<p>
SmartTools is committed to long-term sustainability. Revenue from optional ads
and donations supports:
</p>
<ul>
<li>Maintaining and expanding the project</li>
<li>Hosting infrastructure for the registry</li>
<li>Future hosting of AI models for users with less access to paid services</li>
<li>Building a sustainable, community-first platform</li>
</ul>
<p>
<a href="{{ url_for('web.donate') }}" class="text-indigo-600 hover:text-indigo-800">
Support the project
</a>
</p>
</div>
</div>
</div>
<!-- Open Source -->
<div class="py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Contribute</h2>
<p class="text-lg text-gray-600 mb-8">
SmartTools is open source and welcomes contributions of all kinds.
</p>
<a href="https://github.com/your-org/smarttools"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-6 py-3 text-base font-medium text-white bg-gray-900 rounded-md hover:bg-gray-800">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
View on GitHub
</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ title }} - SmartTools{% endblock %}
{% block content %}
<section class="bg-white py-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold text-gray-900">{{ title }}</h1>
<p class="mt-4 text-gray-600">{{ body }}</p>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,208 @@
{% extends "base.html" %}
{% from "components/callouts.html" import info, warning, tip %}
{% block title %}{{ page.title }} - SmartTools Docs{% endblock %}
{% block meta_description %}{{ page.description or page.title }}{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="lg:grid lg:grid-cols-12 lg:gap-8">
<!-- Left Sidebar - TOC -->
<aside class="hidden lg:block lg:col-span-3">
<nav class="sticky top-4 bg-white rounded-lg border border-gray-200 p-4 max-h-[calc(100vh-6rem)] overflow-y-auto">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Documentation</h3>
<ul class="space-y-1">
{% for section in toc %}
<li>
<a href="{{ url_for('web.docs', path=section.slug) }}"
class="block px-3 py-2 text-sm rounded-md {{ 'bg-indigo-50 text-indigo-700 font-medium' if current_path == section.slug else 'text-gray-600 hover:bg-gray-50' }}">
{{ section.title }}
</a>
{% if section.children %}
<ul class="ml-4 mt-1 space-y-1">
{% for child in section.children %}
<li>
<a href="{{ url_for('web.docs', path=child.slug) }}"
class="block px-3 py-1.5 text-sm rounded-md {{ 'bg-indigo-50 text-indigo-700 font-medium' if current_path == child.slug else 'text-gray-500 hover:text-gray-700 hover:bg-gray-50' }}">
{{ child.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="lg:col-span-6">
<!-- Mobile TOC Toggle -->
<div class="lg:hidden mb-6">
<button type="button"
onclick="toggleMobileToc()"
class="w-full flex items-center justify-between px-4 py-3 bg-white border border-gray-300 rounded-lg">
<span class="font-medium text-gray-700">On this page</span>
<svg id="toc-chevron" class="w-5 h-5 text-gray-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<nav id="mobile-toc" class="hidden mt-2 bg-white border border-gray-200 rounded-lg p-4">
<ul class="space-y-2">
{% for section in toc %}
<li>
<a href="{{ url_for('web.docs', path=section.slug) }}"
class="block text-sm {{ 'text-indigo-600 font-medium' if current_path == section.slug else 'text-gray-600' }}">
{{ section.title }}
</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
<!-- Breadcrumb -->
<nav class="mb-6 text-sm text-gray-500" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li><a href="{{ url_for('web.docs', path='') }}" class="hover:text-gray-700">Docs</a></li>
{% if page.parent %}
<li class="flex items-center">
<svg class="w-4 h-4 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<a href="{{ url_for('web.docs', path=page.parent.slug) }}" class="hover:text-gray-700">{{ page.parent.title }}</a>
</li>
{% endif %}
<li class="flex items-center">
<svg class="w-4 h-4 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-gray-900 font-medium">{{ page.title }}</span>
</li>
</ol>
</nav>
<!-- Article Content -->
<article class="bg-white rounded-lg border border-gray-200 p-8">
<h1 class="text-3xl font-bold text-gray-900 mb-6">{{ page.title }}</h1>
<div class="prose prose-lg prose-indigo max-w-none">
{{ page.content_html|safe }}
</div>
<!-- Page Navigation -->
<div class="mt-12 pt-8 border-t border-gray-200 flex items-center justify-between">
{% if prev_page %}
<a href="{{ url_for('web.docs', path=prev_page.slug) }}"
class="flex items-center text-indigo-600 hover:text-indigo-800">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
{{ prev_page.title }}
</a>
{% else %}
<div></div>
{% endif %}
{% if next_page %}
<a href="{{ url_for('web.docs', path=next_page.slug) }}"
class="flex items-center text-indigo-600 hover:text-indigo-800">
{{ next_page.title }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% endif %}
</div>
</article>
<!-- Edit on GitHub -->
<div class="mt-6 text-center">
<a href="https://github.com/your-org/smarttools/edit/main/docs/{{ current_path }}.md"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
</svg>
Edit this page on GitHub
</a>
</div>
</main>
<!-- Right Sidebar - On This Page + Ads -->
<aside class="hidden lg:block lg:col-span-3">
<div class="sticky top-4 space-y-6">
<!-- On This Page -->
{% if page.headings %}
<nav class="bg-white rounded-lg border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">On this page</h3>
<ul class="space-y-2 text-sm" id="page-toc">
{% for heading in page.headings %}
<li class="{{ 'ml-4' if heading.level > 2 else '' }}">
<a href="#{{ heading.id }}"
class="text-gray-500 hover:text-gray-900 transition-colors"
data-heading="{{ heading.id }}">
{{ heading.text }}
</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
<!-- Ad Zone -->
{% if show_ads %}
<div class="bg-blue-50 rounded-lg border border-blue-100 p-4">
<p class="text-xs text-gray-500 mb-2">Advertisement</p>
<div id="docs-sidebar-ad" class="min-h-[250px]">
<!-- Ad content -->
</div>
</div>
{% endif %}
</div>
</aside>
</div>
</div>
</div>
<script>
function toggleMobileToc() {
const toc = document.getElementById('mobile-toc');
const chevron = document.getElementById('toc-chevron');
toc.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
// Scroll spy for page TOC
document.addEventListener('DOMContentLoaded', function() {
const headings = document.querySelectorAll('article h2, article h3');
const tocLinks = document.querySelectorAll('#page-toc a');
if (headings.length === 0 || tocLinks.length === 0) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocLinks.forEach(link => {
link.classList.remove('text-indigo-600', 'font-medium');
link.classList.add('text-gray-500');
if (link.dataset.heading === entry.target.id) {
link.classList.remove('text-gray-500');
link.classList.add('text-indigo-600', 'font-medium');
}
});
}
});
}, {rootMargin: '-100px 0px -66%'});
headings.forEach(heading => observer.observe(heading));
});
</script>
{% endblock %}

View File

@ -0,0 +1,221 @@
{% extends "base.html" %}
{% from "components/tool_card.html" import tool_card %}
{% from "components/tutorial_card.html" import tutorial_card %}
{% from "components/contributor_card.html" import contributor_card %}
{% block title %}SmartTools - Build Custom AI Commands in YAML{% endblock %}
{% block meta_description %}Create Unix-style pipeable AI tools with simple YAML configuration. Provider-agnostic, composable, and community-driven.{% endblock %}
{% block content %}
<!-- Hero Section -->
<section class="bg-white py-16 md:py-24">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 leading-tight">
Build Custom AI Commands in YAML
</h1>
<p class="mt-6 text-xl text-gray-600 max-w-3xl mx-auto">
Create Unix-style pipeable tools that work with any AI provider.
Provider-agnostic and composable for ultimate flexibility.
</p>
<!-- Install Command -->
<div class="mt-10 max-w-xl mx-auto">
<div class="flex items-center bg-gray-100 rounded-lg p-4 group">
<span class="text-gray-400 mr-2 select-none">$</span>
<code id="install-command" class="flex-1 text-gray-800 font-mono text-sm md:text-base text-left">pip install smarttools && smarttools init</code>
<button type="button"
onclick="copyInstallCommand()"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Copy install command">
<svg id="copy-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg id="check-icon" class="w-5 h-5 hidden text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
<!-- CTAs -->
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.docs', path='getting-started') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 text-lg font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors shadow-md">
Get Started
</a>
<a href="{{ url_for('web.tutorials') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 text-lg font-medium text-cyan-600 border-2 border-cyan-500 rounded-md hover:bg-cyan-50 transition-colors">
View Tutorials
</a>
</div>
</div>
</section>
<!-- Three Pillars Section -->
<section class="py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-gray-900 text-center mb-12">
Why SmartTools?
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Pillar 1: Easy -->
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-3">Easy to Use</h3>
<p class="text-gray-600">
Simple YAML configuration for quick setup. No complex programming required to get started.
</p>
</div>
<!-- Pillar 2: Powerful -->
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-cyan-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-3">Powerful</h3>
<p class="text-gray-600">
Leverage any AI provider, compose complex multi-step workflows with Python code integration.
</p>
</div>
<!-- Pillar 3: Community -->
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-3">Community</h3>
<p class="text-gray-600">
Share, discover, and contribute to a growing ecosystem of tools built by developers like you.
</p>
</div>
</div>
</div>
</section>
<!-- Featured Tools Section -->
<section class="py-16 bg-white">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-12">
<h2 class="text-3xl font-bold text-gray-900">
Featured Tools & Projects
</h2>
<a href="{{ url_for('web.tools') }}" class="text-indigo-600 hover:text-indigo-800 font-medium flex items-center">
View All
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for tool in featured_tools %}
{{ tool_card(
owner=tool.owner,
name=tool.name,
description=tool.description,
category=tool.category,
downloads=tool.downloads,
version=tool.version
) }}
{% else %}
<!-- Placeholder when no tools -->
<div class="col-span-full text-center py-12 text-gray-500">
<p class="mb-4">No featured tools yet. Be the first to publish!</p>
<a href="{{ url_for('web.docs', path='publishing') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Learn how to publish a tool
</a>
</div>
{% endfor %}
</div>
</div>
</section>
<!-- Getting Started Section -->
<section class="py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-gray-900 text-center mb-12">
Getting Started
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{{ tutorial_card(
title="Basic Setup",
description="Learn how to install SmartTools and configure your first AI provider.",
href=url_for('web.docs', path='getting-started'),
step_number=1
) }}
{{ tutorial_card(
title="Your First Tool",
description="Create a simple AI-powered command that you can use from your terminal.",
href=url_for('web.tutorials_path', path='first-tool'),
step_number=2
) }}
{{ tutorial_card(
title="Advanced Workflows",
description="Combine multiple steps and providers to build powerful automation.",
href=url_for('web.tutorials_path', path='advanced-workflows'),
step_number=3
) }}
</div>
</div>
</section>
<!-- Featured Contributor Section -->
{% if featured_contributor %}
<section class="py-16 bg-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-gray-900 text-center mb-12">
Featured Contributor
</h2>
{{ contributor_card(
display_name=featured_contributor.display_name,
slug=featured_contributor.slug,
bio=featured_contributor.bio_override or featured_contributor.bio,
verified=featured_contributor.verified
) }}
</div>
</section>
{% endif %}
<!-- Ad Zone (Optional) -->
{% if show_ads %}
<section class="py-8 bg-blue-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<p class="text-xs text-gray-500 mb-2">Advertisement</p>
<div id="footer-ad-zone" class="min-h-[90px] flex items-center justify-center">
<!-- Ad content loaded dynamically -->
<p class="text-gray-400 text-sm">Support SmartTools Development</p>
</div>
</div>
</section>
{% endif %}
<script>
function copyInstallCommand() {
const command = 'pip install smarttools && smarttools init';
navigator.clipboard.writeText(command).then(() => {
const copyIcon = document.getElementById('copy-icon');
const checkIcon = document.getElementById('check-icon');
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Sign In - SmartTools{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Sign in to SmartTools</h1>
<p class="mt-2 text-gray-600">
Access your dashboard and manage your tools
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{{ form_errors(errors) }}
<form action="{{ url_for('web.login') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if next_url %}
<input type="hidden" name="next" value="{{ next_url }}">
{% endif %}
{{ text_input(
name='email',
label='Email address',
type='email',
placeholder='you@example.com',
required=true,
value=email or ''
) }}
{{ text_input(
name='password',
label='Password',
type='password',
placeholder='Your password',
required=true
) }}
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<input type="checkbox"
name="remember"
id="remember"
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<label for="remember" class="ml-2 text-sm text-gray-600">
Remember me
</label>
</div>
<a href="{{ url_for('web.forgot_password') }}" class="text-sm text-indigo-600 hover:text-indigo-800">
Forgot password?
</a>
</div>
{{ button_primary('Sign in', full_width=true) }}
</form>
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Don't have an account?
<a href="{{ url_for('web.register') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Create one
</a>
</p>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bg-white py-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Privacy Policy</h1>
<p class="text-gray-500 mb-8">Last updated: January 2025</p>
<div class="prose prose-lg prose-indigo max-w-none">
<h2>Introduction</h2>
<p>
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.
</p>
<h2>Information We Collect</h2>
<h3>Account Information</h3>
<p>When you create an account, we collect:</p>
<ul>
<li>Email address</li>
<li>Username (slug)</li>
<li>Display name</li>
<li>Password (stored securely hashed)</li>
<li>Optional: bio and website URL</li>
</ul>
<h3>Usage Data</h3>
<p>We automatically collect certain information when you use our services:</p>
<ul>
<li>Tool download counts (anonymized)</li>
<li>Page views and navigation patterns (if analytics consent given)</li>
<li>Browser type and version</li>
<li>IP address (for security and rate limiting)</li>
</ul>
<h3>Cookies</h3>
<p>We use cookies for:</p>
<ul>
<li><strong>Essential cookies:</strong> Required for the website to function (session management, CSRF protection)</li>
<li><strong>Analytics cookies:</strong> Help us understand how visitors use our site (optional, requires consent)</li>
<li><strong>Advertising cookies:</strong> Used to show relevant ads (optional, requires consent)</li>
</ul>
<p>
You can manage your cookie preferences at any time through our cookie consent banner
or by contacting us.
</p>
<h2>How We Use Your Information</h2>
<p>We use your information to:</p>
<ul>
<li>Provide and maintain our services</li>
<li>Authenticate your identity and secure your account</li>
<li>Display your public profile and published tools</li>
<li>Send important service notifications</li>
<li>Improve our services based on usage patterns</li>
<li>Prevent abuse and enforce our terms of service</li>
</ul>
<h2>Information Sharing</h2>
<p>We do not sell your personal information. We may share data with:</p>
<ul>
<li><strong>Service providers:</strong> Hosting, analytics, and email services that help us operate</li>
<li><strong>Legal requirements:</strong> When required by law or to protect our rights</li>
<li><strong>Public information:</strong> Your username, display name, bio, and published tools are publicly visible</li>
</ul>
<h2>Data Security</h2>
<p>
We implement appropriate security measures to protect your personal information,
including:
</p>
<ul>
<li>Encryption of data in transit (HTTPS)</li>
<li>Secure password hashing (bcrypt)</li>
<li>Regular security audits</li>
<li>Limited access to personal data</li>
</ul>
<h2>Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li><strong>Access:</strong> Request a copy of your personal data</li>
<li><strong>Correction:</strong> Update inaccurate information in your account settings</li>
<li><strong>Deletion:</strong> Delete your account and associated data</li>
<li><strong>Portability:</strong> Export your data in a machine-readable format</li>
<li><strong>Withdraw consent:</strong> Opt out of analytics and advertising cookies</li>
</ul>
<h2>Data Retention</h2>
<p>
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.
</p>
<p>
Published tools may remain in the registry after account deletion for continuity,
but will be disassociated from your personal information.
</p>
<h2>Children's Privacy</h2>
<p>
Our services are not intended for children under 13. We do not knowingly collect
personal information from children under 13.
</p>
<h2>Changes to This Policy</h2>
<p>
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.
</p>
<h2>Contact Us</h2>
<p>
If you have questions about this privacy policy or your personal data, please
contact us at:
</p>
<ul>
<li>Email: privacy@smarttools.dev</li>
<li>GitHub: <a href="https://github.com/your-org/smarttools/issues">Open an issue</a></li>
</ul>
</div>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Profile Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex items-start gap-6">
<!-- Avatar -->
<div class="w-24 h-24 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-3xl font-bold text-indigo-600">{{ publisher.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 truncate">{{ publisher.display_name }}</h1>
{% if publisher.verified %}
<svg class="w-6 h-6 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" title="Verified publisher">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
{% endif %}
</div>
<p class="mt-1 text-gray-500">@{{ publisher.slug }}</p>
{% if publisher.bio %}
<p class="mt-4 text-gray-600">{{ publisher.bio }}</p>
{% endif %}
<div class="mt-4 flex items-center gap-4 text-sm text-gray-500">
{% if publisher.website %}
<a href="{{ publisher.website }}"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 hover:text-indigo-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
Website
</a>
{% endif %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{{ tools|length }} tool{{ 's' if tools|length != 1 else '' }}
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
Joined {{ publisher.created_at|date_format }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tools Section -->
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h2 class="text-xl font-semibold text-gray-900 mb-6">Published Tools</h2>
{% if tools %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{% 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 %}
</div>
{% else %}
<div class="text-center py-12 bg-white rounded-lg border border-gray-200">
<svg class="mx-auto w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<p class="mt-4 text-gray-600">No tools published yet.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Create your account</h1>
<p class="mt-2 text-gray-600">
Start publishing tools and join the community
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{{ form_errors(errors) }}
<form action="{{ url_for('web.register') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ 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
) }}
<div class="mb-6">
<div class="flex items-start">
<input type="checkbox"
name="terms"
id="terms"
required
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<label for="terms" class="ml-3 text-sm text-gray-600">
I agree to the
<a href="{{ url_for('web.terms') }}" class="text-indigo-600 hover:text-indigo-800">Terms of Service</a>
and
<a href="{{ url_for('web.privacy') }}" class="text-indigo-600 hover:text-indigo-800">Privacy Policy</a>
</label>
</div>
</div>
{{ button_primary('Create account', full_width=true) }}
</form>
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Already have an account?
<a href="{{ url_for('web.login') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Sign in
</a>
</p>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Search Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<form action="{{ url_for('web.search') }}" method="GET" class="max-w-2xl mx-auto">
<div class="relative">
<input type="text"
name="q"
value="{{ query or '' }}"
placeholder="Search for tools..."
autofocus
class="w-full pl-12 pr-4 py-4 text-lg border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<svg class="absolute left-4 top-4.5 w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<button type="submit"
class="absolute right-2 top-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Search
</button>
</div>
</form>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% if query %}
<!-- Results Count -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">
{% if results|length == 1 %}
1 result for "{{ query }}"
{% else %}
{{ results|length }} results for "{{ query }}"
{% endif %}
</h1>
</div>
{% if results %}
<!-- Search Results -->
<div class="space-y-4">
{% 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 %}
</div>
<!-- Pagination -->
{% if pagination and pagination.pages > 1 %}
<nav class="mt-12 flex items-center justify-center" aria-label="Pagination">
<div class="flex items-center gap-2">
{% if pagination.has_prev %}
<a href="{{ url_for('web.search', q=query, page=pagination.prev_num) }}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{% endif %}
<span class="px-4 py-2 text-sm text-gray-700">
Page {{ pagination.page }} of {{ pagination.pages }}
</span>
{% if pagination.has_next %}
<a href="{{ url_for('web.search', q=query, page=pagination.next_num) }}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{% endif %}
</div>
</nav>
{% endif %}
{% else %}
<!-- No Results -->
<div class="text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<h2 class="mt-4 text-lg font-medium text-gray-900">No results found</h2>
<p class="mt-2 text-gray-600">
We couldn't find any tools matching "{{ query }}".
</p>
<div class="mt-8">
<h3 class="text-sm font-medium text-gray-700 mb-4">Suggestions:</h3>
<ul class="text-sm text-gray-600 space-y-2">
<li>Check your spelling</li>
<li>Try more general keywords</li>
<li>Use fewer keywords</li>
</ul>
</div>
<div class="mt-8">
<a href="{{ url_for('web.tools') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-indigo-600 border border-indigo-600 rounded-md hover:bg-indigo-50">
Browse All Tools
</a>
</div>
</div>
{% endif %}
{% else %}
<!-- Initial Search State -->
<div class="text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<h2 class="mt-4 text-lg font-medium text-gray-900">Search the Registry</h2>
<p class="mt-2 text-gray-600">
Find tools by name, description, category, or tags.
</p>
<!-- Popular Categories -->
<div class="mt-8">
<h3 class="text-sm font-medium text-gray-700 mb-4">Popular Categories:</h3>
<div class="flex flex-wrap justify-center gap-2">
{% for cat in popular_categories %}
<a href="{{ url_for('web.category', name=cat.name) }}"
class="px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-200 rounded-full hover:border-indigo-500 hover:text-indigo-600 transition-colors">
{{ cat.display_name }}
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bg-white py-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Terms of Service</h1>
<p class="text-gray-500 mb-8">Last updated: January 2025</p>
<div class="prose prose-lg prose-indigo max-w-none">
<h2>1. Acceptance of Terms</h2>
<p>
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.
</p>
<h2>2. Description of Service</h2>
<p>
SmartTools is a platform for creating, publishing, and sharing AI-powered
command-line tools. The Service includes:
</p>
<ul>
<li>The SmartTools CLI application</li>
<li>The SmartTools Registry (tool hosting and discovery)</li>
<li>Documentation and tutorials</li>
<li>Community features</li>
</ul>
<h2>3. User Accounts</h2>
<p>
To publish tools or access certain features, you must create an account. You agree to:
</p>
<ul>
<li>Provide accurate and complete information</li>
<li>Maintain the security of your account credentials</li>
<li>Notify us immediately of any unauthorized access</li>
<li>Accept responsibility for all activities under your account</li>
</ul>
<h2>4. User Content</h2>
<h3>4.1 Your Content</h3>
<p>
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.
</p>
<h3>4.2 Content Standards</h3>
<p>You agree not to publish content that:</p>
<ul>
<li>Contains malware, viruses, or malicious code</li>
<li>Infringes on intellectual property rights</li>
<li>Is illegal, harmful, or promotes illegal activities</li>
<li>Harasses, threatens, or harms others</li>
<li>Contains spam or deceptive content</li>
<li>Violates the privacy of others</li>
</ul>
<h3>4.3 Content Removal</h3>
<p>
We reserve the right to remove any content that violates these Terms or that we
determine to be harmful to users or the community.
</p>
<h2>5. Acceptable Use</h2>
<p>You agree not to:</p>
<ul>
<li>Use the Service for any unlawful purpose</li>
<li>Attempt to gain unauthorized access to our systems</li>
<li>Interfere with or disrupt the Service</li>
<li>Scrape or collect data without permission</li>
<li>Impersonate others or misrepresent your affiliation</li>
<li>Use automated systems to access the Service excessively</li>
</ul>
<h2>6. API and CLI Usage</h2>
<p>
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.
</p>
<h2>7. Third-Party Services</h2>
<p>
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.
</p>
<h2>8. Intellectual Property</h2>
<p>
The SmartTools software is open source and licensed under the MIT License.
The SmartTools name, logo, and branding are trademarks of SmartTools.
</p>
<h2>9. Disclaimer of Warranties</h2>
<p>
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.
</p>
<p>
Tools published by third parties are not endorsed by SmartTools. You use third-party
tools at your own risk.
</p>
<h2>10. Limitation of Liability</h2>
<p>
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.
</p>
<h2>11. Indemnification</h2>
<p>
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.
</p>
<h2>12. Termination</h2>
<p>
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.
</p>
<p>
You may delete your account at any time through your account settings.
</p>
<h2>13. Changes to Terms</h2>
<p>
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.
</p>
<h2>14. Governing Law</h2>
<p>
These Terms are governed by the laws of the jurisdiction in which SmartTools
operates, without regard to conflict of law principles.
</p>
<h2>15. Contact</h2>
<p>
For questions about these Terms, please contact us at:
</p>
<ul>
<li>Email: legal@smarttools.dev</li>
<li>GitHub: <a href="https://github.com/your-org/smarttools/issues">Open an issue</a></li>
</ul>
</div>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Breadcrumb -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<nav class="flex items-center text-sm text-gray-500" aria-label="Breadcrumb">
<a href="{{ url_for('web.tools') }}" class="hover:text-gray-700">Tools</a>
<svg class="w-4 h-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<a href="{{ url_for('web.publisher', slug=tool.owner) }}" class="hover:text-gray-700">{{ tool.owner }}</a>
<svg class="w-4 h-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-gray-900 font-medium">{{ tool.name }}</span>
</nav>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="lg:grid lg:grid-cols-3 lg:gap-8">
<!-- Main Content -->
<main class="lg:col-span-2">
<!-- Tool Header -->
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<div class="flex items-start justify-between">
<div class="flex items-center">
<div class="w-16 h-16 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div class="ml-4">
<h1 class="text-2xl font-bold text-gray-900">{{ tool.name }}</h1>
<p class="text-gray-500">
by <a href="{{ url_for('web.publisher', slug=tool.owner) }}" class="text-indigo-600 hover:text-indigo-800">{{ tool.owner }}</a>
</p>
</div>
</div>
{% if tool.category %}
<span class="px-3 py-1 text-xs font-medium text-cyan-700 bg-cyan-100 rounded-full">
{{ tool.category }}
</span>
{% endif %}
</div>
{% if tool.description %}
<p class="mt-4 text-gray-600">{{ tool.description }}</p>
{% endif %}
{% if tool.tags %}
<div class="mt-4 flex flex-wrap gap-2">
{% for tag in tool.tags.split(',') %}
<span class="px-2 py-1 text-xs text-gray-600 bg-gray-100 rounded">{{ tag.strip() }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Deprecation Warning -->
{% 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 <a href="' ~ url_for('web.tool_detail', owner=tool.replacement.split('/')[0], name=tool.replacement.split('/')[1]) ~ '" class="underline">' ~ tool.replacement ~ '</a> instead.'
) }}
{% endif %}
{% endif %}
<!-- README Content -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="prose prose-indigo max-w-none">
{% if tool.readme %}
{{ tool.readme_html|safe }}
{% else %}
<h2>About {{ tool.name }}</h2>
<p>{{ tool.description or 'No additional documentation available.' }}</p>
<h2>Installation</h2>
<pre><code>smarttools install {{ tool.owner }}/{{ tool.name }}</code></pre>
<h2>Usage</h2>
<pre><code>{{ tool.name }} --help</code></pre>
{% endif %}
</div>
</div>
</main>
<!-- Sidebar -->
<aside class="mt-8 lg:mt-0">
<div class="sticky top-4 space-y-6">
<!-- Install Card -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Install</h3>
<div class="flex items-center bg-gray-100 rounded-lg p-3 group">
<span class="text-gray-400 mr-2 select-none">$</span>
<code id="install-cmd" class="flex-1 text-sm text-gray-800 font-mono overflow-x-auto">smarttools install {{ tool.owner }}/{{ tool.name }}</code>
<button type="button"
onclick="copyInstall()"
class="ml-2 p-1.5 text-gray-400 hover:text-gray-600"
aria-label="Copy install command">
<svg id="copy-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg id="check-icon" class="w-4 h-4 hidden text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
<!-- Versions -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Versions</h3>
<ul class="space-y-2">
{% for version in versions %}
<li class="flex items-center justify-between">
<a href="{{ url_for('web.tool_version', owner=tool.owner, name=tool.name, version=version.version) }}"
class="text-sm {{ 'font-medium text-indigo-600' if version.version == tool.version else 'text-gray-600 hover:text-indigo-600' }}">
v{{ version.version }}
{% if version.version == tool.version %}
<span class="ml-1 text-xs text-green-600">(current)</span>
{% endif %}
</a>
<span class="text-xs text-gray-400">{{ version.published_at|timeago }}</span>
</li>
{% else %}
<li class="text-sm text-gray-500">v{{ tool.version }}</li>
{% endfor %}
</ul>
</div>
<!-- Stats -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Stats</h3>
<dl class="space-y-3">
<div class="flex items-center justify-between">
<dt class="text-sm text-gray-500">Downloads</dt>
<dd class="text-sm font-medium text-gray-900">{{ tool.downloads|format_number }}</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-sm text-gray-500">Published</dt>
<dd class="text-sm font-medium text-gray-900">{{ tool.published_at|timeago }}</dd>
</div>
{% if tool.category %}
<div class="flex items-center justify-between">
<dt class="text-sm text-gray-500">Category</dt>
<dd>
<a href="{{ url_for('web.category', name=tool.category) }}"
class="text-sm font-medium text-indigo-600 hover:text-indigo-800">
{{ tool.category }}
</a>
</dd>
</div>
{% endif %}
</dl>
</div>
<!-- Publisher -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Publisher</h3>
<a href="{{ url_for('web.publisher', slug=tool.owner) }}"
class="flex items-center group">
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<span class="text-gray-600 font-medium">{{ tool.owner[0]|upper }}</span>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900 group-hover:text-indigo-600">
{{ publisher.display_name if publisher else tool.owner }}
</p>
<p class="text-xs text-gray-500">@{{ tool.owner }}</p>
</div>
{% if publisher and publisher.verified %}
<svg class="ml-auto w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
{% endif %}
</a>
</div>
<!-- Report Tool -->
<div class="text-center">
<button type="button"
onclick="openReportModal()"
class="text-sm text-gray-500 hover:text-red-600 transition-colors">
Report this tool
</button>
</div>
</div>
</aside>
</div>
</div>
</div>
<!-- Report Modal -->
<div id="report-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeReportModal()"></div>
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<form action="/api/v1/reports" method="POST" id="report-form">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900">Report Tool</h3>
<p class="mt-2 text-sm text-gray-600">
Help us maintain a safe registry by reporting tools that violate our guidelines.
</p>
<input type="hidden" name="tool_id" value="{{ tool.id }}">
<div class="mt-6 space-y-4">
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 mb-1">Reason</label>
<select name="reason" id="reason" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
<option value="">Select a reason...</option>
<option value="malicious">Malicious code</option>
<option value="spam">Spam or advertising</option>
<option value="copyright">Copyright violation</option>
<option value="inappropriate">Inappropriate content</option>
<option value="broken">Broken or non-functional</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label for="details" class="block text-sm font-medium text-gray-700 mb-1">Details (optional)</label>
<textarea name="details" id="details" rows="3"
placeholder="Provide additional context..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 resize-none"></textarea>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeReportModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
Submit Report
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function copyInstall() {
const cmd = 'smarttools install {{ tool.owner }}/{{ tool.name }}';
navigator.clipboard.writeText(cmd).then(() => {
const copyIcon = document.getElementById('copy-icon');
const checkIcon = document.getElementById('check-icon');
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
});
}
function openReportModal() {
document.getElementById('report-modal').classList.remove('hidden');
}
function closeReportModal() {
document.getElementById('report-modal').classList.add('hidden');
}
// Handle report form submission
document.getElementById('report-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/api/v1/reports', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
closeReportModal();
alert('Thank you for your report. We will review it shortly.');
} else {
const error = await response.json();
alert(error.error || 'Failed to submit report. Please try again.');
}
} catch (err) {
alert('Failed to submit report. Please try again.');
}
});
</script>
{% endblock %}

View File

@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Page Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900">Browse Tools</h1>
<p class="mt-2 text-gray-600">
Discover community-built AI tools for your command line.
</p>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="lg:grid lg:grid-cols-4 lg:gap-8">
<!-- Sidebar Filters -->
<aside class="hidden lg:block">
<div class="sticky top-4 space-y-6">
<!-- Search -->
<div>
<label for="search" class="block text-sm font-medium text-gray-700 mb-2">
Search
</label>
<form action="{{ url_for('web.search') }}" method="GET">
<div class="relative">
<input type="text"
name="q"
id="search"
value="{{ query or '' }}"
placeholder="Search tools..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<svg class="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</form>
</div>
<!-- Categories -->
<div>
<h3 class="text-sm font-medium text-gray-700 mb-3">Categories</h3>
<ul class="space-y-2">
<li>
<a href="{{ url_for('web.tools') }}"
class="flex items-center justify-between px-3 py-2 rounded-lg {{ 'bg-indigo-50 text-indigo-600 font-medium' if not current_category else 'text-gray-600 hover:bg-gray-100' }}">
<span>All Tools</span>
<span class="text-sm text-gray-400">{{ total_count }}</span>
</a>
</li>
{% for cat in categories %}
<li>
<a href="{{ url_for('web.category', name=cat.name) }}"
class="flex items-center justify-between px-3 py-2 rounded-lg {{ 'bg-indigo-50 text-indigo-600 font-medium' if current_category == cat.name else 'text-gray-600 hover:bg-gray-100' }}">
<span>{{ cat.display_name }}</span>
<span class="text-sm text-gray-400">{{ cat.count }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
<!-- Sort -->
<div>
<h3 class="text-sm font-medium text-gray-700 mb-3">Sort By</h3>
<select id="sort-select"
onchange="updateSort(this.value)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white">
<option value="downloads" {{ 'selected' if sort == 'downloads' }}>Most Downloads</option>
<option value="published_at" {{ 'selected' if sort == 'published_at' }}>Recently Published</option>
<option value="name" {{ 'selected' if sort == 'name' }}>Name (A-Z)</option>
</select>
</div>
</div>
</aside>
<!-- Mobile Filter Toggle -->
<div class="lg:hidden mb-6">
<button type="button"
onclick="toggleMobileFilters()"
class="w-full flex items-center justify-between px-4 py-3 bg-white border border-gray-300 rounded-lg">
<span class="font-medium text-gray-700">Filters & Sort</span>
<svg id="filter-chevron" class="w-5 h-5 text-gray-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Mobile Filters Panel -->
<div id="mobile-filters" class="hidden mt-4 p-4 bg-white border border-gray-200 rounded-lg space-y-4">
<!-- Mobile Search -->
<form action="{{ url_for('web.search') }}" method="GET">
<input type="text"
name="q"
value="{{ query or '' }}"
placeholder="Search tools..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</form>
<!-- Mobile Categories -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Category</label>
<select onchange="window.location.href=this.value"
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white">
<option value="{{ url_for('web.tools') }}" {{ 'selected' if not current_category }}>All Tools</option>
{% for cat in categories %}
<option value="{{ url_for('web.category', name=cat.name) }}" {{ 'selected' if current_category == cat.name }}>
{{ cat.display_name }}
</option>
{% endfor %}
</select>
</div>
<!-- Mobile Sort -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Sort By</label>
<select onchange="updateSort(this.value)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white">
<option value="downloads" {{ 'selected' if sort == 'downloads' }}>Most Downloads</option>
<option value="published_at" {{ 'selected' if sort == 'published_at' }}>Recently Published</option>
<option value="name" {{ 'selected' if sort == 'name' }}>Name (A-Z)</option>
</select>
</div>
</div>
</div>
<!-- Tools Grid -->
<main class="lg:col-span-3">
<!-- Results Header -->
<div class="flex items-center justify-between mb-6">
<p class="text-sm text-gray-600">
{% if query %}
{{ tools|length }} results for "{{ query }}"
{% elif current_category %}
{{ tools|length }} tools in {{ current_category }}
{% else %}
{{ tools|length }} tools available
{% endif %}
</p>
</div>
{% if tools %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{% 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 %}
</div>
<!-- Pagination -->
{% if pagination %}
<nav class="mt-12 flex items-center justify-center" aria-label="Pagination">
<div class="flex items-center gap-2">
{% if pagination.has_prev %}
<a href="{{ url_for('web.tools', page=pagination.prev_num, sort=sort, category=current_category) }}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{% endif %}
<span class="px-4 py-2 text-sm text-gray-700">
Page {{ pagination.page }} of {{ pagination.pages }}
</span>
{% if pagination.has_next %}
<a href="{{ url_for('web.tools', page=pagination.next_num, sort=sort, category=current_category) }}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{% endif %}
</div>
</nav>
{% endif %}
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No tools found</h3>
<p class="mt-2 text-gray-600">
{% if query %}
No tools match your search. Try different keywords.
{% else %}
Be the first to publish a tool in this category!
{% endif %}
</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Learn to Publish
</a>
</div>
{% endif %}
</main>
</div>
</div>
</div>
<script>
function toggleMobileFilters() {
const filters = document.getElementById('mobile-filters');
const chevron = document.getElementById('filter-chevron');
filters.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
function updateSort(value) {
const url = new URL(window.location.href);
url.searchParams.set('sort', value);
window.location.href = url.toString();
}
</script>
{% endblock %}

View File

@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Hero -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<h1 class="text-3xl font-bold text-gray-900">Tutorials</h1>
<p class="mt-4 text-lg text-gray-600">
Learn SmartTools from the ground up with step-by-step guides.
</p>
</div>
</div>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Getting Started Section -->
<section class="mb-16">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Getting Started</h2>
<p class="text-gray-600 mb-8">
New to SmartTools? Start here with the fundamentals.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{{ 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
) }}
</div>
</section>
<!-- Core Concepts Section -->
<section class="mb-16">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Core Concepts</h2>
<p class="text-gray-600 mb-8">
Understand the key concepts that power SmartTools.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{% for tutorial in core_tutorials %}
<a href="{{ url_for('web.tutorials_path', path=tutorial.slug) }}"
class="block bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ tutorial.title }}</h3>
<p class="text-gray-600 text-sm">{{ tutorial.description }}</p>
<span class="mt-4 inline-flex items-center text-sm text-indigo-600">
Read tutorial
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
</a>
{% else %}
<a href="{{ url_for('web.tutorials_path', path='providers') }}"
class="block bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Working with Providers</h3>
<p class="text-gray-600 text-sm">Configure and use different AI providers with your tools.</p>
<span class="mt-4 inline-flex items-center text-sm text-indigo-600">
Read tutorial
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
</a>
<a href="{{ url_for('web.tutorials_path', path='arguments') }}"
class="block bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Custom Arguments</h3>
<p class="text-gray-600 text-sm">Add flags and options to make your tools flexible.</p>
<span class="mt-4 inline-flex items-center text-sm text-indigo-600">
Read tutorial
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
</a>
<a href="{{ url_for('web.tutorials_path', path='multi-step') }}"
class="block bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Multi-Step Workflows</h3>
<p class="text-gray-600 text-sm">Chain prompts and code steps together.</p>
<span class="mt-4 inline-flex items-center text-sm text-indigo-600">
Read tutorial
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
</a>
<a href="{{ url_for('web.tutorials_path', path='code-steps') }}"
class="block bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Code Steps</h3>
<p class="text-gray-600 text-sm">Add Python code processing between AI calls.</p>
<span class="mt-4 inline-flex items-center text-sm text-indigo-600">
Read tutorial
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
</a>
{% endfor %}
</div>
</section>
<!-- Advanced Section -->
<section class="mb-16">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Advanced Topics</h2>
<p class="text-gray-600 mb-8">
Take your tools to the next level with advanced techniques.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<a href="{{ url_for('web.tutorials_path', path='publishing') }}"
class="block bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Publishing Tools</h3>
<p class="text-gray-600 text-sm">Share your tools with the community via the registry.</p>
<span class="mt-4 inline-flex items-center text-sm text-indigo-600">
Read tutorial
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
</a>
<a href="{{ url_for('web.tutorials_path', path='advanced-workflows') }}"
class="block bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Advanced Workflows</h3>
<p class="text-gray-600 text-sm">Complex multi-provider and branching workflows.</p>
<span class="mt-4 inline-flex items-center text-sm text-indigo-600">
Read tutorial
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
</a>
</div>
</section>
<!-- Video Tutorials -->
{% if video_tutorials %}
<section>
<h2 class="text-2xl font-bold text-gray-900 mb-6">Video Tutorials</h2>
<p class="text-gray-600 mb-8">
Prefer watching? Check out our video guides.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{% for video in video_tutorials %}
<a href="{{ video.url }}"
target="_blank"
rel="noopener noreferrer"
class="block bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-lg transition-shadow">
<div class="relative">
<img src="{{ video.thumbnail }}"
alt="{{ video.title }}"
class="w-full h-48 object-cover">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-16 h-16 bg-black bg-opacity-60 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-white ml-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
</svg>
</div>
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-gray-900">{{ video.title }}</h3>
<p class="text-sm text-gray-500 mt-1">{{ video.duration }}</p>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}

22
tailwind.config.js Normal file
View File

@ -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: []
};

View File

@ -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"])