Rename project from SmartTools to CmdForge

Major rename operation:
- Rename Python package: smarttools -> cmdforge
- Update CLI entry point: smarttools -> cmdforge
- Update all imports and module references
- Update pyproject.toml with new name, URLs, entry point
- Update all HTML templates with new branding
- Update documentation (CLAUDE.md, README.md, docs/*, wiki/*)
- Update environment variables:
  - SMARTTOOLS_ENV -> CMDFORGE_ENV
  - SMARTTOOLS_REGISTRY_DB -> CMDFORGE_REGISTRY_DB
  - SMARTTOOLS_TOKEN -> CMDFORGE_TOKEN
  - SMARTTOOLS_REGISTRY_* -> CMDFORGE_REGISTRY_*
- Update Dockerfile and docker-compose.yml
- Update tests to use new package name
- Update scripts and examples
- Update package.json and tailwind.config.js

All 158 unit tests pass. The CLI is working correctly with the
new cmdforge command.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-02 22:15:55 -04:00
co-authored by Claude Opus 4.5
parent 2939dd806b
commit 497fe87fc5
113 changed files with 822 additions and 822 deletions
+3
View File
@@ -0,0 +1,3 @@
"""CmdForge - A lightweight personal tool builder for AI-powered CLI commands."""
__version__ = "0.1.0"
+11
View File
@@ -0,0 +1,11 @@
"""CLI entry point for CmdForge.
This module is a thin wrapper for backwards compatibility.
The actual implementation is in the cli/ package.
"""
from .cli import main
if __name__ == "__main__":
import sys
sys.exit(main())
+233
View File
@@ -0,0 +1,233 @@
"""CLI entry point for CmdForge."""
import argparse
import sys
from .. import __version__
from .tool_commands import (
cmd_list, cmd_create, cmd_edit, cmd_delete, cmd_test, cmd_run,
cmd_ui, cmd_refresh, cmd_docs
)
from .provider_commands import cmd_providers
from .registry_commands import cmd_registry
from .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init
from .config_commands import cmd_config
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
prog="cmdforge",
description="A lightweight personal tool builder for AI-powered CLI commands"
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# No command = launch UI
# 'list' command
p_list = subparsers.add_parser("list", help="List all tools")
p_list.set_defaults(func=cmd_list)
# 'create' command
p_create = subparsers.add_parser("create", help="Create a new tool")
p_create.add_argument("name", help="Tool name")
p_create.add_argument("-d", "--description", help="Tool description")
p_create.add_argument("-p", "--prompt", help="Prompt template")
p_create.add_argument("--provider", help="AI provider (default: mock)")
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
p_create.set_defaults(func=cmd_create)
# 'edit' command
p_edit = subparsers.add_parser("edit", help="Edit a tool config")
p_edit.add_argument("name", help="Tool name")
p_edit.set_defaults(func=cmd_edit)
# 'delete' command
p_delete = subparsers.add_parser("delete", help="Delete a tool")
p_delete.add_argument("name", help="Tool name")
p_delete.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
p_delete.set_defaults(func=cmd_delete)
# 'test' command
p_test = subparsers.add_parser("test", help="Test a tool with mock provider")
p_test.add_argument("name", help="Tool name")
p_test.add_argument("-i", "--input", help="Input file for testing")
p_test.add_argument("--dry-run", action="store_true", help="Show prompt only")
p_test.set_defaults(func=cmd_test)
# 'run' command
p_run = subparsers.add_parser("run", help="Run a tool")
p_run.add_argument("name", help="Tool name")
p_run.add_argument("-i", "--input", help="Input file (reads from stdin if piped)")
p_run.add_argument("-o", "--output", help="Output file (writes to stdout if omitted)")
p_run.add_argument("--stdin", action="store_true", help="Read input interactively (type then Ctrl+D)")
p_run.add_argument("-p", "--provider", help="Override provider")
p_run.add_argument("--dry-run", action="store_true", help="Show what would happen without executing")
p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output")
p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information")
p_run.add_argument("tool_args", nargs="*", help="Additional tool-specific arguments")
p_run.set_defaults(func=cmd_run)
# 'ui' command (explicit)
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
p_ui.set_defaults(func=cmd_ui)
# 'refresh' command
p_refresh = subparsers.add_parser("refresh", help="Refresh all wrapper scripts")
p_refresh.set_defaults(func=cmd_refresh)
# 'docs' command
p_docs = subparsers.add_parser("docs", help="View or edit tool documentation")
p_docs.add_argument("name", help="Tool name")
p_docs.add_argument("-e", "--edit", action="store_true", help="Edit/create README in $EDITOR")
p_docs.set_defaults(func=cmd_docs)
# 'providers' command
p_providers = subparsers.add_parser("providers", help="Manage AI providers")
providers_sub = p_providers.add_subparsers(dest="providers_cmd", help="Provider commands")
# providers list
p_prov_list = providers_sub.add_parser("list", help="List all providers and their status")
p_prov_list.set_defaults(func=cmd_providers)
# providers check
p_prov_check = providers_sub.add_parser("check", help="Check which providers are available")
p_prov_check.set_defaults(func=cmd_providers)
# providers install
p_prov_install = providers_sub.add_parser("install", help="Interactive guide to install AI providers")
p_prov_install.set_defaults(func=cmd_providers)
# providers add
p_prov_add = providers_sub.add_parser("add", help="Add or update a provider")
p_prov_add.add_argument("name", help="Provider name")
p_prov_add.add_argument("command", help="Command to run (e.g., 'claude -p')")
p_prov_add.add_argument("-d", "--description", help="Provider description")
p_prov_add.set_defaults(func=cmd_providers)
# providers remove
p_prov_remove = providers_sub.add_parser("remove", help="Remove a provider")
p_prov_remove.add_argument("name", help="Provider name")
p_prov_remove.set_defaults(func=cmd_providers)
# providers test
p_prov_test = providers_sub.add_parser("test", help="Test a provider")
p_prov_test.add_argument("name", help="Provider name")
p_prov_test.set_defaults(func=cmd_providers)
# Default for providers with no subcommand
p_providers.set_defaults(func=lambda args: cmd_providers(args) if args.providers_cmd else (setattr(args, 'providers_cmd', 'list') or cmd_providers(args)))
# -------------------------------------------------------------------------
# Registry Commands
# -------------------------------------------------------------------------
p_registry = subparsers.add_parser("registry", help="Registry commands (search, install, publish)")
registry_sub = p_registry.add_subparsers(dest="registry_cmd", help="Registry commands")
# registry search
p_reg_search = registry_sub.add_parser("search", help="Search for tools")
p_reg_search.add_argument("query", help="Search query")
p_reg_search.add_argument("-c", "--category", help="Filter by category")
p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)")
p_reg_search.set_defaults(func=cmd_registry)
# registry install
p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry")
p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)")
p_reg_install.add_argument("-v", "--version", help="Version constraint")
p_reg_install.set_defaults(func=cmd_registry)
# registry uninstall
p_reg_uninstall = registry_sub.add_parser("uninstall", help="Uninstall a tool")
p_reg_uninstall.add_argument("tool", help="Tool to uninstall (owner/name)")
p_reg_uninstall.set_defaults(func=cmd_registry)
# registry info
p_reg_info = registry_sub.add_parser("info", help="Show tool information")
p_reg_info.add_argument("tool", help="Tool name (owner/name)")
p_reg_info.set_defaults(func=cmd_registry)
# registry update
p_reg_update = registry_sub.add_parser("update", help="Update local index cache")
p_reg_update.set_defaults(func=cmd_registry)
# registry publish
p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry")
p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)")
p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing")
p_reg_publish.set_defaults(func=cmd_registry)
# registry my-tools
p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools")
p_reg_mytools.set_defaults(func=cmd_registry)
# registry browse
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
p_reg_browse.set_defaults(func=cmd_registry)
# Default for registry with no subcommand
p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args)))
# -------------------------------------------------------------------------
# Project Commands
# -------------------------------------------------------------------------
# 'deps' command
p_deps = subparsers.add_parser("deps", help="Show project dependencies")
p_deps.set_defaults(func=cmd_deps)
# 'install' command (for dependencies)
p_install = subparsers.add_parser("install", help="Install dependencies from cmdforge.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 cmdforge.yaml")
p_init.add_argument("-n", "--name", help="Project name")
p_init.add_argument("-v", "--version", help="Project version")
p_init.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
p_init.set_defaults(func=cmd_init)
# -------------------------------------------------------------------------
# Config Commands
# -------------------------------------------------------------------------
p_config = subparsers.add_parser("config", help="Manage configuration")
config_sub = p_config.add_subparsers(dest="config_cmd", help="Config commands")
# config show
p_cfg_show = config_sub.add_parser("show", help="Show current configuration")
p_cfg_show.set_defaults(func=cmd_config)
# config set-token
p_cfg_token = config_sub.add_parser("set-token", help="Set registry authentication token")
p_cfg_token.add_argument("token", help="Registry token")
p_cfg_token.set_defaults(func=cmd_config)
# config set
p_cfg_set = config_sub.add_parser("set", help="Set a configuration value")
p_cfg_set.add_argument("key", help="Config key")
p_cfg_set.add_argument("value", help="Config value")
p_cfg_set.set_defaults(func=cmd_config)
# Default for config with no subcommand
p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args)))
args = parser.parse_args()
# If no command, launch UI
if args.command is None:
return cmd_ui(args)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -0,0 +1,7 @@
"""Allow running the CLI as a module."""
import sys
from . import main
if __name__ == "__main__":
sys.exit(main())
+62
View File
@@ -0,0 +1,62 @@
"""Configuration commands."""
from ..config import load_config, save_config, set_registry_token
def cmd_config(args):
"""Manage CmdForge configuration."""
if args.config_cmd == "show":
return _cmd_config_show(args)
elif args.config_cmd == "set-token":
return _cmd_config_set_token(args)
elif args.config_cmd == "set":
return _cmd_config_set(args)
else:
print("Config commands:")
print(" show Show current configuration")
print(" set-token <token> Set registry authentication token")
print(" set <key> <value> Set a configuration value")
return 0
def _cmd_config_show(args):
"""Show current configuration."""
config = load_config()
print("CmdForge Configuration:")
print(f" Registry URL: {config.registry.url}")
print(f" Token: {'***' if config.registry.token else '(not set)'}")
print(f" Client ID: {config.client_id}")
print(f" Auto-fetch: {config.auto_fetch_from_registry}")
if config.default_provider:
print(f" Default provider: {config.default_provider}")
return 0
def _cmd_config_set_token(args):
"""Set registry authentication token."""
token = args.token
set_registry_token(token)
print("Registry token saved.")
return 0
def _cmd_config_set(args):
"""Set a configuration value."""
config = load_config()
key = args.key
value = args.value
if key == "auto_fetch":
config.auto_fetch_from_registry = value.lower() in ("true", "1", "yes")
elif key == "default_provider":
config.default_provider = value if value else None
elif key == "registry_url":
config.registry.url = value
else:
print(f"Unknown config key: {key}")
print("Available keys: auto_fetch, default_provider, registry_url")
return 1
save_config(config)
print(f"Set {key} = {value}")
return 0
+202
View File
@@ -0,0 +1,202 @@
"""Project management commands."""
import sys
from pathlib import Path
from ..manifest import (
load_manifest, save_manifest, create_manifest, find_manifest,
MANIFEST_FILENAME
)
from ..resolver import (
find_tool, install_from_registry, ToolSpec
)
def cmd_deps(args):
"""Show project dependencies from cmdforge.yaml."""
manifest = load_manifest()
if manifest is None:
print("No cmdforge.yaml found in current project.")
print("Create one with: cmdforge init")
return 1
print(f"Project: {manifest.name} v{manifest.version}")
print()
if not manifest.dependencies:
print("No dependencies defined.")
print("Add one with: cmdforge 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 cmdforge.yaml."""
from ..registry_client import RegistryError
manifest = load_manifest()
if manifest is None:
print("No cmdforge.yaml found in current project.")
print("Create one with: cmdforge init")
return 1
if not manifest.dependencies:
print("No dependencies to install.")
return 0
print(f"Installing dependencies for {manifest.name}...")
print()
failed = []
installed = []
for i, dep in enumerate(manifest.dependencies, 1):
print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}")
# Check if already installed
existing = find_tool(dep.name)
if existing:
print(f" Already installed: {existing.full_name}")
installed.append(dep.name)
continue
try:
print(f" Downloading...")
resolved = install_from_registry(dep.name, dep.version)
print(f" Installed: {resolved.full_name}@{resolved.version}")
installed.append(dep.name)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f" Not found in registry")
elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE":
print(f" Version {dep.version} not available")
elif e.code == "CONNECTION_ERROR":
print(f" Connection failed (check network)")
else:
print(f" Failed: {e.message}")
failed.append(dep.name)
except Exception as e:
print(f" Failed: {e}")
failed.append(dep.name)
print()
print(f"Installed {len(installed)} tools")
if failed:
print(f"Failed: {', '.join(failed)}")
return 1
return 0
def cmd_add(args):
"""Add a tool to project dependencies."""
from ..registry_client import RegistryError
tool_spec = args.tool
version = args.version or "*"
# Find or create manifest
manifest_path = find_manifest()
if manifest_path:
manifest = load_manifest(manifest_path)
else:
# Create in current directory
manifest = create_manifest(name=Path.cwd().name)
manifest_path = Path.cwd() / MANIFEST_FILENAME
# Parse tool spec
parsed = ToolSpec.parse(tool_spec)
full_name = parsed.full_name
# Add dependency
manifest.add_dependency(full_name, version)
# Save
save_manifest(manifest, manifest_path)
print(f"Added {full_name}@{version} to {manifest_path.name}")
# Install if requested
if not args.no_install:
print(f"Installing {full_name}...")
try:
resolved = install_from_registry(tool_spec, version if version != "*" else None)
print(f"Installed: {resolved.full_name}@{resolved.version}")
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool not found in registry.", file=sys.stderr)
print(f"It's been added to your dependencies - you can install it manually later.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print(f"Could not connect to registry.", file=sys.stderr)
print("Run 'cmdforge install' to try again later.", file=sys.stderr)
else:
print(f"Install failed: {e.message}", file=sys.stderr)
print("Run 'cmdforge install' to try again.", file=sys.stderr)
except Exception as e:
print(f"Install failed: {e}", file=sys.stderr)
print("Run 'cmdforge install' to try again.", file=sys.stderr)
return 0
def cmd_init(args):
"""Initialize a new cmdforge.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: cmdforge add <owner/name>")
print("Install them with: cmdforge install")
return 0
+307
View File
@@ -0,0 +1,307 @@
"""Provider management commands."""
import shutil
import subprocess
from pathlib import Path
from ..providers import load_providers, add_provider, delete_provider, Provider, call_provider
PROVIDER_INSTALL_INFO = {
"claude": {
"group": "Anthropic Claude",
"install_cmd": "npm install -g @anthropic-ai/claude-code",
"requires": "Node.js 18+ and npm",
"setup": "Run 'claude' - opens browser for sign-in (auto-saves auth tokens)",
"cost": "Pay-per-use (billed to your Anthropic account)",
"variants": ["claude", "claude-haiku", "claude-opus", "claude-sonnet"],
},
"codex": {
"group": "OpenAI Codex",
"install_cmd": "npm install -g @openai/codex",
"requires": "Node.js 18+ and npm",
"setup": "Run 'codex' - opens browser for sign-in (auto-saves auth tokens)",
"cost": "Pay-per-use (billed to your OpenAI account)",
"variants": ["codex"],
},
"gemini": {
"group": "Google Gemini",
"install_cmd": "npm install -g @google/gemini-cli",
"requires": "Node.js 18+ and npm",
"setup": "Run 'gemini' - opens browser for Google sign-in",
"cost": "Free tier available, pay-per-use for more",
"variants": ["gemini", "gemini-flash"],
},
"opencode": {
"group": "OpenCode (75+ providers)",
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
"requires": "curl, bash",
"setup": "Run 'opencode' - opens browser to connect more providers",
"cost": "4 FREE models included (Big Pickle, GLM-4.7, Grok Code Fast 1, MiniMax M2.1), 75+ more available",
"variants": ["opencode-pickle", "opencode-deepseek", "opencode-nano", "opencode-reasoner", "opencode-grok"],
},
"ollama": {
"group": "Ollama (Local LLMs)",
"install_cmd": "curl -fsSL https://ollama.ai/install.sh | bash",
"requires": "curl, bash, 8GB+ RAM (GPU recommended)",
"setup": "Run 'ollama pull llama3' to download a model, then add provider",
"cost": "FREE (runs entirely on your machine)",
"variants": [],
"custom": True,
"post_install_note": "After installing, add the provider:\n cmdforge providers add ollama 'ollama run llama3' -d 'Local Llama 3'",
},
}
def cmd_providers(args):
"""Manage AI providers."""
if args.providers_cmd == "install":
return _cmd_providers_install(args)
elif args.providers_cmd == "list":
return _cmd_providers_list(args)
elif args.providers_cmd == "add":
return _cmd_providers_add(args)
elif args.providers_cmd == "remove":
return _cmd_providers_remove(args)
elif args.providers_cmd == "test":
return _cmd_providers_test(args)
elif args.providers_cmd == "check":
return _cmd_providers_check(args)
return 0
def _cmd_providers_install(args):
"""Interactive guide to install AI providers."""
print("=" * 60)
print("CmdForge Provider Installation Guide")
print("=" * 60)
print()
# Check what's already installed
providers = load_providers()
installed_groups = set()
for p in providers:
if p.name.lower() == "mock":
continue
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
if shutil.which(cmd_expanded) or Path(cmd_expanded).exists():
# Find which group this belongs to
for group, info in PROVIDER_INSTALL_INFO.items():
if p.name in info.get("variants", []):
installed_groups.add(group)
# Show available provider groups
print("Available AI Provider Groups:\n")
options = []
for i, (key, info) in enumerate(PROVIDER_INSTALL_INFO.items(), 1):
status = "[INSTALLED]" if key in installed_groups else ""
print(f" {i}. {info['group']} {status}")
print(f" Cost: {info['cost']}")
print(f" Models: {', '.join(info['variants']) if info['variants'] else 'Custom'}")
print()
options.append(key)
print(" 0. Cancel")
print()
try:
choice = input("Select a provider to install (1-{}, or 0 to cancel): ".format(len(options)))
choice = int(choice)
except (ValueError, EOFError):
print("Cancelled.")
return 0
if choice == 0 or choice > len(options):
print("Cancelled.")
return 0
selected = options[choice - 1]
info = PROVIDER_INSTALL_INFO[selected]
print()
print("=" * 60)
print(f"Installing: {info['group']}")
print("=" * 60)
print()
print(f"Requirements: {info['requires']}")
print(f"Install command: {info['install_cmd']}")
print(f"Post-install: {info['setup']}")
print()
try:
confirm = input("Run installation command? (y/N): ").strip().lower()
except EOFError:
confirm = "n"
if confirm == "y":
print()
print(f"Running: {info['install_cmd']}")
print("-" * 40)
result = subprocess.run(info['install_cmd'], shell=True)
print("-" * 40)
if result.returncode == 0:
# Refresh PATH to pick up newly installed tools
import os
new_paths = []
# Common install locations that might have been added
potential_paths = [
Path.home() / ".opencode" / "bin", # OpenCode
Path.home() / ".local" / "bin", # pip/pipx installs
Path("/usr/local/bin"), # Ollama, system installs
]
# Also try to get npm global bin path
try:
npm_result = subprocess.run(
["npm", "bin", "-g"],
capture_output=True, text=True, timeout=5
)
if npm_result.returncode == 0:
npm_bin = npm_result.stdout.strip()
if npm_bin:
potential_paths.append(Path(npm_bin))
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
current_path = os.environ.get("PATH", "")
for p in potential_paths:
if p.exists() and str(p) not in current_path:
new_paths.append(str(p))
if new_paths:
os.environ["PATH"] = ":".join(new_paths) + ":" + current_path
print()
print("Installation completed!")
print()
print("IMPORTANT: Refresh your shell PATH before continuing:")
print(" source ~/.bashrc")
print()
print(f"Next steps:")
print(f" 1. source ~/.bashrc (required!)")
print(f" 2. {info['setup']}")
if info.get('post_install_note'):
print(f" 3. {info['post_install_note']}")
print(f" 4. Test with: cmdforge providers test {selected}")
else:
print(f" 3. Test with: cmdforge providers test {info['variants'][0] if info['variants'] else selected}")
else:
print()
print(f"Installation failed (exit code {result.returncode})")
print("Try running the command manually:")
print(f" {info['install_cmd']}")
else:
print()
print("To install manually, run:")
print(f" {info['install_cmd']}")
print()
print(f"Then: {info['setup']}")
return 0
def _cmd_providers_list(args):
"""List all providers and their status."""
providers = load_providers()
print(f"Configured providers ({len(providers)}):\n")
for p in providers:
# Mock provider is always available
if p.name.lower() == "mock":
print(f" [+] {p.name}")
print(f" Command: (built-in)")
print(f" Status: OK (always available)")
if p.description:
print(f" Info: {p.description}")
print()
continue
# Check if command exists
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
status = "OK" if exists else "NOT FOUND"
status_icon = "+" if exists else "-"
print(f" [{status_icon}] {p.name}")
print(f" Command: {p.command}")
print(f" Status: {status}")
if p.description:
print(f" Info: {p.description}")
print()
return 0
def _cmd_providers_add(args):
"""Add or update a provider."""
name = args.name
command = args.command
description = args.description or ""
provider = Provider(name, command, description)
add_provider(provider)
print(f"Provider '{name}' added/updated.")
return 0
def _cmd_providers_remove(args):
"""Remove a provider."""
name = args.name
if delete_provider(name):
print(f"Provider '{name}' removed.")
return 0
else:
print(f"Provider '{name}' not found.")
return 1
def _cmd_providers_test(args):
"""Test a provider."""
name = args.name
print(f"Testing provider '{name}'...")
result = call_provider(name, "Say 'hello' and nothing else.", timeout=30)
if result.success:
print(f"SUCCESS: {result.text[:200]}...")
else:
print(f"FAILED: {result.error}")
return 0 if result.success else 1
def _cmd_providers_check(args):
"""Check which providers are available."""
providers = load_providers()
print("Checking all providers...\n")
available = []
missing = []
for p in providers:
# Mock provider is always available (handled specially)
if p.name.lower() == "mock":
available.append(p.name)
print(f" [+] {p.name}: OK (built-in)")
continue
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
if exists:
available.append(p.name)
print(f" [+] {p.name}: OK")
else:
missing.append(p.name)
print(f" [-] {p.name}: NOT FOUND ({cmd_parts})")
print(f"\nSummary: {len(available)} available, {len(missing)} missing")
if len(available) == 1 and available[0] == "mock":
print(f"\nNo real AI providers found. Install one of these:")
print(f" - claude: npm install -g @anthropic-ai/claude-cli")
print(f" - codex: pip install openai-codex")
print(f" - gemini: pip install google-generative-ai")
print(f"\nMeanwhile, use mock provider for testing:")
print(f" echo 'test' | summarize --provider mock")
elif missing:
print(f"\nAvailable providers: {', '.join(available)}")
return 0
+425
View File
@@ -0,0 +1,425 @@
"""Registry commands."""
import sys
from pathlib import Path
import yaml
from ..config import load_config, set_registry_token
from ..resolver import install_from_registry, uninstall_tool, ToolSpec
def cmd_registry(args):
"""Handle registry subcommands."""
from ..registry_client import RegistryClient, RegistryError, RateLimitError, get_client
if args.registry_cmd == "search":
return _cmd_registry_search(args)
elif args.registry_cmd == "install":
return _cmd_registry_install(args)
elif args.registry_cmd == "uninstall":
return _cmd_registry_uninstall(args)
elif args.registry_cmd == "info":
return _cmd_registry_info(args)
elif args.registry_cmd == "update":
return _cmd_registry_update(args)
elif args.registry_cmd == "publish":
return _cmd_registry_publish(args)
elif args.registry_cmd == "my-tools":
return _cmd_registry_my_tools(args)
elif args.registry_cmd == "browse":
return _cmd_registry_browse(args)
else:
# Default: show registry help
print("Registry commands:")
print(" search <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
def _cmd_registry_search(args):
"""Search for tools in the registry."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
results = client.search_tools(
query=args.query,
category=args.category,
per_page=args.limit or 20
)
if not results.data:
print(f"No tools found matching '{args.query}'")
return 0
print(f"Found {results.total} tools:\n")
for tool in results.data:
owner = tool.get("owner", "")
name = tool.get("name", "")
version = tool.get("version", "")
desc = tool.get("description", "")
downloads = tool.get("downloads", 0)
print(f" {owner}/{name} v{version}")
print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}")
print(f" Downloads: {downloads}")
print()
if results.total_pages > 1:
print(f"Showing page {results.page}/{results.total_pages}")
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print(f"Rate limited. Please wait and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error searching registry: {e}", file=sys.stderr)
print("If the problem persists, check: cmdforge config show", file=sys.stderr)
return 1
return 0
def _cmd_registry_install(args):
"""Install a tool from the registry."""
from ..registry_client import RegistryError
from ..tool import BIN_DIR
tool_spec = args.tool
version = args.version
print(f"Installing {tool_spec}...")
try:
resolved = install_from_registry(tool_spec, version)
print(f"Installed: {resolved.full_name}@{resolved.version}")
print(f"Location: {resolved.path}")
# Show wrapper info
wrapper_name = resolved.tool.name
if resolved.owner:
# Check for collision
short_wrapper = BIN_DIR / resolved.tool.name
if short_wrapper.exists():
wrapper_name = f"{resolved.owner}-{resolved.tool.name}"
print(f"\nRun with: {wrapper_name}")
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr)
print(f"Try: cmdforge registry search {tool_spec.split('/')[-1]}", file=sys.stderr)
elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE":
print(f"Error: {e.message}", file=sys.stderr)
if e.details and "available_versions" in e.details:
versions = e.details["available_versions"]
print(f"Available versions: {', '.join(versions[:5])}", file=sys.stderr)
if e.details.get("latest_stable"):
print(f"Latest stable: {e.details['latest_stable']}", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error installing tool: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_uninstall(args):
"""Uninstall a tool."""
tool_spec = args.tool
print(f"Uninstalling {tool_spec}...")
if uninstall_tool(tool_spec):
print(f"Uninstalled: {tool_spec}")
else:
print(f"Tool '{tool_spec}' not found", file=sys.stderr)
return 1
return 0
def _cmd_registry_info(args):
"""Show tool information."""
from ..registry_client import RegistryError, get_client
tool_spec = args.tool
try:
# Parse the tool spec
parsed = ToolSpec.parse(tool_spec)
owner = parsed.owner or "official"
client = get_client()
tool_info = client.get_tool(owner, parsed.name)
print(f"{tool_info.owner}/{tool_info.name} v{tool_info.version}")
print("=" * 50)
print(f"Description: {tool_info.description}")
print(f"Category: {tool_info.category}")
print(f"Tags: {', '.join(tool_info.tags)}")
print(f"Downloads: {tool_info.downloads}")
print(f"Published: {tool_info.published_at}")
if tool_info.deprecated:
print()
print(f"DEPRECATED: {tool_info.deprecated_message}")
if tool_info.replacement:
print(f"Use instead: {tool_info.replacement}")
# Show versions
versions = client.get_tool_versions(owner, parsed.name)
if versions:
print(f"\nVersions: {', '.join(versions[:5])}")
if len(versions) > 5:
print(f" ...and {len(versions) - 5} more")
print(f"\nInstall: cmdforge 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: cmdforge registry search {parsed.name}", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error fetching tool info: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_update(args):
"""Update local index cache."""
from ..registry_client import RegistryError, get_client
print("Updating registry index...")
try:
client = get_client()
index = client.get_index(force_refresh=True)
tool_count = index.get("tool_count", len(index.get("tools", [])))
generated = index.get("generated_at", "unknown")
print(f"Index updated: {tool_count} tools")
print(f"Generated: {generated}")
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error updating index: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_publish(args):
"""Publish a tool to the registry."""
from ..registry_client import RegistryError, get_client
# Read tool from current directory or specified path
tool_path = Path(args.path) if args.path else Path.cwd()
if tool_path.is_dir():
config_path = tool_path / "config.yaml"
else:
config_path = tool_path
tool_path = config_path.parent
if not config_path.exists():
print(f"Error: config.yaml not found in {tool_path}", file=sys.stderr)
return 1
# Read config
config_yaml = config_path.read_text()
# Read README if exists
readme_path = tool_path / "README.md"
readme = readme_path.read_text() if readme_path.exists() else ""
# Validate
try:
data = yaml.safe_load(config_yaml)
name = data.get("name", "")
version = data.get("version", "")
if not name or not version:
print("Error: config.yaml must have 'name' and 'version' fields", file=sys.stderr)
return 1
except yaml.YAMLError as e:
print(f"Error: Invalid YAML in config.yaml: {e}", file=sys.stderr)
return 1
if args.dry_run:
print("Dry run - validating only")
print()
print(f"Would publish:")
print(f" Name: {name}")
print(f" Version: {version}")
print(f" Config: {len(config_yaml)} bytes")
print(f" README: {len(readme)} bytes")
return 0
# Check for token
config = load_config()
if not config.registry.token:
print("No registry token configured.")
print()
print("1. Register at: https://gitea.brrd.tech/registry/register")
print("2. Generate a token from your dashboard")
print("3. Enter your token below")
print()
try:
token = input("Registry token: ").strip()
if not token:
print("Cancelled.")
return 1
set_registry_token(token)
print("Token saved.")
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 1
print(f"Publishing {name}@{version}...")
try:
client = get_client()
result = client.publish_tool(config_yaml, readme)
pr_url = result.get("pr_url", "")
status = result.get("status", "")
if status == "published" or result.get("version"):
print(f"Published: {result.get('owner', '')}/{result.get('name', '')}@{result.get('version', version)}")
elif pr_url:
print(f"PR created: {pr_url}")
print("Your tool is pending review.")
else:
print("Published successfully!")
# Show suggestions if provided (from Phase 6 smart features)
suggestions = result.get("suggestions", {})
if suggestions:
print()
# Category suggestion
cat_suggestion = suggestions.get("category")
if cat_suggestion and cat_suggestion.get("suggested"):
confidence = cat_suggestion.get("confidence", 0)
print(f"Suggested category: {cat_suggestion['suggested']} ({confidence:.0%} confidence)")
# Similar tools warning
similar = suggestions.get("similar_tools", [])
if similar:
print("Similar existing tools:")
for tool in similar[:3]:
similarity = tool.get("similarity", 0)
print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)")
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Authentication failed.", file=sys.stderr)
print("Your token may have expired. Generate a new one from the registry.", file=sys.stderr)
elif e.code == "INVALID_CONFIG":
print(f"Invalid tool config: {e.message}", file=sys.stderr)
print("Check your config.yaml for errors.", file=sys.stderr)
elif e.code == "VERSION_EXISTS":
print(f"Version already exists: {e.message}", file=sys.stderr)
print("Bump the version in config.yaml and try again.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error publishing: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_my_tools(args):
"""List your published tools."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
tools = client.get_my_tools()
if not tools:
print("You haven't published any tools yet.")
print("Publish your first tool with: cmdforge 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(" cmdforge 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
def _cmd_registry_browse(args):
"""Browse tools (TUI)."""
try:
from ..ui_registry import run_registry_browser
return run_registry_browser()
except ImportError:
print("TUI browser requires urwid. Install with:", file=sys.stderr)
print(" pip install 'cmdforge[tui]'", file=sys.stderr)
print()
print("Or search from command line:", file=sys.stderr)
print(" cmdforge registry search <query>", file=sys.stderr)
return 1
+333
View File
@@ -0,0 +1,333 @@
"""Tool management commands."""
import sys
from pathlib import Path
from ..tool import (
list_tools, load_tool, save_tool, delete_tool, get_tools_dir,
Tool, ToolArgument, PromptStep, CodeStep
)
from ..ui import run_ui
def cmd_list(args):
"""List all tools."""
tools = list_tools()
if not tools:
print("No tools found.")
print("Create your first tool with: cmdforge ui")
return 0
print(f"Available tools ({len(tools)}):\n")
for name in tools:
tool = load_tool(name)
if tool:
print(f" {name}")
print(f" {tool.description or 'No description'}")
# Show arguments
if tool.arguments:
args_str = ", ".join(arg.flag for arg in tool.arguments)
print(f" Arguments: {args_str}")
# Show steps
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
elif isinstance(step, CodeStep):
step_info.append("CODE")
print(f" Steps: {' -> '.join(step_info)}")
print()
return 0
def cmd_create(args):
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
name = args.name
# Check if already exists
existing = load_tool(name)
if existing and not args.force:
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
return 1
# Create a tool with a single prompt step
steps = []
if args.prompt:
steps.append(PromptStep(
prompt=args.prompt,
provider=args.provider or "mock",
output_var="response"
))
tool = Tool(
name=name,
description=args.description or "",
arguments=[],
steps=steps,
output="{response}" if steps else "{input}"
)
path = save_tool(tool)
print(f"Created tool '{name}'")
print(f"Config: {path}")
print(f"\nUse 'cmdforge ui' to add arguments, steps, and customize.")
print(f"Or run: {name} < input.txt")
return 0
def cmd_edit(args):
"""Edit a tool (opens in $EDITOR or nano)."""
import os
import subprocess
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
config_path = get_tools_dir() / args.name / "config.yaml"
editor = os.environ.get("EDITOR", "nano")
try:
subprocess.run([editor, str(config_path)], check=True)
print(f"Tool '{args.name}' updated.")
return 0
except subprocess.CalledProcessError:
print(f"Error: Editor failed.")
return 1
except FileNotFoundError:
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
return 1
def cmd_delete(args):
"""Delete a tool."""
if not load_tool(args.name):
print(f"Error: Tool '{args.name}' not found.")
return 1
if not args.force:
confirm = input(f"Delete tool '{args.name}'? [y/N] ")
if confirm.lower() != 'y':
print("Cancelled.")
return 0
if delete_tool(args.name):
print(f"Deleted tool '{args.name}'.")
return 0
else:
print(f"Error: Failed to delete '{args.name}'.")
return 1
def cmd_test(args):
"""Test a tool with mock provider."""
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
from ..runner import run_tool
# Read test input
if args.input:
input_text = Path(args.input).read_text()
else:
print("Enter test input (Ctrl+D to end):")
input_text = sys.stdin.read()
print("\n--- Running with mock provider ---\n")
output, code = run_tool(
tool=tool,
input_text=input_text,
custom_args={},
provider_override="mock",
dry_run=args.dry_run,
show_prompt=True,
verbose=True
)
if output:
print("\n--- Output ---\n")
print(output)
return code
def cmd_run(args):
"""Run a tool."""
from ..runner import run_tool
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
return 1
# Read input
if args.input:
# Read from file
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
return 1
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args from remaining arguments
custom_args = {}
if args.tool_args:
# Parse tool-specific arguments
i = 0
while i < len(args.tool_args):
arg = args.tool_args[i]
if arg.startswith('--'):
key = arg[2:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
elif arg.startswith('-'):
key = arg[1:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
else:
i += 1
# Run tool
output, code = run_tool(
tool=tool,
input_text=input_text,
custom_args=custom_args,
provider_override=args.provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose
)
# Write output
if code == 0 and output:
if args.output:
Path(args.output).write_text(output)
else:
print(output)
return code
def cmd_ui(args):
"""Launch the interactive UI."""
run_ui()
return 0
def cmd_refresh(args):
"""Refresh all wrapper scripts with the current Python path."""
from ..tool import create_wrapper_script
tools = list_tools()
if not tools:
print("No tools found.")
return 0
print(f"Refreshing wrapper scripts for {len(tools)} tools...")
for name in tools:
path = create_wrapper_script(name)
print(f" {name} -> {path}")
print("\nDone. Wrapper scripts updated to use current Python interpreter.")
return 0
def cmd_docs(args):
"""View or edit tool documentation."""
import os
import subprocess
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
readme_path = get_tools_dir() / args.name / "README.md"
if args.edit:
# Edit/create README
editor = os.environ.get("EDITOR", "nano")
# Create a template if README doesn't exist
if not readme_path.exists():
template = f"""# {args.name}
{tool.description or 'No description provided.'}
## Usage
```bash
echo "input" | {args.name}
```
## Arguments
| Flag | Default | Description |
|------|---------|-------------|
"""
for arg in tool.arguments:
template += f"| `{arg.flag}` | {arg.default or ''} | {arg.description or ''} |\n"
template += """
## Examples
```bash
# Example 1
```
## Requirements
- List any dependencies here
"""
readme_path.write_text(template)
print(f"Created template: {readme_path}")
try:
subprocess.run([editor, str(readme_path)], check=True)
print(f"Documentation updated: {readme_path}")
return 0
except subprocess.CalledProcessError:
print("Error: Editor failed.")
return 1
except FileNotFoundError:
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
return 1
else:
# View README
if not readme_path.exists():
print(f"No documentation found for '{args.name}'.")
print(f"Create it with: cmdforge docs {args.name} --edit")
return 1
print(readme_path.read_text())
return 0
+135
View File
@@ -0,0 +1,135 @@
"""Global configuration handling for CmdForge.
Manages ~/.cmdforge/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() / ".cmdforge"
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 CmdForge configuration."""
registry: RegistryConfig = field(default_factory=RegistryConfig)
client_id: str = ""
auto_fetch_from_registry: bool = True
default_provider: Optional[str] = None
def __post_init__(self):
# Generate client_id if not set
if not self.client_id:
self.client_id = f"anon_{uuid.uuid4().hex[:16]}"
def to_dict(self) -> dict:
d = {
"registry": self.registry.to_dict(),
"client_id": self.client_id,
"auto_fetch_from_registry": self.auto_fetch_from_registry,
}
if self.default_provider:
d["default_provider"] = self.default_provider
return d
@classmethod
def from_dict(cls, data: dict) -> "Config":
registry_data = data.get("registry", {})
return cls(
registry=RegistryConfig.from_dict(registry_data),
client_id=data.get("client_id", ""),
auto_fetch_from_registry=data.get("auto_fetch_from_registry", True),
default_provider=data.get("default_provider")
)
def get_config_dir() -> Path:
"""Get the config directory, creating it if needed."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
return CONFIG_DIR
def load_config() -> Config:
"""Load configuration from disk, creating defaults if needed."""
config_path = get_config_dir() / "config.yaml"
if not config_path.exists():
# Create default config
config = Config()
save_config(config)
return config
try:
data = yaml.safe_load(config_path.read_text()) or {}
return Config.from_dict(data)
except Exception as e:
print(f"Warning: Error loading config, using defaults: {e}")
return Config()
def save_config(config: Config) -> Path:
"""Save configuration to disk."""
config_path = get_config_dir() / "config.yaml"
config_path.write_text(yaml.dump(config.to_dict(), default_flow_style=False, sort_keys=False))
return config_path
def get_registry_url() -> str:
"""Get the configured registry URL."""
config = load_config()
return config.registry.url
def get_registry_token() -> Optional[str]:
"""Get the configured registry token."""
config = load_config()
return config.registry.token
def set_registry_token(token: str) -> None:
"""Set and save the registry token."""
config = load_config()
config.registry.token = token
save_config(config)
def get_client_id() -> str:
"""Get the client ID for anonymous usage tracking."""
config = load_config()
return config.client_id
def is_auto_fetch_enabled() -> bool:
"""Check if auto-fetch from registry is enabled."""
config = load_config()
return config.auto_fetch_from_registry
+276
View File
@@ -0,0 +1,276 @@
"""Project manifest (cmdforge.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 = "cmdforge.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 (cmdforge.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 cmdforge.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: ./cmdforge.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}
+273
View File
@@ -0,0 +1,273 @@
"""Provider abstraction for AI CLI tools."""
import os
import shlex
import subprocess
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List
import yaml
# Default providers config location
PROVIDERS_FILE = Path.home() / ".cmdforge" / "providers.yaml"
@dataclass
class Provider:
"""Definition of an AI provider."""
name: str
command: str
description: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"command": self.command,
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> "Provider":
return cls(
name=data["name"],
command=data["command"],
description=data.get("description", ""),
)
@dataclass
class ProviderResult:
"""Result from a provider call."""
text: str
success: bool
error: Optional[str] = None
# Default providers that come pre-configured
# Profiled with 4-task test: Math, Code, Reasoning, Extract (Dec 2025)
DEFAULT_PROVIDERS = [
# TOP PICKS - best value/performance ratio
Provider("opencode-deepseek", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat", "13s 4/4 | BEST VALUE, cheap, fast, accurate"),
Provider("opencode-pickle", "$HOME/.opencode/bin/opencode run --model opencode/big-pickle", "13s 4/4 | BEST FREE, accurate"),
Provider("claude-haiku", "claude -p --model haiku", "14s 4/4 | fast, accurate, best paid option"),
Provider("codex", "codex exec -", "14s 4/4 | reliable, auto-routes"),
# CLAUDE - all accurate, paid, good for code
Provider("claude", "claude -p", "18s 4/4 | auto-routes to best model"),
Provider("claude-opus", "claude -p --model opus", "18s 4/4 | highest quality, expensive"),
Provider("claude-sonnet", "claude -p --model sonnet", "21s 4/4 | balanced quality/speed"),
# OPENCODE - additional models
Provider("opencode-nano", "$HOME/.opencode/bin/opencode run --model opencode/gpt-5-nano", "24s 4/4 | GPT-5 Nano, reliable"),
Provider("opencode-reasoner", "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-reasoner", "33s 4/4 | complex reasoning, cheap"),
Provider("opencode-grok", "$HOME/.opencode/bin/opencode run --model opencode/grok-code", "11s 2/4 | fastest but unreliable, FREE"),
# GEMINI - slow CLI but good for large docs (1M token context)
Provider("gemini-flash", "gemini --model gemini-2.5-flash", "28s 4/4 | use this for quick tasks"),
Provider("gemini", "gemini --model gemini-2.5-pro", "91s 3/4 | slow CLI, best for large docs/PDFs"),
# Mock for testing
Provider("mock", "mock", "Mock provider for testing"),
]
def get_providers_file() -> Path:
"""Get the providers config file, creating default if needed."""
if not PROVIDERS_FILE.exists():
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
save_providers(DEFAULT_PROVIDERS)
return PROVIDERS_FILE
def load_providers() -> List[Provider]:
"""Load all defined providers."""
providers_file = get_providers_file()
try:
data = yaml.safe_load(providers_file.read_text())
if not data or "providers" not in data:
return DEFAULT_PROVIDERS.copy()
return [Provider.from_dict(p) for p in data["providers"]]
except Exception:
return DEFAULT_PROVIDERS.copy()
def save_providers(providers: List[Provider]):
"""Save providers to config file."""
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
data = {"providers": [p.to_dict() for p in providers]}
PROVIDERS_FILE.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
def get_provider(name: str) -> Optional[Provider]:
"""Get a provider by name."""
providers = load_providers()
for p in providers:
if p.name == name:
return p
return None
def add_provider(provider: Provider) -> bool:
"""Add or update a provider."""
providers = load_providers()
# Update if exists, otherwise add
for i, p in enumerate(providers):
if p.name == provider.name:
providers[i] = provider
save_providers(providers)
return True
providers.append(provider)
save_providers(providers)
return True
def delete_provider(name: str) -> bool:
"""Delete a provider by name."""
providers = load_providers()
original_len = len(providers)
providers = [p for p in providers if p.name != name]
if len(providers) < original_len:
save_providers(providers)
return True
return False
def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> ProviderResult:
"""
Call an AI provider with the given prompt.
Args:
provider_name: Name of the provider to use
prompt: The prompt to send
timeout: Maximum execution time in seconds
Returns:
ProviderResult with the response text or error
"""
# Handle mock provider specially
if provider_name.lower() == "mock":
return mock_provider(prompt)
# Look up provider
provider = get_provider(provider_name)
if not provider:
return ProviderResult(
text="",
success=False,
error=f"Provider '{provider_name}' not found. Use 'cmdforge providers' to manage providers."
)
# Parse command (expand environment variables)
cmd = os.path.expandvars(provider.command)
# Check if base command exists (use shlex for proper quote handling)
try:
cmd_parts = shlex.split(cmd)
base_cmd = cmd_parts[0] if cmd_parts else cmd.split()[0]
except ValueError:
# shlex failed (unbalanced quotes, etc.) - fall back to simple split
base_cmd = cmd.split()[0]
# Expand ~ for the which check
base_cmd_expanded = os.path.expanduser(base_cmd)
if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded):
return ProviderResult(
text="",
success=False,
error=f"Command '{base_cmd}' not found. Is it installed and in PATH?\n\nTo install AI providers, run: cmdforge providers install"
)
try:
result = subprocess.run(
cmd,
shell=True,
input=prompt,
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode != 0:
error_msg = f"Provider exited with code {result.returncode}: {result.stderr}"
if "not found" in result.stderr.lower() or "not installed" in result.stderr.lower():
error_msg += "\n\nTo install AI providers, run: cmdforge providers install"
return ProviderResult(
text="",
success=False,
error=error_msg
)
# Warn if output is empty (provider ran but returned nothing)
if not result.stdout.strip():
stderr = result.stderr.strip()
# Check for OpenCode's ProviderModelNotFoundError
if "ProviderModelNotFoundError" in stderr or "ModelNotFoundError" in stderr:
# Extract provider and model info if possible
import re
provider_match = re.search(r'providerID:\s*"([^"]+)"', stderr)
model_match = re.search(r'modelID:\s*"([^"]+)"', stderr)
provider_id = provider_match.group(1) if provider_match else "unknown"
model_id = model_match.group(1) if model_match else "unknown"
return ProviderResult(
text="",
success=False,
error=f"Model '{model_id}' from provider '{provider_id}' is not available.\n\n"
f"To fix this, either:\n"
f" 1. Run 'opencode' to connect the {provider_id} provider\n"
f" 2. Use --provider to pick a different model (e.g., --provider opencode-pickle)\n"
f" 3. Run 'cmdforge ui' to edit the tool's default provider"
)
stderr_hint = f" (stderr: {stderr[:200]}...)" if len(stderr) > 200 else (f" (stderr: {stderr})" if stderr else "")
return ProviderResult(
text="",
success=False,
error=f"Provider returned empty output{stderr_hint}.\n\nThis may mean the model is not available. Try a different provider or run: cmdforge providers install"
)
return ProviderResult(text=result.stdout, success=True)
except subprocess.TimeoutExpired:
return ProviderResult(
text="",
success=False,
error=f"Provider timed out after {timeout} seconds"
)
except Exception as e:
return ProviderResult(
text="",
success=False,
error=f"Provider error: {str(e)}"
)
def mock_provider(prompt: str) -> ProviderResult:
"""
Return a mock response for testing.
Args:
prompt: The prompt (used for generating mock response)
Returns:
ProviderResult with mock response
"""
lines = prompt.strip().split('\n')
preview = lines[0][:50] + "..." if len(lines[0]) > 50 else lines[0]
return ProviderResult(
text=f"[MOCK RESPONSE]\n"
f"Prompt length: {len(prompt)} chars, {len(lines)} lines\n"
f"First line: {preview}\n"
f"\n"
f"This is a mock response. Use a real provider for actual output.",
success=True
)
+3
View File
@@ -0,0 +1,3 @@
"""Registry API server package."""
__all__ = ["app"]
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
"""Category suggestion helpers for registry tools."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Tuple
import yaml
def load_categories(categories_path: Path) -> List[Dict]:
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
return data.get("categories", [])
def suggest_categories(
name: str,
description: str,
tags: List[str],
categories_path: Path,
) -> List[Tuple[str, float]]:
"""Suggest categories ranked by confidence.
Uses keyword matching against name/description/tags.
Returns a list of (category_name, confidence).
"""
categories = load_categories(categories_path)
text = f"{name} {description} {' '.join(tags)}".lower()
suggestions: List[Tuple[str, float]] = []
for cat in categories:
cat_name = cat.get("name")
keywords = [str(k).lower() for k in cat.get("keywords", []) if k]
if not cat_name or not keywords:
continue
hits = sum(1 for k in keywords if k in text)
confidence = hits / max(len(keywords), 1)
if hits:
suggestions.append((cat_name, round(confidence, 3)))
suggestions.sort(key=lambda item: item[1], reverse=True)
return suggestions
+276
View File
@@ -0,0 +1,276 @@
"""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,
scrutiny_status TEXT DEFAULT 'pending',
scrutiny_report TEXT,
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);
CREATE TABLE IF NOT EXISTS pageviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
referrer TEXT,
user_agent TEXT,
ip_hash TEXT,
viewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path, viewed_at DESC);
CREATE INDEX IF NOT EXISTS idx_pageviews_date ON pageviews(date(viewed_at), path);
"""
def get_db_path() -> Path:
default_path = Path.home() / ".cmdforge" / "registry" / "registry.db"
return Path(os.environ.get("CMDFORGE_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
# WAL mode provides better concurrency but may not work on all filesystems
# (e.g., some FUSE filesystems like mergerfs). Fall back gracefully.
try:
conn.execute("PRAGMA journal_mode=WAL;")
except sqlite3.OperationalError:
# Fall back to DELETE journal mode which works everywhere
conn.execute("PRAGMA journal_mode=DELETE;")
conn.execute("PRAGMA foreign_keys=ON;")
return conn
def init_db(conn: sqlite3.Connection) -> None:
conn.executescript(SCHEMA_SQL)
conn.commit()
def query_one(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
cur = conn.execute(sql, params or [])
return cur.fetchone()
def query_all(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
cur = conn.execute(sql, params or [])
return cur.fetchall()
def execute(conn: sqlite3.Connection, sql: str, params: Iterable | None = None) -> None:
conn.execute(sql, params or [])
conn.commit()
+29
View File
@@ -0,0 +1,29 @@
"""In-memory rate limiting for the registry API."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Dict, Tuple
@dataclass
class RateLimitState:
reset_at: float
count: int
class RateLimiter:
def __init__(self) -> None:
self._state: Dict[Tuple[str, str], RateLimitState] = {}
def check(self, scope_key: str, limit: int, window_seconds: int) -> Tuple[bool, RateLimitState]:
now = time.time()
key = (scope_key, str(window_seconds))
state = self._state.get(key)
if not state or now >= state.reset_at:
state = RateLimitState(reset_at=now + window_seconds, count=0)
self._state[key] = state
state.count += 1
allowed = state.count <= limit
return allowed, state
+588
View File
@@ -0,0 +1,588 @@
"""Tool scrutiny system for automated review of published tools.
This module analyzes tools on publish to verify:
1. Honesty - Does the tool do what its description claims?
2. Transparency - Is behavior visible or hidden/obfuscated?
3. Scope - Does the code stay within expected bounds?
4. Efficiency - Are AI calls necessary or wasteful?
"""
from __future__ import annotations
import base64
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
import yaml
class CheckResult(Enum):
PASS = "pass"
WARNING = "warning"
FAIL = "fail"
@dataclass
class Finding:
"""A single finding from scrutiny analysis."""
check: str
result: CheckResult
message: str
suggestion: Optional[str] = None
location: Optional[str] = None # e.g., "step 2", "code block"
@dataclass
class ScrutinyReport:
"""Complete scrutiny report for a tool."""
tool_name: str
findings: List[Finding] = field(default_factory=list)
optimizations: List[Any] = field(default_factory=list) # OptimizationSuggestion list
@property
def passed(self) -> bool:
"""Tool passes if no FAIL results."""
return not any(f.result == CheckResult.FAIL for f in self.findings)
@property
def has_warnings(self) -> bool:
return any(f.result == CheckResult.WARNING for f in self.findings)
@property
def decision(self) -> str:
"""Auto-approve, flag for review, or reject."""
if any(f.result == CheckResult.FAIL for f in self.findings):
return "reject"
if any(f.result == CheckResult.WARNING for f in self.findings):
return "review"
return "approve"
def to_dict(self) -> Dict[str, Any]:
result = {
"tool_name": self.tool_name,
"decision": self.decision,
"passed": self.passed,
"has_warnings": self.has_warnings,
"findings": [
{
"check": f.check,
"result": f.result.value,
"message": f.message,
"suggestion": f.suggestion,
"location": f.location,
}
for f in self.findings
],
}
# Add optimization suggestions if any
if self.optimizations:
result["optimizations"] = [
{
"operation": opt.operation,
"current_prompt": opt.current_prompt,
"optimized_code": opt.optimized_code,
"tradeoffs": opt.tradeoffs,
"location": opt.location,
"action": "optional",
}
for opt in self.optimizations
]
return result
# Keywords that indicate specific behaviors
BEHAVIOR_KEYWORDS = {
"summarize": ["summary", "summarize", "condense", "brief", "shorten", "tldr"],
"translate": ["translate", "translation", "language", "convert to"],
"explain": ["explain", "clarify", "describe", "what is", "how does"],
"fix": ["fix", "correct", "repair", "grammar", "spelling"],
"generate": ["generate", "create", "write", "produce", "make"],
"extract": ["extract", "pull", "find", "identify", "parse"],
"analyze": ["analyze", "review", "examine", "check", "audit"],
"convert": ["convert", "transform", "format", "change to"],
}
# Patterns that suggest obfuscation or suspicious behavior
SUSPICIOUS_PATTERNS = [
(r'base64\.(b64decode|decodebytes)', "Base64 decoding detected"),
(r'exec\s*\(', "Dynamic code execution with exec()"),
(r'eval\s*\(', "Dynamic code execution with eval()"),
(r'__import__\s*\(', "Dynamic import detected"),
(r'subprocess\.(run|call|Popen)', "Subprocess execution detected"),
(r'os\.(system|popen|exec)', "OS command execution detected"),
(r'requests\.(get|post|put|delete)', "Network request detected"),
(r'urllib', "URL library usage detected"),
(r'socket\.', "Raw socket usage detected"),
(r'\\x[0-9a-fA-F]{2}', "Hex-encoded strings detected"),
]
# File/system access patterns
SCOPE_PATTERNS = [
(r'open\s*\([^)]*["\']/', "Absolute path file access"),
(r'open\s*\([^)]*~', "Home directory file access"),
(r'\.ssh', "SSH directory access"),
(r'\.aws', "AWS credentials access"),
(r'\.env', "Environment file access"),
(r'/etc/', "System config access"),
(r'os\.environ', "Environment variable access"),
(r'keyring|password|credential', "Credential-related access"),
]
# Operations that could be done with code (with tradeoffs)
# Format: (prompt_pattern, operation_name, code_template, code_benefit, ai_benefit)
CODE_OPTIMIZATIONS = [
(
r'count (the |how many )?words',
"Word counting",
'word_count = len(input.split())',
"Faster, no API cost, deterministic",
"Handles edge cases (hyphenated words, contractions, numbers as words)",
),
(
r'count (the |how many )?(lines|line)',
"Line counting",
'line_count = len(input.strip().split("\\n"))',
"Instant, no API cost",
"Could interpret 'meaningful lines' vs blank lines",
),
(
r'count (the |how many )?(characters?|chars?)',
"Character counting",
'char_count = len(input)',
"Instant, no API cost",
"Could exclude whitespace or count 'visible' characters",
),
(
r'convert.*(to|into) json',
"JSON conversion",
'import json\nresult = json.dumps(data, indent=2)',
"Reliable, no API cost, handles valid input perfectly",
"Can interpret messy/partial data and fix formatting issues",
),
(
r'parse.*(json|the json)',
"JSON parsing",
'import json\nresult = json.loads(input)',
"Fast, no API cost, strict validation",
"Can fix malformed JSON, handle comments, trailing commas",
),
(
r'convert.*(to|into) csv',
"CSV conversion",
'import csv\nimport io\n# ... csv.writer conversion',
"Reliable for structured data",
"Can infer structure from unstructured text",
),
(
r'(extract|find|get) (all )?(emails?|email addresses)',
"Email extraction",
'import re\nemails = re.findall(r"[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}", input)',
"Fast, consistent pattern matching",
"Handles obfuscated emails (user [at] domain), context-aware",
),
(
r'(extract|find|get) (all )?(urls?|links?)',
"URL extraction",
'import re\nurls = re.findall(r"https?://[^\\s<>\"]+", input)',
"Fast, consistent pattern matching",
"Handles partial URLs, context-aware link detection",
),
(
r'(uppercase|to upper|make upper)',
"Uppercase conversion",
'result = input.upper()',
"Instant, no API cost",
"Language-aware (Turkish İ/i, Greek σ/ς edge cases)",
),
(
r'(lowercase|to lower|make lower)',
"Lowercase conversion",
'result = input.lower()',
"Instant, no API cost",
"Language-aware case handling",
),
(
r'(capitalize|title case)',
"Title case conversion",
'result = input.title()',
"Instant, no API cost",
"Knows style rules (articles, prepositions)",
),
(
r'(reverse|reversed?) (the |this )?(text|string|order)',
"Text reversal",
'result = input[::-1]',
"Instant, no API cost",
"Could reverse by word, sentence, or paragraph intelligently",
),
(
r'(sort|sorted?) (the |these |this )?(lines?|items?|list)',
"Sorting",
'result = "\\n".join(sorted(input.strip().split("\\n")))',
"Fast, deterministic alphabetical sort",
"Can sort by meaning ('sort by importance', 'by date mentioned')",
),
(
r'remove (duplicate|duplicated?) (lines?|items?)',
"Deduplication",
'seen = set()\nresult = "\\n".join(x for x in input.split("\\n") if not (x in seen or seen.add(x)))',
"Fast, exact matching",
"Can detect semantic duplicates ('USA' vs 'United States')",
),
(
r'(remove|strip|trim) (whitespace|spaces|blank)',
"Whitespace removal",
'result = " ".join(input.split())',
"Instant, predictable",
"Could preserve meaningful spacing, format-aware",
),
(
r'split (by|on|into)',
"Text splitting",
'# result = input.split(delimiter)',
"Fast, exact delimiter matching",
"Can split by meaning ('split into paragraphs about each topic')",
),
]
def analyze_tool(config_yaml: str, description: str) -> ScrutinyReport:
"""Run full scrutiny analysis on a tool.
Args:
config_yaml: The tool's YAML configuration
description: The tool's description
Returns:
ScrutinyReport with all findings
"""
try:
config = yaml.safe_load(config_yaml) or {}
except yaml.YAMLError:
return ScrutinyReport(
tool_name="unknown",
findings=[Finding(
check="parse",
result=CheckResult.FAIL,
message="Invalid YAML configuration",
)]
)
tool_name = config.get("name", "unknown")
report = ScrutinyReport(tool_name=tool_name)
# Run all checks
report.findings.extend(check_honesty(config, description))
report.findings.extend(check_transparency(config))
report.findings.extend(check_scope(config))
# Efficiency check returns findings and optimizations
efficiency_findings, optimizations = check_efficiency(config)
report.findings.extend(efficiency_findings)
report.optimizations = optimizations
# If no findings, add a pass
if not report.findings:
report.findings.append(Finding(
check="overall",
result=CheckResult.PASS,
message="All checks passed",
))
return report
def check_honesty(config: Dict[str, Any], description: str) -> List[Finding]:
"""Check if tool behavior matches its description.
Analyzes prompts and compares against description claims.
"""
findings = []
steps = config.get("steps", [])
description_lower = description.lower()
# Extract all prompt text
all_prompts = ""
for step in steps:
if step.get("type") == "prompt":
all_prompts += " " + (step.get("prompt") or "").lower()
# Detect what the description claims
claimed_behaviors = set()
for behavior, keywords in BEHAVIOR_KEYWORDS.items():
if any(kw in description_lower for kw in keywords):
claimed_behaviors.add(behavior)
# Detect what prompts actually do
actual_behaviors = set()
for behavior, keywords in BEHAVIOR_KEYWORDS.items():
if any(kw in all_prompts for kw in keywords):
actual_behaviors.add(behavior)
# Check for mismatches
claimed_not_done = claimed_behaviors - actual_behaviors
done_not_claimed = actual_behaviors - claimed_behaviors
if claimed_not_done:
findings.append(Finding(
check="honesty",
result=CheckResult.WARNING,
message=f"Description claims '{', '.join(claimed_not_done)}' but prompts don't reflect this",
suggestion="Update description to accurately reflect tool behavior",
))
if done_not_claimed and claimed_behaviors:
# Only warn if tool claims to do something specific but also does other things
findings.append(Finding(
check="honesty",
result=CheckResult.WARNING,
message=f"Prompts include '{', '.join(done_not_claimed)}' not mentioned in description",
suggestion="Update description to include all tool capabilities",
))
# Check for empty or minimal prompts
for i, step in enumerate(steps):
if step.get("type") == "prompt":
prompt = step.get("prompt", "").strip()
if len(prompt) < 10:
findings.append(Finding(
check="honesty",
result=CheckResult.WARNING,
message="Prompt is unusually short",
location=f"step {i + 1}",
))
if not findings:
findings.append(Finding(
check="honesty",
result=CheckResult.PASS,
message="Description matches observed behavior",
))
return findings
def check_transparency(config: Dict[str, Any]) -> List[Finding]:
"""Check for obfuscated or hidden behavior.
Looks for encoded strings, dynamic execution, etc.
"""
findings = []
steps = config.get("steps", [])
for i, step in enumerate(steps):
if step.get("type") == "code":
code = step.get("code", "")
location = f"step {i + 1}"
# Check for suspicious patterns
for pattern, description in SUSPICIOUS_PATTERNS:
if re.search(pattern, code, re.IGNORECASE):
# Determine severity
if "exec" in pattern or "eval" in pattern:
result = CheckResult.FAIL
msg = f"{description} - potential code injection risk"
elif "socket" in pattern or "subprocess" in pattern:
result = CheckResult.WARNING
msg = f"{description} - flagged for manual review"
else:
result = CheckResult.WARNING
msg = description
findings.append(Finding(
check="transparency",
result=result,
message=msg,
location=location,
))
# Check for obfuscated variable names
obfuscated = re.findall(r'\b([a-z])\1{2,}\b|\b[a-z]{1,2}\d+\b', code)
if len(obfuscated) > 3:
findings.append(Finding(
check="transparency",
result=CheckResult.WARNING,
message="Multiple obfuscated variable names detected",
location=location,
suggestion="Use descriptive variable names",
))
# Check for very long single lines (possible obfuscation)
for line_num, line in enumerate(code.split('\n'), 1):
if len(line) > 500:
findings.append(Finding(
check="transparency",
result=CheckResult.WARNING,
message=f"Unusually long code line ({len(line)} chars)",
location=f"{location}, line {line_num}",
))
if not findings:
findings.append(Finding(
check="transparency",
result=CheckResult.PASS,
message="No obfuscation detected",
))
return findings
def check_scope(config: Dict[str, Any]) -> List[Finding]:
"""Check if code accesses unexpected resources.
Flags file system, network, or credential access.
"""
findings = []
steps = config.get("steps", [])
tool_name = config.get("name", "").lower()
description = config.get("description", "").lower()
for i, step in enumerate(steps):
if step.get("type") == "code":
code = step.get("code", "")
location = f"step {i + 1}"
for pattern, description_text in SCOPE_PATTERNS:
if re.search(pattern, code, re.IGNORECASE):
# Check if this access seems expected based on tool name/description
is_expected = False
if "ssh" in pattern and ("ssh" in tool_name or "ssh" in description):
is_expected = True
if "env" in pattern and ("env" in tool_name or "environment" in description):
is_expected = True
if "file" in description_text.lower() and ("file" in tool_name or "file" in description):
is_expected = True
if not is_expected:
# Credential access is always flagged
if "credential" in pattern or ".ssh" in pattern or ".aws" in pattern:
result = CheckResult.FAIL
suggestion = "Remove credential access or justify in description"
else:
result = CheckResult.WARNING
suggestion = "Document this access in tool description"
findings.append(Finding(
check="scope",
result=result,
message=description_text,
location=location,
suggestion=suggestion,
))
if not findings:
findings.append(Finding(
check="scope",
result=CheckResult.PASS,
message="No unexpected resource access",
))
return findings
@dataclass
class OptimizationSuggestion:
"""A code optimization suggestion for an AI prompt."""
operation: str
current_prompt: str
optimized_code: str
tradeoffs: Dict[str, str]
location: str
def check_efficiency(config: Dict[str, Any]) -> List[Finding]:
"""Check for AI calls that could optionally be pure code.
Generates optimization suggestions with tradeoffs - these are
informational, not penalties. Authors can choose to keep AI
for valid reasons.
"""
findings = []
steps = config.get("steps", [])
optimizations = []
for i, step in enumerate(steps):
if step.get("type") == "prompt":
prompt = step.get("prompt", "")
prompt_lower = prompt.lower()
location = f"step {i + 1}"
# Check against known optimizable patterns
for pattern, operation, code, code_benefit, ai_benefit in CODE_OPTIMIZATIONS:
if re.search(pattern, prompt_lower):
optimizations.append(OptimizationSuggestion(
operation=operation,
current_prompt=prompt.strip()[:200], # Truncate for display
optimized_code=code,
tradeoffs={
"code": code_benefit,
"ai": ai_benefit,
},
location=location,
))
break # One suggestion per step
# Add optimization suggestions as findings (not warnings - optional)
for opt in optimizations:
findings.append(Finding(
check="efficiency",
result=CheckResult.PASS, # Not a warning - it's a suggestion
message=f"Optional optimization: {opt.operation} could use code",
location=opt.location,
suggestion=f"Code: {opt.optimized_code}",
))
# Check for all-AI tools (informational, not a warning)
code_steps = [s for s in steps if s.get("type") == "code"]
prompt_steps = [s for s in steps if s.get("type") == "prompt"]
if prompt_steps and not code_steps and len(prompt_steps) > 3:
findings.append(Finding(
check="efficiency",
result=CheckResult.PASS, # Informational
message=f"Tool has {len(prompt_steps)} AI steps - consider if any could be code",
suggestion="Mixing AI and code steps can reduce API costs",
))
if not findings:
findings.append(Finding(
check="efficiency",
result=CheckResult.PASS,
message="AI usage appears appropriate",
))
return findings, optimizations
def get_optimization_details(optimizations: List[OptimizationSuggestion]) -> List[Dict[str, Any]]:
"""Convert optimization suggestions to serializable format."""
return [
{
"operation": opt.operation,
"current_prompt": opt.current_prompt,
"optimized_code": opt.optimized_code,
"tradeoffs": opt.tradeoffs,
"location": opt.location,
"action": "optional", # Author chooses
}
for opt in optimizations
]
def scrutinize_tool(config_yaml: str, description: str, readme: Optional[str] = None) -> Dict[str, Any]:
"""Main entry point for tool scrutiny.
Args:
config_yaml: The tool's YAML configuration
description: The tool's description
readme: Optional README content (for additional context)
Returns:
Dictionary with scrutiny results
"""
report = analyze_tool(config_yaml, description)
return report.to_dict()
+77
View File
@@ -0,0 +1,77 @@
"""Similarity detection for registry tools."""
from __future__ import annotations
from difflib import SequenceMatcher
from typing import Dict, List, Tuple
def _tokenize(text: str) -> List[str]:
return [t for t in re_split_nonword(text.lower()) if t]
def re_split_nonword(text: str) -> List[str]:
token = ""
tokens = []
for ch in text:
if ch.isalnum():
token += ch
else:
if token:
tokens.append(token)
token = ""
if token:
tokens.append(token)
return tokens
def jaccard(a: List[str], b: List[str]) -> float:
set_a = set(a)
set_b = set(b)
if not set_a and not set_b:
return 0.0
return len(set_a & set_b) / max(len(set_a | set_b), 1)
def name_similarity(name_a: str, name_b: str) -> float:
return SequenceMatcher(None, name_a.lower(), name_b.lower()).ratio()
def description_similarity(desc_a: str, desc_b: str) -> float:
return jaccard(_tokenize(desc_a), _tokenize(desc_b))
def tags_similarity(tags_a: List[str], tags_b: List[str]) -> float:
return jaccard([t.lower() for t in tags_a], [t.lower() for t in tags_b])
def score_similarity(
candidate: Dict,
name: str,
description: str,
tags: List[str],
category: str | None,
) -> float:
name_score = name_similarity(name, candidate.get("name", ""))
desc_score = description_similarity(description, candidate.get("description", ""))
tags_score = tags_similarity(tags, candidate.get("tags", []))
category_bonus = 0.1 if category and candidate.get("category") == category else 0.0
score = (0.5 * name_score) + (0.3 * desc_score) + (0.2 * tags_score) + category_bonus
return min(score, 1.0)
def find_similar_tools(
tools: List[Dict],
name: str,
description: str,
tags: List[str],
category: str | None,
threshold: float = 0.6,
) -> List[Tuple[Dict, float]]:
results = []
for tool in tools:
score = score_similarity(tool, name, description, tags, category)
if score >= threshold:
results.append((tool, round(score, 3)))
results.sort(key=lambda item: item[1], reverse=True)
return results
+268
View File
@@ -0,0 +1,268 @@
"""Registry repository sync and webhook processing."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import shutil
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, Tuple
import yaml
from .db import connect_db, query_one
def get_repo_dir() -> Path:
default_dir = Path.home() / ".cmdforge" / "registry" / "repo"
return Path(os.environ.get("CMDFORGE_REGISTRY_REPO_DIR", default_dir))
def get_repo_url() -> str:
return os.environ.get("CMDFORGE_REGISTRY_REPO_URL", "https://gitea.brrd.tech/rob/CmdForge-Registry.git")
def get_repo_branch() -> str:
return os.environ.get("CMDFORGE_REGISTRY_REPO_BRANCH", "main")
def get_categories_cache_path() -> Path:
return Path(os.environ.get(
"CMDFORGE_REGISTRY_CATEGORIES_CACHE",
Path.home() / ".cmdforge" / "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() / ".cmdforge" / "registry" / "locks" / "webhook.lock"
if not acquire_lock(lock_path, timeout):
return 200, {"data": {"status": "skipped", "reason": "sync_in_progress"}}
try:
if is_delivery_processed(conn, delivery_id):
return 200, {"data": {"status": "already_processed"}}
ok, reason = sync_from_repo()
if ok:
record_webhook_delivery(conn, delivery_id, event_type)
conn.commit()
return 200, {"data": {"status": "processed"}}
return 500, {"error": {"code": "SERVER_ERROR", "message": f"Sync failed: {reason}"}}
finally:
release_lock(lock_path)
finally:
conn.close()
+732
View File
@@ -0,0 +1,732 @@
"""Registry API client for CmdForge.
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 CmdForge 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": "CmdForge-CLI/1.0",
"X-CmdForge-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 'cmdforge 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
+674
View File
@@ -0,0 +1,674 @@
"""Tool resolution with proper search order.
Implements the tool resolution order:
1. Local project: ./.cmdforge/<owner>/<name>/config.yaml
2. Global user: ~/.cmdforge/<owner>/<name>/config.yaml
3. Registry: Fetch from API, install to global, then run
4. Error if not found
"""
import logging
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
logger = logging.getLogger(__name__)
# Local project tools directories (support both legacy and documented paths)
LOCAL_TOOLS_DIRS = [Path(".cmdforge"), Path("cmdforge")]
@dataclass
class ToolSpec:
"""Parsed tool specification."""
owner: Optional[str] # None for unqualified names like "summarize"
name: str
version: Optional[str] = None # Version constraint
@property
def full_name(self) -> str:
"""Get full owner/name format."""
if self.owner:
return f"{self.owner}/{self.name}"
return self.name
@property
def is_qualified(self) -> bool:
"""Check if this is a fully qualified name (owner/name)."""
return self.owner is not None
@classmethod
def parse(cls, spec: str) -> "ToolSpec":
"""
Parse a tool specification string.
Formats:
- "summarize" -> owner=None, name="summarize"
- "rob/summarize" -> owner="rob", name="summarize"
- "[email protected]" -> name="summarize", version="1.0.0"
- "rob/summarize@^1.0.0" -> owner="rob", name="summarize", version="^1.0.0"
"""
version = None
# Extract version if present
if "@" in spec:
spec, version = spec.rsplit("@", 1)
# Extract owner if present
if "/" in spec:
owner, name = spec.split("/", 1)
else:
owner = None
name = spec
return cls(owner=owner, name=name, version=version)
@dataclass
class ResolvedTool:
"""Result of tool resolution."""
tool: Tool
source: str # "local", "global", "registry"
path: Path
owner: Optional[str] = None
version: Optional[str] = None
@property
def full_name(self) -> str:
if self.owner:
return f"{self.owner}/{self.tool.name}"
return self.tool.name
class ToolNotFoundError(Exception):
"""Raised when a tool cannot be found."""
def __init__(self, spec: ToolSpec, searched_paths: list):
self.spec = spec
self.searched_paths = searched_paths
super().__init__(f"Tool '{spec.full_name}' not found")
class ToolResolver:
"""Resolves tool specifications to actual tool configs."""
def __init__(
self,
project_dir: Optional[Path] = None,
auto_fetch: Optional[bool] = None,
verbose: bool = False
):
"""
Initialize the resolver.
Args:
project_dir: Project root directory (default: cwd)
auto_fetch: Override auto-fetch setting
verbose: Print debug info
"""
self.project_dir = project_dir or Path.cwd()
self.verbose = verbose
# Determine auto-fetch setting
if auto_fetch is not None:
self.auto_fetch = auto_fetch
else:
self.auto_fetch = is_auto_fetch_enabled()
# Load project manifest if present
self.manifest = load_manifest()
def resolve(self, spec: str | ToolSpec) -> ResolvedTool:
"""
Resolve a tool specification to an actual tool.
Args:
spec: Tool specification (string or ToolSpec)
Returns:
ResolvedTool with loaded tool and metadata
Raises:
ToolNotFoundError: If tool cannot be found
"""
if isinstance(spec, str):
spec = ToolSpec.parse(spec)
searched_paths = []
# Get version constraint from manifest if available
version = spec.version
if not version and self.manifest:
for dep in self.manifest.dependencies:
if dep.tool_name == spec.name:
version = dep.version
if not spec.owner and dep.owner:
spec = ToolSpec(owner=dep.owner, name=spec.name, version=version)
break
# 1. Check local project directory
result = self._find_in_local(spec, searched_paths)
if result:
return result
# 2. Check global user directory
result = self._find_in_global(spec, searched_paths)
if result:
return result
# 3. Try fetching from registry
if self.auto_fetch:
result = self._fetch_from_registry(spec, version)
if result:
return result
# Not found
raise ToolNotFoundError(spec, searched_paths)
def _find_in_local(
self,
spec: ToolSpec,
searched_paths: list
) -> Optional[ResolvedTool]:
"""Search for tool in local project directory."""
local_dirs = [self.project_dir / path for path in LOCAL_TOOLS_DIRS]
local_dirs = [path for path in local_dirs if path.exists()]
if not local_dirs:
return None
for local_dir in local_dirs:
# Try qualified path first if owner is specified
if spec.owner:
path = local_dir / spec.owner / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="local",
path=path.parent,
owner=spec.owner
)
# Try unqualified path
path = local_dir / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="local",
path=path.parent
)
# Search all owner directories for this tool name
# Priority: official first, then alphabetical for deterministic resolution
owner_dirs = [d for d in local_dir.iterdir() if d.is_dir() and not d.name.startswith(".")]
def owner_priority(d: Path) -> tuple:
if d.name == "official":
return (0, d.name)
return (1, d.name)
owner_dirs.sort(key=owner_priority)
for owner_dir in owner_dirs:
tool_dir = owner_dir / spec.name
config_path = tool_dir / "config.yaml"
if config_path.exists():
tool = self._load_tool_from_path(config_path)
if tool:
return ResolvedTool(
tool=tool,
source="local",
path=tool_dir,
owner=owner_dir.name
)
return None
def _find_in_global(
self,
spec: ToolSpec,
searched_paths: list
) -> Optional[ResolvedTool]:
"""Search for tool in global user directory."""
global_dir = TOOLS_DIR
if not global_dir.exists():
return None
# Try qualified path first if owner is specified
if spec.owner:
path = global_dir / spec.owner / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="global",
path=path.parent,
owner=spec.owner
)
# Try unqualified path (old-style tools without owner)
path = global_dir / spec.name / "config.yaml"
searched_paths.append(str(path))
if path.exists():
tool = self._load_tool_from_path(path)
if tool:
return ResolvedTool(
tool=tool,
source="global",
path=path.parent
)
# Search all owner directories for this tool name
# Priority: official first, then alphabetical for deterministic resolution
owner_dirs = [
d for d in global_dir.iterdir()
if d.is_dir() and not d.name.startswith(".") and d.name not in ("registry",)
]
# Sort with official first, then alphabetical
def owner_priority(d: Path) -> tuple:
if d.name == "official":
return (0, d.name)
return (1, d.name)
owner_dirs.sort(key=owner_priority)
for owner_dir in owner_dirs:
tool_dir = owner_dir / spec.name
config_path = tool_dir / "config.yaml"
if config_path.exists():
tool = self._load_tool_from_path(config_path)
if tool:
return ResolvedTool(
tool=tool,
source="global",
path=tool_dir,
owner=owner_dir.name
)
return None
def _fetch_from_registry(
self,
spec: ToolSpec,
version: Optional[str] = None
) -> Optional[ResolvedTool]:
"""Fetch and install tool from registry."""
try:
# Import here to avoid circular imports
from .registry_client import get_client, RegistryError
if self.verbose:
print(f"Fetching '{spec.full_name}' from registry...", file=sys.stderr)
client = get_client()
# Determine owner for registry lookup
owner = spec.owner or "official"
try:
result = client.download_tool(
owner=owner,
name=spec.name,
version=version,
install=True
)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND" and not spec.owner:
# Try searching for most popular tool with this name
results = client.search_tools(spec.name, per_page=1)
if results.data:
first = results.data[0]
result = client.download_tool(
owner=first["owner"],
name=first["name"],
version=version,
install=True
)
else:
return None
else:
raise
# Install the tool locally
resolved = self._install_from_registry(
owner=result.owner,
name=result.name,
version=result.resolved_version,
config_yaml=result.config_yaml,
readme=result.readme
)
if self.verbose:
print(
f"Installed: {result.owner}/{result.name}@{result.resolved_version}",
file=sys.stderr
)
return resolved
except ImportError:
# Registry client not available
logger.debug("Registry client not available")
return None
except Exception as e:
logger.warning(f"Registry fetch failed: {e}")
if self.verbose:
print(f"Registry fetch failed: {e}", file=sys.stderr)
return None
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
# CmdForge wrapper for '{owner}/{name}'
# Auto-generated - do not edit
exec {python_path} -m cmdforge.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: cmdforge.runner owner/name
# Owner slugs can contain lowercase alphanumeric and hyphens
match = re.search(r'cmdforge\.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:
logger.warning(f"Error loading tool from {config_path}: {e}")
if self.verbose:
print(f"Error loading tool from {config_path}: {e}", file=sys.stderr)
return None
def _convert_legacy_format(self, data: dict) -> dict:
"""Convert legacy tool format to new format."""
steps = []
if data.get("prompt"):
steps.append({
"type": "prompt",
"prompt": data["prompt"],
"provider": data.get("provider", "mock"),
"output_var": "response"
})
arguments = []
for inp in data.get("inputs", []):
arguments.append({
"flag": inp.get("flag", f"--{inp['name']}"),
"variable": inp["name"],
"default": inp.get("default"),
"description": inp.get("description", "")
})
return {
"name": data["name"],
"description": data.get("description", ""),
"arguments": arguments,
"steps": steps,
"output": "{response}" if steps else "{input}"
}
# -------------------------------------------------------------------------
# Convenience functions
# -------------------------------------------------------------------------
def resolve_tool(spec: str, auto_fetch: Optional[bool] = None) -> ResolvedTool:
"""
Resolve a tool specification to an actual tool.
Args:
spec: Tool specification (e.g., "summarize", "rob/[email protected]")
auto_fetch: Override auto-fetch setting
Returns:
ResolvedTool with loaded tool and metadata
"""
resolver = ToolResolver(auto_fetch=auto_fetch)
return resolver.resolve(spec)
def find_tool(name: str) -> Optional[ResolvedTool]:
"""
Find a tool by name without auto-fetching.
Args:
name: Tool name or owner/name
Returns:
ResolvedTool if found, None otherwise
"""
try:
resolver = ToolResolver(auto_fetch=False)
return resolver.resolve(name)
except ToolNotFoundError:
return None
def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedTool:
"""
Install a tool from the registry.
Args:
spec: Tool specification
version: Version constraint
Returns:
ResolvedTool for installed tool
"""
from .registry_client import get_client
parsed = ToolSpec.parse(spec)
if version:
parsed.version = version
client = get_client()
owner = parsed.owner or "official"
result = client.download_tool(
owner=owner,
name=parsed.name,
version=parsed.version,
install=True
)
resolver = ToolResolver(auto_fetch=False)
return resolver._install_from_registry(
owner=result.owner,
name=result.name,
version=result.resolved_version,
config_yaml=result.config_yaml,
readme=result.readme
)
def uninstall_tool(spec: str) -> bool:
"""
Uninstall a tool.
Args:
spec: Tool specification
Returns:
True if tool was uninstalled
"""
import shutil
parsed = ToolSpec.parse(spec)
# Find the tool first
resolved = find_tool(spec)
if not resolved:
return False
# Remove tool directory
if resolved.path.exists():
shutil.rmtree(resolved.path)
# Remove wrapper script(s)
bin_dir = get_bin_dir()
# Remove short name wrapper if it belongs to this tool
short_wrapper = bin_dir / parsed.name
if short_wrapper.exists():
resolver = ToolResolver(auto_fetch=False)
wrapper_owner = resolver._get_wrapper_owner(short_wrapper)
if wrapper_owner == resolved.owner or wrapper_owner is None:
short_wrapper.unlink()
# Remove owner-name wrapper
if resolved.owner:
long_wrapper = bin_dir / f"{resolved.owner}-{parsed.name}"
if long_wrapper.exists():
long_wrapper.unlink()
return True
def list_installed_tools() -> list[ResolvedTool]:
"""
List all installed tools (global only).
Returns:
List of ResolvedTool objects
"""
tools = []
if not TOOLS_DIR.exists():
return tools
# Check owner directories
for item in TOOLS_DIR.iterdir():
if item.is_dir() and not item.name.startswith("."):
# Skip non-owner directories
if item.name in ("registry",):
continue
# Check if this is an owner directory (contains tool subdirectories)
has_subtools = False
for subitem in item.iterdir():
if subitem.is_dir():
config = subitem / "config.yaml"
if config.exists():
has_subtools = True
try:
tool = Tool.from_dict(yaml.safe_load(config.read_text()))
tools.append(ResolvedTool(
tool=tool,
source="global",
path=subitem,
owner=item.name
))
except Exception:
pass
# If no subtools, this might be an old-style tool directory
if not has_subtools:
config = item / "config.yaml"
if config.exists():
try:
tool = Tool.from_dict(yaml.safe_load(config.read_text()))
tools.append(ResolvedTool(
tool=tool,
source="global",
path=item
))
except Exception:
pass
return sorted(tools, key=lambda t: t.full_name)
+324
View File
@@ -0,0 +1,324 @@
"""Tool execution engine."""
import argparse
import sys
from pathlib import Path
from typing import Optional
from .tool import Tool, PromptStep, CodeStep
from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec
from .manifest import load_manifest
def substitute_variables(template: str, variables: dict) -> str:
"""
Substitute {variable} placeholders in a template.
Supports escaping: use {{ for literal { and }} for literal }
Args:
template: String with {var} placeholders
variables: Dict of variable name -> value
Returns:
String with placeholders replaced
Examples:
>>> substitute_variables("Hello {name}", {"name": "World"})
'Hello World'
>>> substitute_variables("Use {{braces}}", {"braces": "nope"})
'Use {braces}'
"""
# Use unique placeholders for escaped braces
ESCAPE_OPEN = "\x00\x01OPEN\x01\x00"
ESCAPE_CLOSE = "\x00\x01CLOSE\x01\x00"
# First, replace escaped braces with placeholders
result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE)
# Now do variable substitution
for name, value in variables.items():
result = result.replace(f"{{{name}}}", str(value) if value else "")
# Finally, restore escaped braces as single braces
result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}")
return result
def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]:
"""
Execute a prompt step.
Args:
step: The prompt step to execute
variables: Current variable values
provider_override: Override the step's provider
Returns:
Tuple of (output_value, success)
"""
# Build prompt with variable substitution
prompt = substitute_variables(step.prompt, variables)
# Call provider
provider = provider_override or step.provider
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, prompt)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
return "", False
return result.text, True
def execute_code_step(step: CodeStep, variables: dict) -> tuple[dict, bool]:
"""
Execute a code step.
Args:
step: The code step to execute
variables: Current variable values (available in code)
Returns:
Tuple of (output_vars_dict, success)
"""
# Substitute variables in code (like {outputfile} -> actual value)
code = substitute_variables(step.code, variables)
# Create execution environment with variables
local_vars = dict(variables)
try:
# Execute the code with substituted variables
exec(code, {"__builtins__": __builtins__}, local_vars)
# Support comma-separated output vars (e.g., "a, b, c")
output_vars = [v.strip() for v in step.output_var.split(',')]
outputs = {}
for var in output_vars:
outputs[var] = str(local_vars.get(var, ""))
return outputs, True
except Exception as e:
print(f"Error in code step: {e}", file=sys.stderr)
return {}, False
def run_tool(
tool: Tool,
input_text: str,
custom_args: dict,
provider_override: Optional[str] = None,
dry_run: bool = False,
show_prompt: bool = False,
verbose: bool = False
) -> tuple[str, int]:
"""
Execute a tool.
Args:
tool: Tool definition
input_text: Input content
custom_args: Custom argument values
provider_override: Override all providers
dry_run: Just show what would happen
show_prompt: Show prompts in addition to output
verbose: Show debug info
Returns:
Tuple of (output_text, exit_code)
"""
# Initialize variables with input and arguments
variables = {"input": input_text}
# Add argument values (with defaults)
for arg in tool.arguments:
value = custom_args.get(arg.variable, arg.default)
variables[arg.variable] = value
if verbose:
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
# If no steps, just substitute output template
if not tool.steps:
output = substitute_variables(tool.output, variables)
return output, 0
# Execute each step
for i, step in enumerate(tool.steps):
if verbose:
step_type = "PROMPT" if isinstance(step, PromptStep) else "CODE"
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
if isinstance(step, PromptStep):
# Show prompt if requested
if show_prompt or dry_run:
prompt = substitute_variables(step.prompt, variables)
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
print(prompt, file=sys.stderr)
print("=== END PROMPT ===", file=sys.stderr)
if dry_run:
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
else:
output, success = execute_prompt_step(step, variables, provider_override)
if not success:
return "", 2
variables[step.output_var] = output
elif isinstance(step, CodeStep):
if verbose or dry_run:
print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(step.code, file=sys.stderr)
print("=== END CODE ===", file=sys.stderr)
if dry_run:
# Handle comma-separated output vars for dry run
for var in [v.strip() for v in step.output_var.split(',')]:
variables[var] = "[DRY RUN - would execute code]"
else:
outputs, success = execute_code_step(step, variables)
if not success:
return "", 1
# Merge all output vars into variables
variables.update(outputs)
# Generate final output
output = substitute_variables(tool.output, variables)
return output, 0
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
"""
Create an argument parser for a tool.
Args:
tool: Tool definition
Returns:
Configured ArgumentParser
"""
parser = argparse.ArgumentParser(
prog=tool.name,
description=tool.description or f"CmdForge: {tool.name}"
)
# Universal flags
parser.add_argument("-i", "--input", dest="input_file",
help="Input file (reads from stdin if piped)")
parser.add_argument("--stdin", action="store_true",
help="Read input interactively from stdin (type then Ctrl+D)")
parser.add_argument("-o", "--output", dest="output_file",
help="Output file (writes to stdout if omitted)")
parser.add_argument("--dry-run", action="store_true",
help="Show what would happen without executing")
parser.add_argument("--show-prompt", action="store_true",
help="Show prompts in addition to output")
parser.add_argument("-p", "--provider",
help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information")
# Tool-specific flags from arguments
for arg in tool.arguments:
parser.add_argument(
arg.flag,
dest=arg.variable,
default=arg.default,
help=arg.description or f"{arg.variable} (default: {arg.default})"
)
return parser
def main():
"""Entry point for tool execution via wrapper script."""
if len(sys.argv) < 2:
print("Usage: python -m cmdforge.runner <tool_name> [args...]", file=sys.stderr)
sys.exit(1)
tool_spec = sys.argv[1]
# Resolve tool using new resolution order
try:
resolved = resolve_tool(tool_spec)
tool = resolved.tool
except ToolNotFoundError as e:
print(f"Error: Tool '{tool_spec}' not found", file=sys.stderr)
print(f"Searched: {', '.join(e.searched_paths[:3])}", file=sys.stderr)
sys.exit(1)
# Check for manifest overrides
manifest = load_manifest()
provider_override_from_manifest = None
if manifest:
override = manifest.get_override(tool_spec)
if override and override.provider:
provider_override_from_manifest = override.provider
# Parse remaining arguments
parser = create_argument_parser(tool)
args = parser.parse_args(sys.argv[2:])
# Read input
if args.input_file:
# Read from file
input_path = Path(args.input_file)
if not input_path.exists():
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args
custom_args = {}
for arg in tool.arguments:
value = getattr(args, arg.variable, None)
if value is not None:
custom_args[arg.variable] = value
# Determine provider override (CLI flag takes precedence over manifest)
effective_provider = args.provider or provider_override_from_manifest
# Run tool
output, exit_code = run_tool(
tool=tool,
input_text=input_text,
custom_args=custom_args,
provider_override=effective_provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose
)
# Write output
if exit_code == 0 and output:
if args.output_file:
Path(args.output_file).write_text(output)
else:
print(output)
sys.exit(exit_code)
if __name__ == "__main__":
main()
+326
View File
@@ -0,0 +1,326 @@
"""Tool loading, saving, and management."""
import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List, Literal
import yaml
# Default tools directory
TOOLS_DIR = Path.home() / ".cmdforge"
# Default bin directory for wrapper scripts
BIN_DIR = Path.home() / ".local" / "bin"
@dataclass
class ToolArgument:
"""Definition of a custom input argument."""
flag: str # e.g., "--max-size"
variable: str # e.g., "max_size"
default: Optional[str] = None
description: str = ""
def to_dict(self) -> dict:
d = {"flag": self.flag, "variable": self.variable}
if self.default:
d["default"] = self.default
if self.description:
d["description"] = self.description
return d
@classmethod
def from_dict(cls, data: dict) -> "ToolArgument":
return cls(
flag=data["flag"],
variable=data["variable"],
default=data.get("default"),
description=data.get("description", "")
)
@dataclass
class PromptStep:
"""A prompt step that calls an AI provider."""
prompt: str # The prompt template
provider: str # Provider name
output_var: str # Variable to store output
prompt_file: Optional[str] = None # Optional filename for external prompt
def to_dict(self) -> dict:
d = {
"type": "prompt",
"prompt": self.prompt,
"provider": self.provider,
"output_var": self.output_var
}
if self.prompt_file:
d["prompt_file"] = self.prompt_file
return d
@classmethod
def from_dict(cls, data: dict) -> "PromptStep":
return cls(
prompt=data["prompt"],
provider=data["provider"],
output_var=data["output_var"],
prompt_file=data.get("prompt_file")
)
@dataclass
class CodeStep:
"""A code step that runs Python code."""
code: str # Python code (inline or loaded from file)
output_var: str # Variable name(s) to capture (comma-separated for multiple)
code_file: Optional[str] = None # Optional filename for external code
def to_dict(self) -> dict:
d = {
"type": "code",
"code": self.code,
"output_var": self.output_var
}
if self.code_file:
d["code_file"] = self.code_file
return d
@classmethod
def from_dict(cls, data: dict) -> "CodeStep":
return cls(
code=data.get("code", ""),
output_var=data["output_var"],
code_file=data.get("code_file")
)
Step = PromptStep | CodeStep
# Default categories for organizing tools
DEFAULT_CATEGORIES = ["Text", "Developer", "Data", "Other"]
@dataclass
class Tool:
"""A CmdForge tool definition."""
name: str
description: str = ""
category: str = "Other" # Tool category for organization
arguments: List[ToolArgument] = field(default_factory=list)
steps: List[Step] = field(default_factory=list)
output: str = "{input}" # Output template
@classmethod
def from_dict(cls, data: dict) -> "Tool":
arguments = []
for arg in data.get("arguments", []):
arguments.append(ToolArgument.from_dict(arg))
steps = []
for step in data.get("steps", []):
if step.get("type") == "prompt":
steps.append(PromptStep.from_dict(step))
elif step.get("type") == "code":
steps.append(CodeStep.from_dict(step))
return cls(
name=data["name"],
description=data.get("description", ""),
category=data.get("category", "Other"),
arguments=arguments,
steps=steps,
output=data.get("output", "{input}")
)
def to_dict(self) -> dict:
d = {
"name": self.name,
"description": self.description,
}
# Only include category if it's not the default
if self.category and self.category != "Other":
d["category"] = self.category
d["arguments"] = [arg.to_dict() for arg in self.arguments]
d["steps"] = [step.to_dict() for step in self.steps]
d["output"] = self.output
return d
def get_available_variables(self) -> List[str]:
"""Get all variables available for use in templates."""
variables = ["input"] # Always available
# Add argument variables
for arg in self.arguments:
variables.append(arg.variable)
# Add step output variables
for step in self.steps:
variables.append(step.output_var)
return variables
def get_tools_dir() -> Path:
"""Get the tools directory, creating it if needed."""
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
return TOOLS_DIR
def get_bin_dir() -> Path:
"""Get the bin directory for wrapper scripts, creating it if needed."""
BIN_DIR.mkdir(parents=True, exist_ok=True)
return BIN_DIR
def list_tools() -> list[str]:
"""List all available tools."""
tools_dir = get_tools_dir()
tools = []
for item in tools_dir.iterdir():
if item.is_dir():
config = item / "config.yaml"
if config.exists():
tools.append(item.name)
return sorted(tools)
def load_tool(name: str) -> Optional[Tool]:
"""Load a tool by name."""
config_path = get_tools_dir() / name / "config.yaml"
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text())
# Handle legacy format (prompt/provider/provider_args/inputs)
if "prompt" in data and "steps" not in data:
# Convert to new format
steps = []
if data.get("prompt"):
steps.append({
"type": "prompt",
"prompt": data["prompt"],
"provider": data.get("provider", "mock"),
"output_var": "response"
})
arguments = []
for inp in data.get("inputs", []):
arguments.append({
"flag": inp.get("flag", f"--{inp['name']}"),
"variable": inp["name"],
"default": inp.get("default"),
"description": inp.get("description", "")
})
data = {
"name": data["name"],
"description": data.get("description", ""),
"arguments": arguments,
"steps": steps,
"output": "{response}" if steps else "{input}"
}
return Tool.from_dict(data)
except Exception as e:
print(f"Error loading tool {name}: {e}")
return None
def save_tool(tool: Tool) -> Path:
"""Save a tool to disk."""
tool_dir = get_tools_dir() / tool.name
tool_dir.mkdir(parents=True, exist_ok=True)
config_path = tool_dir / "config.yaml"
config_path.write_text(yaml.dump(tool.to_dict(), default_flow_style=False, sort_keys=False))
# Create wrapper script
create_wrapper_script(tool.name)
return config_path
def delete_tool(name: str) -> bool:
"""Delete a tool."""
tool_dir = get_tools_dir() / name
if not tool_dir.exists():
return False
# Remove wrapper script
wrapper = get_bin_dir() / name
if wrapper.exists():
wrapper.unlink()
# Remove tool directory
import shutil
shutil.rmtree(tool_dir)
return True
def create_wrapper_script(name: str) -> Path:
"""Create a wrapper script for a tool in ~/.local/bin."""
import sys
bin_dir = get_bin_dir()
wrapper_path = bin_dir / name
# Use the current Python interpreter to ensure cmdforge is available
python_path = sys.executable
script = f"""#!/bin/bash
# CmdForge wrapper for '{name}'
# Auto-generated - do not edit
exec {python_path} -m cmdforge.runner {name} "$@"
"""
wrapper_path.write_text(script)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
def tool_exists(name: str) -> bool:
"""Check if a tool exists."""
return (get_tools_dir() / name / "config.yaml").exists()
def validate_tool_name(name: str) -> tuple[bool, str]:
"""
Validate a tool name.
Returns:
(is_valid, error_message) - error_message is empty if valid
"""
if not name:
return False, "Tool name cannot be empty"
if ' ' in name:
return False, "Tool name cannot contain spaces"
# Check for shell-problematic characters
bad_chars = set('/\\|&;$`"\'<>(){}[]!?*#~')
found = [c for c in name if c in bad_chars]
if found:
return False, f"Tool name cannot contain: {' '.join(found)}"
# Must start with letter or underscore
if not (name[0].isalpha() or name[0] == '_'):
return False, "Tool name must start with a letter or underscore"
# Check it's a valid identifier-ish (alphanumeric, underscore, dash)
for c in name:
if not (c.isalnum() or c in '_-'):
return False, f"Tool name can only contain letters, numbers, underscore, and dash"
return True, ""
+828
View File
@@ -0,0 +1,828 @@
"""Dialog-based UI for managing CmdForge."""
import subprocess
import sys
import tempfile
from typing import Optional, Tuple, List
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
def _check_urwid() -> bool:
"""Check if urwid is available (preferred - has mouse support)."""
try:
import urwid
return True
except ImportError:
return False
def _check_snack() -> bool:
"""Check if snack (python3-newt) is available."""
try:
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
return True
except ImportError:
return False
def check_dialog() -> str:
"""Check for available dialog program. Returns 'dialog', 'whiptail', or None."""
for prog in ["dialog", "whiptail"]:
try:
subprocess.run([prog, "--version"], capture_output=True, check=False)
return prog
except FileNotFoundError:
continue
return None
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
"""Run a dialog command and return (exit_code, output)."""
try:
if dialog_prog == "whiptail":
result = subprocess.run(
[dialog_prog] + args,
stderr=subprocess.PIPE,
text=True
)
return result.returncode, result.stderr.strip()
else:
cmd_with_stdout = [dialog_prog, "--stdout"] + args
result = subprocess.run(
cmd_with_stdout,
stdout=subprocess.PIPE,
text=True
)
return result.returncode, result.stdout.strip()
except Exception as e:
return 1, ""
def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]:
"""Show a menu and return the selected item."""
args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))]
for tag, desc in choices:
args.extend([tag, desc])
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]:
"""Show an input box and return the entered text."""
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
"""Show a text editor for multi-line input."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(text)
temp_path = f.name
try:
args = ["--title", title, "--editbox", temp_path, "20", "75"]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
finally:
import os
os.unlink(temp_path)
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
"""Show a yes/no dialog."""
args = ["--title", title, "--yesno", text, "10", "60"]
code, _ = run_dialog(args, dialog_prog)
return code == 0
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
"""Show a message box."""
args = ["--title", title, "--msgbox", text, "15", "70"]
run_dialog(args, dialog_prog)
def show_mixed_form(title: str, fields: dict, dialog_prog: str, height: int = 20) -> Optional[dict]:
"""Show a form with multiple fields using --mixedform."""
args = ["--title", title, "--mixedform",
"Tab: next field | Enter: submit | Esc: cancel",
str(height), "75", "0"]
field_names = list(fields.keys())
y = 1
for name in field_names:
label, initial, field_type = fields[name]
args.extend([
label, str(y), "1",
initial, str(y), "18",
"52", "256", str(field_type)
])
y += 1
code, output = run_dialog(args, dialog_prog)
if code != 0:
return None
values = output.split('\n')
result = {}
for i, name in enumerate(field_names):
result[name] = values[i] if i < len(values) else ""
return result
# ============ Provider Management ============
def select_provider(dialog_prog: str) -> Optional[str]:
"""Show provider selection menu with option to create new."""
providers = load_providers()
choices = [(p.name, f"{p.description} ({p.command})") for p in providers]
choices.append(("__new__", "[ + Add New Provider ]"))
selected = show_menu("Select Provider", choices, dialog_prog)
if selected == "__new__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
return provider.name
return None
return selected
def create_provider_form(dialog_prog: str, existing: Optional[Provider] = None) -> Optional[Provider]:
"""Show form for creating/editing a provider."""
title = f"Edit Provider: {existing.name}" if existing else "Add New Provider"
fields = {
"name": (
"Name:",
existing.name if existing else "",
2 if existing else 0 # readonly if editing
),
"command": (
"Command:",
existing.command if existing else "",
0
),
"description": (
"Description:",
existing.description if existing else "",
0
),
}
result = show_mixed_form(title, fields, dialog_prog, height=12)
if not result:
return None
name = result["name"].strip()
command = result["command"].strip()
if not name:
show_message("Error", "Provider name is required.", dialog_prog)
return None
if not command:
show_message("Error", "Command is required.", dialog_prog)
return None
return Provider(
name=name,
command=command,
description=result["description"].strip()
)
def ui_manage_providers(dialog_prog: str):
"""Manage providers menu."""
while True:
providers = load_providers()
choices = [(p.name, f"{p.command}") for p in providers]
choices.append(("__add__", "[ + Add New Provider ]"))
choices.append(("__back__", "[ <- Back to Main Menu ]"))
selected = show_menu("Manage Providers", choices, dialog_prog)
if selected is None or selected == "__back__":
break
elif selected == "__add__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
show_message("Success", f"Provider '{provider.name}' added.", dialog_prog)
else:
# Edit or delete existing provider
provider = get_provider(selected)
if provider:
action = show_menu(
f"Provider: {selected}",
[
("edit", "Edit provider"),
("delete", "Delete provider"),
("back", "Back"),
],
dialog_prog
)
if action == "edit":
updated = create_provider_form(dialog_prog, provider)
if updated:
add_provider(updated)
show_message("Success", f"Provider '{updated.name}' updated.", dialog_prog)
elif action == "delete":
if show_yesno("Confirm", f"Delete provider '{selected}'?", dialog_prog):
delete_provider(selected)
show_message("Deleted", f"Provider '{selected}' deleted.", dialog_prog)
# ============ Tool Builder UI ============
def format_tool_summary(tool: Tool) -> str:
"""Format a summary of the tool's components."""
lines = []
lines.append(f"Name: {tool.name}")
lines.append(f"Description: {tool.description or '(none)'}")
lines.append("")
if tool.arguments:
lines.append("Arguments:")
for arg in tool.arguments:
default = f" = {arg.default}" if arg.default else ""
lines.append(f" {arg.flag} -> {{{arg.variable}}}{default}")
lines.append("")
if tool.steps:
lines.append("Steps:")
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
preview = step.prompt[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}")
lines.append(f" {preview}")
elif isinstance(step, CodeStep):
preview = step.code[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. CODE -> {{{step.output_var}}}")
lines.append(f" {preview}")
lines.append("")
lines.append(f"Output: {tool.output}")
return "\n".join(lines)
def get_available_variables(tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get list of available variables at a given point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def edit_argument(dialog_prog: str, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Edit or create an argument."""
title = f"Edit Argument: {existing.flag}" if existing else "Add Argument"
fields = {
"flag": ("Flag:", existing.flag if existing else "--", 0),
"variable": ("Variable:", existing.variable if existing else "", 0),
"default": ("Default:", existing.default or "" if existing else "", 0),
"description": ("Description:", existing.description if existing else "", 0),
}
result = show_mixed_form(title, fields, dialog_prog, height=14)
if not result:
return None
flag = result["flag"].strip()
variable = result["variable"].strip()
if not flag:
show_message("Error", "Flag is required (e.g., --max-size).", dialog_prog)
return None
if not variable:
# Auto-generate variable name from flag
variable = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=variable,
default=result["default"].strip() or None,
description=result["description"].strip()
)
def edit_prompt_step(dialog_prog: str, existing: Optional[PromptStep] = None,
available_vars: List[str] = None) -> Optional[PromptStep]:
"""Edit or create a prompt step."""
title = "Edit Prompt Step" if existing else "Add Prompt Step"
# First, select provider
provider = select_provider(dialog_prog)
if not provider:
provider = existing.provider if existing else "mock"
# Show variable help
var_help = "Available: " + ", ".join(f"{{{v}}}" for v in (available_vars or ["input"]))
# Edit prompt text
default_prompt = existing.prompt if existing else f"Process this input:\n\n{{input}}"
prompt = show_textbox(f"Prompt Template\n{var_help}", default_prompt, dialog_prog)
if prompt is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "result",
dialog_prog
)
if not output_var:
return None
return PromptStep(
prompt=prompt,
provider=provider,
output_var=output_var.strip()
)
def edit_code_step(dialog_prog: str, existing: Optional[CodeStep] = None,
available_vars: List[str] = None) -> Optional[CodeStep]:
"""Edit or create a code step."""
title = "Edit Code Step" if existing else "Add Code Step"
# Show variable help
var_help = "Variables: " + ", ".join(available_vars or ["input"])
var_help += "\nSet 'result' variable for output"
# Edit code
default_code = existing.code if existing else "# Available variables: " + ", ".join(available_vars or ["input"]) + "\n# Set 'result' for output\nresult = input.upper()"
code = show_textbox(f"Python Code\n{var_help}", default_code, dialog_prog)
if code is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "processed",
dialog_prog
)
if not output_var:
return None
return CodeStep(
code=code,
output_var=output_var.strip()
)
def edit_tool_info(tool: Tool, is_edit: bool, dialog_prog: str) -> None:
"""Edit basic tool info (name, description, output)."""
while True:
# Build info section menu
output_preview = tool.output[:35] + "..." if len(tool.output) > 35 else tool.output
args_count = len(tool.arguments)
args_summary = f"({args_count} defined)" if args_count else "(none)"
choices = [
("name", f"Name: {tool.name or '(not set)'}"),
("desc", f"Description: {tool.description[:35] + '...' if len(tool.description) > 35 else tool.description or '(none)'}"),
("---1", "─" * 40),
]
# Show arguments
if tool.arguments:
for i, arg in enumerate(tool.arguments):
default = f" = {arg.default}" if arg.default else ""
choices.append((f"arg_{i}", f" {arg.flag} -> {{{arg.variable}}}{default}"))
choices.append(("add_arg", " [ + Add Argument ]"))
choices.append(("---2", "─" * 40))
choices.append(("output", f"Output Template: {output_preview}"))
choices.append(("---3", "─" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Tool Info & Arguments", choices, dialog_prog)
if selected is None or selected == "back":
break
elif selected == "name":
if is_edit:
show_message("Info", "Cannot change tool name after creation.", dialog_prog)
else:
new_name = show_input("Tool Name", "Enter tool name:", tool.name, dialog_prog)
if new_name:
tool.name = new_name.strip()
elif selected == "desc":
new_desc = show_input("Description", "Enter tool description:", tool.description, dialog_prog)
if new_desc is not None:
tool.description = new_desc.strip()
elif selected == "add_arg":
arg = edit_argument(dialog_prog)
if arg:
tool.arguments.append(arg)
elif selected.startswith("arg_"):
idx = int(selected[4:])
arg = tool.arguments[idx]
action = show_menu(
f"Argument: {arg.flag}",
[("edit", "Edit"), ("delete", "Delete"), ("back", "Back")],
dialog_prog
)
if action == "edit":
updated = edit_argument(dialog_prog, arg)
if updated:
tool.arguments[idx] = updated
elif action == "delete":
if show_yesno("Delete", f"Delete argument {arg.flag}?", dialog_prog):
tool.arguments.pop(idx)
elif selected == "output":
available = get_available_variables(tool)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
new_output = show_textbox(f"Output Template\n{var_help}", tool.output, dialog_prog)
if new_output is not None:
tool.output = new_output
def edit_tool_steps(tool: Tool, dialog_prog: str) -> None:
"""Edit tool processing steps."""
while True:
# Build steps section menu
choices = []
if tool.steps:
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
choices.append((f"step_{i}", f"{i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}"))
elif isinstance(step, CodeStep):
choices.append((f"step_{i}", f"{i+1}. CODE -> {{{step.output_var}}}"))
else:
choices.append(("none", "(no steps defined)"))
choices.append(("---1", "─" * 40))
choices.append(("add_prompt", "[ + Add Prompt Step ]"))
choices.append(("add_code", "[ + Add Code Step ]"))
choices.append(("---2", "─" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Processing Steps", choices, dialog_prog)
if selected is None or selected == "back" or selected == "none":
if selected == "none":
continue
break
elif selected == "add_prompt":
available = get_available_variables(tool)
step = edit_prompt_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected == "add_code":
available = get_available_variables(tool)
step = edit_code_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected.startswith("step_"):
idx = int(selected[5:])
step = tool.steps[idx]
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
move_choices = [("edit", "Edit"), ("delete", "Delete")]
if idx > 0:
move_choices.insert(1, ("move_up", "Move Up"))
if idx < len(tool.steps) - 1:
move_choices.insert(2 if idx > 0 else 1, ("move_down", "Move Down"))
move_choices.append(("back", "Back"))
action = show_menu(f"Step {idx+1}: {step_type}", move_choices, dialog_prog)
if action == "edit":
available = get_available_variables(tool, idx)
if isinstance(step, PromptStep):
updated = edit_prompt_step(dialog_prog, step, available)
else:
updated = edit_code_step(dialog_prog, step, available)
if updated:
tool.steps[idx] = updated
elif action == "move_up" and idx > 0:
tool.steps[idx], tool.steps[idx-1] = tool.steps[idx-1], tool.steps[idx]
elif action == "move_down" and idx < len(tool.steps) - 1:
tool.steps[idx], tool.steps[idx+1] = tool.steps[idx+1], tool.steps[idx]
elif action == "delete":
if show_yesno("Delete", f"Delete step {idx+1}?", dialog_prog):
tool.steps.pop(idx)
def tool_builder(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder interface with tabbed sections."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Build main menu with section summaries
args_count = len(tool.arguments)
steps_count = len(tool.steps)
# Info summary
name_display = tool.name or "(not set)"
info_summary = f"{name_display}"
if tool.arguments:
info_summary += f" | {args_count} arg{'s' if args_count != 1 else ''}"
# Steps summary
if tool.steps:
step_types = []
for s in tool.steps:
if isinstance(s, PromptStep):
step_types.append(f"P:{s.provider}")
else:
step_types.append("C")
steps_summary = " -> ".join(step_types)
else:
steps_summary = "(none)"
choices = [
("info", f"[1] Info & Args : {info_summary}"),
("steps", f"[2] Steps : {steps_summary}"),
("---", "─" * 50),
("preview", "Preview Full Summary"),
("save", "Save Tool"),
("cancel", "Cancel"),
]
title = f"Tool Builder: {tool.name}" if tool.name else "Tool Builder: New Tool"
selected = show_menu(title, choices, dialog_prog)
if selected is None or selected == "cancel":
if show_yesno("Cancel", "Discard changes?", dialog_prog):
return None
continue
elif selected == "info":
edit_tool_info(tool, is_edit, dialog_prog)
elif selected == "steps":
edit_tool_steps(tool, dialog_prog)
elif selected == "preview":
summary = format_tool_summary(tool)
show_message("Tool Summary", summary, dialog_prog)
elif selected == "save":
if not tool.name:
show_message("Error", "Tool name is required. Go to Info & Args to set it.", dialog_prog)
continue
if not is_edit and tool_exists(tool.name):
if not show_yesno("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", dialog_prog):
continue
return tool
# ============ Main Menu Functions ============
def ui_list_tools(dialog_prog: str):
"""Show list of tools."""
tools = list_tools()
if not tools:
show_message("Tools", "No tools found.\n\nCreate your first tool from the main menu.", dialog_prog)
return
text = "Available tools:\n\n"
for name in tools:
tool = load_tool(name)
if tool:
text += f" {name}: {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(arg.flag for arg in tool.arguments)
text += f" Arguments: {args}\n"
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
else:
step_info.append("CODE")
text += f" Steps: {' -> '.join(step_info)}\n"
text += "\n"
show_message("Tools", text, dialog_prog)
def ui_create_tool(dialog_prog: str):
"""Create a new tool."""
tool = tool_builder(dialog_prog)
if tool:
path = save_tool(tool)
# Build usage example
usage = f"{tool.name}"
for arg in tool.arguments:
if arg.default:
usage += f" [{arg.flag} <{arg.variable}>]"
else:
usage += f" {arg.flag} <{arg.variable}>"
usage += " < input.txt"
show_message("Success",
f"Tool '{tool.name}' created!\n\n"
f"Config: {path}\n\n"
f"Usage: {usage}",
dialog_prog)
def ui_edit_tool(dialog_prog: str):
"""Edit an existing tool."""
tools = list_tools()
if not tools:
show_message("Edit Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Edit", choices, dialog_prog)
if selected:
existing = load_tool(selected)
if existing:
tool = tool_builder(dialog_prog, existing)
if tool:
save_tool(tool)
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
def ui_delete_tool(dialog_prog: str):
"""Delete a tool."""
tools = list_tools()
if not tools:
show_message("Delete Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Delete", choices, dialog_prog)
if selected:
if show_yesno("Confirm Delete", f"Delete tool '{selected}'?\n\nThis cannot be undone.", dialog_prog):
if delete_tool(selected):
show_message("Deleted", f"Tool '{selected}' deleted.", dialog_prog)
else:
show_message("Error", f"Failed to delete '{selected}'.", dialog_prog)
def ui_test_tool(dialog_prog: str):
"""Test a tool with mock provider."""
tools = list_tools()
if not tools:
show_message("Test Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Test", choices, dialog_prog)
if selected:
tool = load_tool(selected)
if tool:
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}"
if len(output) > 1000:
result_text += "\n... (truncated)"
show_message("Test Result", result_text, dialog_prog)
def main_menu(dialog_prog: str):
"""Show the main menu."""
while True:
choice = show_menu(
"CmdForge Manager",
[
("list", "List all tools"),
("create", "Create new tool"),
("edit", "Edit existing tool"),
("delete", "Delete tool"),
("test", "Test tool (mock provider)"),
("providers", "Manage providers"),
("exit", "Exit"),
],
dialog_prog
)
if choice is None or choice == "exit":
break
elif choice == "list":
ui_list_tools(dialog_prog)
elif choice == "create":
ui_create_tool(dialog_prog)
elif choice == "edit":
ui_edit_tool(dialog_prog)
elif choice == "delete":
ui_delete_tool(dialog_prog)
elif choice == "test":
ui_test_tool(dialog_prog)
elif choice == "providers":
ui_manage_providers(dialog_prog)
def run_ui():
"""Entry point for the UI."""
# Prefer urwid (has mouse support)
if _check_urwid():
from .ui_urwid import run_ui as run_urwid_ui
run_urwid_ui()
return
# Fallback to snack (BIOS-style)
if _check_snack():
from .ui_snack import run_ui as run_snack_ui
run_snack_ui()
return
# Fallback to dialog/whiptail
dialog_prog = check_dialog()
if not dialog_prog:
print("Error: No TUI library found.", file=sys.stderr)
print("Install one of:", file=sys.stderr)
print(" pip install urwid (recommended - has mouse support)", file=sys.stderr)
print(" sudo apt install python3-newt", file=sys.stderr)
print(" sudo apt install dialog", file=sys.stderr)
sys.exit(1)
try:
main_menu(dialog_prog)
except KeyboardInterrupt:
pass
finally:
subprocess.run(["clear"], check=False)
if __name__ == "__main__":
run_ui()
+496
View File
@@ -0,0 +1,496 @@
"""TUI for browsing the CmdForge 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 CmdForge 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(" CmdForge 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:
cmdforge registry install {owner}/{name}
Press 'i' to install this tool
"""
self.detail_text.set_text(detail)
def _install_tool(self, tool_data: Dict):
"""Install the selected tool asynchronously."""
if self.loading:
return
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
self.loading = True
self._set_status(f"Installing {owner}/{name}...", loading=True)
def install():
return install_from_registry(f"{owner}/{name}")
def on_success(resolved):
self.loading = False
self._set_status(f"Installed: {resolved.full_name}@{resolved.version}")
def on_error(e):
self.loading = False
self._set_status(f"Install failed: {e}")
self.async_ops.run_async(install, on_success, on_error)
def _do_search(self, query: str):
"""Perform search."""
self.current_query = query
self.current_page = 1
self._load_tools(query=query, category=self.current_category)
def _cycle_category(self):
"""Cycle through categories."""
if not self.categories:
self._load_categories()
# Schedule the cycle after categories load
return
if not self.categories:
return
cat_names = [None] + [c.get("name") for c in self.categories]
try:
idx = cat_names.index(self.current_category)
idx = (idx + 1) % len(cat_names)
except ValueError:
idx = 0
self.current_category = cat_names[idx]
cat_display = self.current_category or "All"
self.category_text.set_text(f"Category: {cat_display}")
self._load_tools(query=self.current_query, category=self.current_category)
def _next_page(self):
"""Go to next page."""
if self.current_page < self.total_pages:
self.current_page += 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _prev_page(self):
"""Go to previous page."""
if self.current_page > 1:
self.current_page -= 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _set_status(self, message: str, loading: bool = False):
"""Update status bar message."""
if loading:
self.status_text.set_text(('loading', f" ⟳ {message}"))
else:
self.status_text.set_text(f" {message}")
def _handle_input(self, key):
"""Handle global key input."""
if key in ('q', 'Q'):
raise urwid.ExitMainLoop()
elif key == '/':
# Focus search box
self.frame.body.base_widget.set_focus(0)
return None
elif key == 'c':
self._cycle_category()
return None
elif key == 'n':
self._next_page()
return None
elif key == 'p':
self._prev_page()
return None
elif key == 'r':
# Refresh current view
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
return None
return key
def run(self):
"""Run the TUI browser."""
# Create main loop
self.loop = urwid.MainLoop(
self.frame,
palette=PALETTE,
unhandled_input=self._handle_input,
handle_mouse=True
)
# Setup async pipe for thread-safe callbacks
self.async_ops.setup_pipe(self.loop)
try:
# Initial load (async)
self._load_categories()
self._load_tools()
# Run main loop
self.loop.run()
finally:
# Cleanup
self.async_ops.cleanup()
self.executor.shutdown(wait=False)
def run_registry_browser():
"""Entry point for the registry browser TUI."""
try:
browser = RegistryBrowser()
browser.run()
except RegistryError as e:
print(f"Error connecting to registry: {e.message}")
return 1
except Exception as e:
print(f"Error: {e}")
return 1
return 0
+706
View File
@@ -0,0 +1,706 @@
"""BIOS-style TUI for CmdForge using snack (python3-newt)."""
import sys
# Ensure system packages are accessible
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
from typing import Optional, List, Tuple
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
class CmdForgeUI:
"""BIOS-style UI for CmdForge."""
def __init__(self):
self.screen = None
def run(self):
"""Run the UI."""
self.screen = snack.SnackScreen()
# Enable mouse support
self.screen.pushHelpLine(" Tab/Arrow:Navigate | Enter:Select | Mouse:Click | Esc:Back ")
try:
# Enable mouse - newt supports GPM and xterm mouse
import os
os.environ.setdefault('NEWT_MONO', '0')
self.main_menu()
finally:
self.screen.finish()
def main_menu(self):
"""Show the main menu."""
while True:
items = [
("Create New Tool", "create"),
("Edit Tool", "edit"),
("Delete Tool", "delete"),
("List Tools", "list"),
("Test Tool", "test"),
("Manage Providers", "providers"),
("Exit", "exit"),
]
listbox = snack.Listbox(height=7, width=30, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, "CmdForge Manager", 1, 1)
grid.add(listbox, 0, 0)
result = grid.runOnce()
selected = listbox.current()
if selected == "exit" or result == "ESC":
break
elif selected == "create":
self.tool_builder(None)
elif selected == "edit":
self.select_and_edit_tool()
elif selected == "delete":
self.select_and_delete_tool()
elif selected == "list":
self.show_tools_list()
elif selected == "test":
self.select_and_test_tool()
elif selected == "providers":
self.manage_providers()
def message_box(self, title: str, message: str):
"""Show a message box."""
snack.ButtonChoiceWindow(self.screen, title, message, ["OK"])
def yes_no(self, title: str, message: str) -> bool:
"""Show a yes/no dialog."""
result = snack.ButtonChoiceWindow(self.screen, title, message, ["Yes", "No"])
return result == "yes"
def input_box(self, title: str, prompt: str, initial: str = "", width: int = 40) -> Optional[str]:
"""Show an input dialog."""
entry = snack.Entry(width, initial)
grid = snack.GridForm(self.screen, title, 1, 3)
grid.add(snack.Label(prompt), 0, 0)
grid.add(entry, 0, 1, padding=(0, 1, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def text_edit(self, title: str, initial: str = "", width: int = 60, height: int = 10) -> Optional[str]:
"""Show a multi-line text editor."""
text = snack.Textbox(width, height, initial, scroll=1, wrap=1)
# snack doesn't have a true multi-line editor, so we use Entry for now
# For multi-line, we'll use a workaround with a simple entry
entry = snack.Entry(width, initial, scroll=1)
grid = snack.GridForm(self.screen, title, 1, 2)
grid.add(entry, 0, 0, padding=(0, 0, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def select_provider(self) -> Optional[str]:
"""Show provider selection dialog."""
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 1, 8), width=50, returnExit=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add New Provider ]", "__new__")
grid = snack.GridForm(self.screen, "Select Provider", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
selected = listbox.current()
if selected == "__new__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
return provider.name
return None
return selected
def add_provider_dialog(self) -> Optional[Provider]:
"""Dialog to add a new provider."""
name_entry = snack.Entry(30, "")
cmd_entry = snack.Entry(40, "")
desc_entry = snack.Entry(40, "")
grid = snack.GridForm(self.screen, "Add Provider", 2, 4)
grid.add(snack.Label("Name:"), 0, 0, anchorLeft=1)
grid.add(name_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Command:"), 0, 1, anchorLeft=1)
grid.add(cmd_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 2, anchorLeft=1)
grid.add(desc_entry, 1, 2, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 3, growx=1, growy=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
name = name_entry.value().strip()
cmd = cmd_entry.value().strip()
if name and cmd:
return Provider(name=name, command=cmd, description=desc_entry.value().strip())
self.message_box("Error", "Name and command are required.")
return None
def add_argument_dialog(self, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Dialog to add/edit an argument."""
flag_entry = snack.Entry(20, existing.flag if existing else "--")
var_entry = snack.Entry(20, existing.variable if existing else "")
default_entry = snack.Entry(20, existing.default or "" if existing else "")
desc_entry = snack.Entry(40, existing.description if existing else "")
title = "Edit Argument" if existing else "Add Argument"
grid = snack.GridForm(self.screen, title, 2, 5)
grid.add(snack.Label("Flag:"), 0, 0, anchorLeft=1)
grid.add(flag_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Variable:"), 0, 1, anchorLeft=1)
grid.add(var_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Default:"), 0, 2, anchorLeft=1)
grid.add(default_entry, 1, 2, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 3, anchorLeft=1)
grid.add(desc_entry, 1, 3, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 4, growx=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
flag = flag_entry.value().strip()
var = var_entry.value().strip()
if not flag:
self.message_box("Error", "Flag is required.")
return None
if not var:
var = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=var,
default=default_entry.value().strip() or None,
description=desc_entry.value().strip()
)
return None
def add_step_dialog(self, tool: Tool, existing_step: Optional[Step] = None, step_idx: int = -1) -> Optional[Step]:
"""Dialog to choose and add a step."""
if existing_step:
# Edit existing step
if isinstance(existing_step, PromptStep):
return self.add_prompt_dialog(tool, existing_step, step_idx)
else:
return self.add_code_dialog(tool, existing_step, step_idx)
# Choose step type
listbox = snack.Listbox(height=2, width=30, returnExit=1)
listbox.append("Prompt (AI call)", "prompt")
listbox.append("Code (Python)", "code")
grid = snack.GridForm(self.screen, "Add Step", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
step_type = listbox.current()
if step_type == "prompt":
return self.add_prompt_dialog(tool, None, step_idx)
else:
return self.add_code_dialog(tool, None, step_idx)
def get_available_variables(self, tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get available variables at a point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def add_prompt_dialog(self, tool: Tool, existing: Optional[PromptStep] = None, step_idx: int = -1) -> Optional[PromptStep]:
"""Dialog to add/edit a prompt step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
# Provider selection first
provider = self.select_provider()
if not provider:
provider = existing.provider if existing else "mock"
prompt_entry = snack.Entry(60, existing.prompt if existing else f"Process this:\n\n{{input}}", scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "result")
title = "Edit Prompt Step" if existing else "Add Prompt Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Provider: {provider}"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 0)
grid.add(snack.Label(f"Prompt ({var_help}):"), 0, 1, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 1)
grid.add(prompt_entry, 0, 2, growx=1)
grid.add(snack.Label(""), 1, 2)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 3, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 3)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
prompt = prompt_entry.value().strip()
output_var = output_entry.value().strip()
if not prompt:
self.message_box("Error", "Prompt is required.")
return None
if not output_var:
output_var = "result"
return PromptStep(prompt=prompt, provider=provider, output_var=output_var)
return None
def add_code_dialog(self, tool: Tool, existing: Optional[CodeStep] = None, step_idx: int = -1) -> Optional[CodeStep]:
"""Dialog to add/edit a code step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(available)
default_code = existing.code if existing else "# Set 'result' for output\nresult = input.upper()"
code_entry = snack.Entry(60, default_code, scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "processed")
title = "Edit Code Step" if existing else "Add Code Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Python Code ({var_help}):"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label("Set 'result' variable for output"), 1, 0)
grid.add(code_entry, 0, 1, growx=1)
grid.add(snack.Label(""), 1, 1)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 2, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
code = code_entry.value().strip()
output_var = output_entry.value().strip()
if not code:
self.message_box("Error", "Code is required.")
return None
if not output_var:
output_var = "processed"
return CodeStep(code=code, output_var=output_var)
return None
def tool_builder(self, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder - BIOS-style unified form."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Create form elements
name_entry = snack.Entry(25, tool.name, scroll=0)
desc_entry = snack.Entry(25, tool.description, scroll=1)
output_entry = snack.Entry(25, tool.output, scroll=1)
# Arguments listbox
args_listbox = snack.Listbox(height=4, width=35, returnExit=0, scroll=1)
for i, arg in enumerate(tool.arguments):
args_listbox.append(f"{arg.flag} -> {{{arg.variable}}}", i)
args_listbox.append("[ + Add ]", "add")
# Steps listbox
steps_listbox = snack.Listbox(height=5, width=35, returnExit=0, scroll=1)
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
steps_listbox.append(f"P:{step.provider} -> {{{step.output_var}}}", i)
else:
steps_listbox.append(f"C: -> {{{step.output_var}}}", i)
steps_listbox.append("[ + Add ]", "add")
# Build the grid layout using nested grids for better control
title = f"Edit Tool: {tool.name}" if is_edit and tool.name else "New Tool"
# Left column grid: Name, Description, Output
left_grid = snack.Grid(1, 6)
left_grid.setField(snack.Label("Name:"), 0, 0, anchorLeft=1)
left_grid.setField(name_entry, 0, 1, anchorLeft=1)
left_grid.setField(snack.Label("Description:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(desc_entry, 0, 3, anchorLeft=1)
left_grid.setField(snack.Label("Output:"), 0, 4, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(output_entry, 0, 5, anchorLeft=1)
# Right column grid: Arguments and Steps
right_grid = snack.Grid(1, 4)
right_grid.setField(snack.Label("Arguments:"), 0, 0, anchorLeft=1)
right_grid.setField(args_listbox, 0, 1, anchorLeft=1)
right_grid.setField(snack.Label("Execution Steps:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
right_grid.setField(steps_listbox, 0, 3, anchorLeft=1)
# Main grid
grid = snack.GridForm(self.screen, title, 2, 2)
grid.add(left_grid, 0, 0, anchorTop=1, padding=(0, 0, 2, 0))
grid.add(right_grid, 1, 0, anchorTop=1)
# Buttons at bottom
buttons = snack.ButtonBar(self.screen, [("Save", "save"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1, growx=1)
# Handle hotkeys for listbox interaction
form = grid.form
while True:
result = form.run()
# Update tool from entries
if not is_edit:
tool.name = name_entry.value().strip()
tool.description = desc_entry.value().strip()
tool.output = output_entry.value().strip()
# Check what was activated
if result == args_listbox:
selected = args_listbox.current()
if selected == "add":
new_arg = self.add_argument_dialog()
if new_arg:
tool.arguments.append(new_arg)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing argument
action = self.arg_action_menu(tool.arguments[selected])
if action == "edit":
updated = self.add_argument_dialog(tool.arguments[selected])
if updated:
tool.arguments[selected] = updated
elif action == "delete":
tool.arguments.pop(selected)
break # Refresh form
elif result == steps_listbox:
selected = steps_listbox.current()
if selected == "add":
new_step = self.add_step_dialog(tool)
if new_step:
tool.steps.append(new_step)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing step
action = self.step_action_menu(tool.steps[selected], selected, len(tool.steps))
if action == "edit":
updated = self.add_step_dialog(tool, tool.steps[selected], selected)
if updated:
tool.steps[selected] = updated
elif action == "delete":
tool.steps.pop(selected)
elif action == "move_up" and selected > 0:
tool.steps[selected], tool.steps[selected-1] = tool.steps[selected-1], tool.steps[selected]
elif action == "move_down" and selected < len(tool.steps) - 1:
tool.steps[selected], tool.steps[selected+1] = tool.steps[selected+1], tool.steps[selected]
break # Refresh form
elif buttons.buttonPressed(result) == "save":
if not tool.name:
self.message_box("Error", "Tool name is required.")
break
if not is_edit and tool_exists(tool.name):
if not self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?"):
break
self.screen.popWindow()
save_tool(tool)
self.message_box("Success", f"Tool '{tool.name}' saved!")
return tool
elif buttons.buttonPressed(result) == "cancel" or result == "ESC":
self.screen.popWindow()
return None
else:
# Tab between fields, continue
continue
self.screen.popWindow()
def arg_action_menu(self, arg: ToolArgument) -> Optional[str]:
"""Show action menu for an argument."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Argument: {arg.flag}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def step_action_menu(self, step: Step, idx: int, total: int) -> Optional[str]:
"""Show action menu for a step."""
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
items = [("Edit", "edit")]
if idx > 0:
items.append(("Move Up", "move_up"))
if idx < total - 1:
items.append(("Move Down", "move_down"))
items.append(("Delete", "delete"))
items.append(("Cancel", "cancel"))
listbox = snack.Listbox(height=len(items), width=20, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, f"Step {idx+1}: {step_type}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def select_and_edit_tool(self):
"""Select a tool to edit."""
tools = list_tools()
if not tools:
self.message_box("Edit Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
tool = load_tool(name)
desc = tool.description[:30] if tool and tool.description else "No description"
listbox.append(f"{name}: {desc}", name)
grid = snack.GridForm(self.screen, "Select Tool to Edit", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
self.tool_builder(tool)
def select_and_delete_tool(self):
"""Select a tool to delete."""
tools = list_tools()
if not tools:
self.message_box("Delete Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Delete", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Delete", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
if self.yes_no("Confirm", f"Delete tool '{selected}'?"):
if delete_tool(selected):
self.message_box("Deleted", f"Tool '{selected}' deleted.")
def show_tools_list(self):
"""Show list of all tools."""
tools = list_tools()
if not tools:
self.message_box("Tools", "No tools found.\n\nCreate one from the main menu.")
return
text = ""
for name in tools:
tool = load_tool(name)
if tool:
text += f"{name}\n"
text += f" {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(a.flag for a in tool.arguments)
text += f" Args: {args}\n"
if tool.steps:
steps = []
for s in tool.steps:
if isinstance(s, PromptStep):
steps.append(f"P:{s.provider}")
else:
steps.append("C")
text += f" Steps: {' -> '.join(steps)}\n"
text += "\n"
snack.ButtonChoiceWindow(self.screen, "Available Tools", text.strip(), ["OK"])
def select_and_test_tool(self):
"""Select a tool to test."""
tools = list_tools()
if not tools:
self.message_box("Test Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Test", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Test", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
test_input = self.input_box("Test Input", "Enter test input:", "Hello world")
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\nOutput:\n{output[:500]}"
self.message_box("Test Result", result_text)
def manage_providers(self):
"""Manage providers menu."""
while True:
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 2, 10), width=50, returnExit=1, scroll=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add Provider ]", "__add__")
listbox.append("[ <- Back ]", "__back__")
grid = snack.GridForm(self.screen, "Manage Providers", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
selected = listbox.current()
if selected == "__back__":
break
elif selected == "__add__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
self.message_box("Success", f"Provider '{provider.name}' added.")
else:
# Edit or delete
provider = get_provider(selected)
if provider:
action = self.provider_action_menu(provider)
if action == "edit":
updated = self.add_provider_dialog() # TODO: pass existing
if updated:
add_provider(updated)
elif action == "delete":
if self.yes_no("Confirm", f"Delete provider '{selected}'?"):
delete_provider(selected)
def provider_action_menu(self, provider: Provider) -> Optional[str]:
"""Show action menu for a provider."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Provider: {provider.name}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def run_ui():
"""Entry point for the snack UI."""
ui = CmdForgeUI()
ui.run()
if __name__ == "__main__":
run_ui()
+23
View File
@@ -0,0 +1,23 @@
"""BIOS-style TUI for CmdForge using urwid.
This module is a thin wrapper for backwards compatibility.
The actual implementation is in the ui_urwid/ package.
"""
from .ui_urwid import run_ui, CmdForgeUI
from .ui_urwid.palette import PALETTE
from .ui_urwid.widgets import (
SelectableText, Button3D, Button3DCompact, ClickableButton,
SelectableToolItem, ToolListBox, TabCyclePile, TabPassEdit,
UndoableEdit, DOSScrollBar, ToolBuilderLayout, Dialog
)
__all__ = [
'run_ui', 'CmdForgeUI', 'PALETTE',
'SelectableText', 'Button3D', 'Button3DCompact', 'ClickableButton',
'SelectableToolItem', 'ToolListBox', 'TabCyclePile', 'TabPassEdit',
'UndoableEdit', 'DOSScrollBar', 'ToolBuilderLayout', 'Dialog'
]
if __name__ == "__main__":
run_ui()
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
"""Allow running the UI as a module."""
from . import run_ui
if __name__ == "__main__":
run_ui()
+30
View File
@@ -0,0 +1,30 @@
"""Color palette for the BIOS-style TUI."""
# Color palette - BIOS style with 3D button effects
PALETTE = [
('body', 'white', 'dark blue'),
('header', 'white', 'dark red', 'bold'),
('footer', 'black', 'light gray'),
# Button colors - raised 3D effect
('button', 'black', 'light gray'),
('button_focus', 'white', 'dark red', 'bold'),
('button_highlight', 'white', 'light gray'), # Top/left edge (light)
('button_shadow', 'dark gray', 'light gray'), # Bottom/right edge (dark)
('button_pressed', 'black', 'dark gray'), # Pressed state
# Edit fields
('edit', 'black', 'light gray'),
('edit_focus', 'black', 'yellow'),
# List items
('listbox', 'black', 'light gray'),
('listbox_focus', 'white', 'dark red'),
# Dialog
('dialog', 'black', 'light gray'),
('dialog_border', 'white', 'dark blue'),
# Text styles
('label', 'yellow', 'dark blue', 'bold'),
('error', 'white', 'dark red', 'bold'),
('success', 'light green', 'dark blue', 'bold'),
# 3D shadow elements
('shadow', 'black', 'black'),
('shadow_edge', 'dark gray', 'dark blue'),
]
+715
View File
@@ -0,0 +1,715 @@
"""Custom widgets for the BIOS-style TUI."""
import urwid
class SelectableText(urwid.WidgetWrap):
"""A selectable text widget for list items."""
def __init__(self, text, value=None, on_select=None):
self.value = value
self.on_select = on_select
self.text_widget = urwid.Text(text)
display = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus')
super().__init__(display)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.value)
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if event == 'mouse press' and button == 1 and self.on_select:
self.on_select(self.value)
return True
return False
class Button3D(urwid.WidgetWrap):
"""A 3D-style button using box-drawing characters for depth.
Creates a raised button effect like DOS/BIOS interfaces:
┌──────────┐
│ Label │▄
└──────────┘█
When focused, colors change to show selection.
"""
signals = ['click']
def __init__(self, label, on_press=None, user_data=None):
self.label = label
self.on_press = on_press
self.user_data = user_data
self._pressed = False
# Build the 3D button structure
self._build_widget()
super().__init__(self._widget)
def _build_widget(self):
"""Build the 3D button widget structure."""
label = self.label
width = len(label) + 4 # Padding inside button
# Button face with border
# Top edge: ┌────┐
top = '┌' + '─' * (width - 2) + '┐'
# Middle: │ Label │ with shadow
middle_text = '│ ' + label + ' │'
# Bottom edge: └────┘ with shadow
bottom = '└' + '─' * (width - 2) + '┘'
# Shadow characters (right and bottom)
shadow_right = '▄'
shadow_bottom = '█'
# Create the rows
top_row = urwid.Text(top + ' ') # Space for shadow alignment
middle_row = urwid.Columns([
('pack', urwid.Text(middle_text)),
('pack', urwid.Text(('shadow_edge', shadow_right))),
])
bottom_row = urwid.Columns([
('pack', urwid.Text(bottom)),
('pack', urwid.Text(('shadow_edge', shadow_right))),
])
shadow_row = urwid.Text(('shadow_edge', ' ' + shadow_bottom * (width - 1)))
# Stack them
pile = urwid.Pile([
top_row,
middle_row,
bottom_row,
shadow_row,
])
self._widget = urwid.AttrMap(pile, 'button', 'button_focus')
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter':
self._activate()
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if button == 1:
if event == 'mouse press':
self._pressed = True
return True
elif event == 'mouse release' and self._pressed:
self._pressed = False
self._activate()
return True
return False
def _activate(self):
"""Trigger the button callback."""
if self.on_press:
self.on_press(self.user_data)
self._emit('click')
class Button3DCompact(urwid.WidgetWrap):
"""A compact 3D button that fits on a single line with shadow effect.
Creates a subtle 3D effect: [ Label ]▌
Better for inline use where vertical space is limited.
"""
signals = ['click']
def __init__(self, label, on_press=None, user_data=None):
self.label = label
self.on_press = on_press
self.user_data = user_data
# Build compact button: ▐ Label ▌ with shadow
# Using block characters for edges
button_text = urwid.Text([
('button_highlight', '▐'),
('button', f' {label} '),
('button_shadow', '▌'),
('shadow_edge', '▄'),
])
self._widget = urwid.AttrMap(button_text, None, {
'button': 'button_focus',
'button_highlight': 'button_focus',
'button_shadow': 'button_focus',
})
super().__init__(self._widget)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter':
self._activate()
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if button == 1 and event == 'mouse release':
self._activate()
return True
return False
def _activate(self):
if self.on_press:
self.on_press(self.user_data)
self._emit('click')
class ClickableButton(urwid.WidgetWrap):
"""A button that responds to mouse clicks (legacy wrapper)."""
def __init__(self, label, on_press=None, user_data=None):
self.on_press = on_press
self.user_data = user_data
button = urwid.Button(label)
if on_press:
urwid.connect_signal(button, 'click', self._handle_click)
display = urwid.AttrMap(button, 'button', 'button_focus')
super().__init__(display)
def _handle_click(self, button):
if self.on_press:
self.on_press(self.user_data)
class SelectableToolItem(urwid.WidgetWrap):
"""A selectable tool item that maintains selection state."""
def __init__(self, name, on_select=None):
self.name = name
self.on_select = on_select
self._selected = False
self.text_widget = urwid.Text(f" {name} ")
self.attr_map = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus')
super().__init__(self.attr_map)
def selectable(self):
return True
def set_selected(self, selected):
"""Set whether this item is the selected tool."""
self._selected = selected
if self._selected:
self.attr_map.set_attr_map({None: 'listbox_focus'})
else:
self.attr_map.set_attr_map({None: 'listbox'})
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.name)
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if event == 'mouse press' and button == 1:
# Single click just selects/focuses - don't call on_select
# on_select is only called on Enter key (to edit)
return True
return False
class ToolListBox(urwid.ListBox):
"""A ListBox that keeps arrow keys internal and passes Tab out."""
def __init__(self, body, on_focus_change=None):
super().__init__(body)
self.on_focus_change = on_focus_change
self._last_focus = None
def keypress(self, size, key):
if key in ('up', 'down'):
# Handle arrow keys internally - navigate within list
result = super().keypress(size, key)
# Check if focus changed
self._check_focus_change()
return result
elif key == 'tab':
# Pass tab out to parent for focus cycling
return key
elif key == 'shift tab':
return key
else:
return super().keypress(size, key)
def _check_focus_change(self):
"""Check if focus changed and notify callback."""
try:
current = self.focus
if current is not self._last_focus:
self._last_focus = current
if self.on_focus_change and isinstance(current, SelectableToolItem):
self.on_focus_change(current.name)
except (IndexError, TypeError):
pass
def render(self, size, focus=False):
# Check focus on render too (for initial display)
if focus:
self._check_focus_change()
return super().render(size, focus)
class TabCyclePile(urwid.Pile):
"""A Pile that uses Tab/Shift-Tab to cycle between specific positions.
Args:
widget_list: List of widgets (same as urwid.Pile)
tab_positions: List of indices in the pile that Tab should cycle between.
Default is [0] (only first position).
"""
def __init__(self, widget_list, tab_positions=None):
super().__init__(widget_list)
# Positions in the pile that Tab should cycle between
self.tab_positions = tab_positions or [0]
self._current_tab_idx = 0
def keypress(self, size, key):
if key == 'tab':
# Move to next tab position
self._current_tab_idx = (self._current_tab_idx + 1) % len(self.tab_positions)
self.focus_position = self.tab_positions[self._current_tab_idx]
return None
elif key == 'shift tab':
# Move to previous tab position
self._current_tab_idx = (self._current_tab_idx - 1) % len(self.tab_positions)
self.focus_position = self.tab_positions[self._current_tab_idx]
return None
else:
return super().keypress(size, key)
class TabPassEdit(urwid.Edit):
"""A multiline Edit that passes Tab through for focus cycling instead of inserting tabs."""
def keypress(self, size, key):
if key in ('tab', 'shift tab'):
# Pass Tab through to parent for focus cycling
return key
return super().keypress(size, key)
class UndoableEdit(urwid.Edit):
"""A multiline Edit with undo/redo support.
Features:
- Undo with Alt+U (up to 50 states)
- Redo with Alt+R
- Tab passes through for focus cycling
"""
MAX_UNDO = 50 # Maximum undo history size
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._undo_stack = [] # List of (text, cursor_pos) tuples
self._redo_stack = []
def keypress(self, size, key):
if key in ('tab', 'shift tab'):
return key
# Handle undo (Alt+U or meta u)
if key in ('meta u', 'alt u'):
self._undo()
return None
# Handle redo (Alt+R or meta r)
if key in ('meta r', 'alt r'):
self._redo()
return None
# Save current state BEFORE the edit for undo
old_text = self.edit_text
old_pos = self.edit_pos
# Let the parent handle the keypress
result = super().keypress(size, key)
# If text changed, save the old state to undo stack
if self.edit_text != old_text:
self._save_undo_state(old_text, old_pos)
self._redo_stack.clear() # Clear redo on new edit
return result
def _save_undo_state(self, text, pos):
"""Save state to undo stack."""
# Don't save duplicate states
if self._undo_stack and self._undo_stack[-1][0] == text:
return
if len(self._undo_stack) >= self.MAX_UNDO:
self._undo_stack.pop(0)
self._undo_stack.append((text, pos))
def _undo(self):
"""Restore previous state from undo stack."""
if not self._undo_stack:
return
# Save current state to redo stack
self._redo_stack.append((self.edit_text, self.edit_pos))
# Restore previous state
text, pos = self._undo_stack.pop()
self.set_edit_text(text)
self.set_edit_pos(min(pos, len(text)))
def _redo(self):
"""Restore state from redo stack."""
if not self._redo_stack:
return
# Save current state to undo stack
self._undo_stack.append((self.edit_text, self.edit_pos))
# Restore redo state
text, pos = self._redo_stack.pop()
self.set_edit_text(text)
self.set_edit_pos(min(pos, len(text)))
class DOSScrollBar(urwid.WidgetWrap):
"""A DOS-style scrollbar with arrow buttons at top and bottom.
Renders a scrollbar on the right side of the wrapped widget with:
- ▲ arrow at top (click to scroll up)
- ░ track with █ thumb showing scroll position
- ▼ arrow at bottom (click to scroll down)
Click zones (expanded to last 2 columns for easier clicking):
- Top 25%: scroll up 3 lines
- Bottom 25%: scroll down 3 lines
- Middle: page up/down based on which half clicked
"""
def __init__(self, widget):
self._wrapped = widget
# Create a columns layout: content on left, scrollbar on right
super().__init__(widget)
def render(self, size, focus=False):
maxcol, maxrow = size
# Render the wrapped widget with one less column for scrollbar
content_size = (maxcol - 1, maxrow)
content_canvas = self._wrapped.render(content_size, focus)
# Build the scrollbar column
scrollbar_chars = []
# Up arrow at top
scrollbar_chars.append('▲')
# Calculate thumb position
if maxrow > 2:
track_height = maxrow - 2 # Minus the two arrow buttons
# Get scroll position info from wrapped widget
try:
if hasattr(self._wrapped, 'rows_max'):
rows_max = self._wrapped.rows_max(content_size)
scroll_pos = self._wrapped.get_scrollpos(content_size)
else:
rows_max = maxrow
scroll_pos = 0
if rows_max > maxrow:
# Calculate thumb position within track
thumb_pos = int((scroll_pos / (rows_max - maxrow)) * (track_height - 1))
thumb_pos = max(0, min(thumb_pos, track_height - 1))
else:
thumb_pos = 0
except (AttributeError, TypeError, ZeroDivisionError):
thumb_pos = 0
# Build track with thumb
for i in range(track_height):
if i == thumb_pos:
scrollbar_chars.append('█') # Thumb
else:
scrollbar_chars.append('░') # Track
# Down arrow at bottom
scrollbar_chars.append('▼')
# Create scrollbar canvas
scrollbar_text = '\n'.join(scrollbar_chars[:maxrow])
scrollbar_canvas = urwid.Text(scrollbar_text).render((1,))
# Combine canvases
combined = urwid.CanvasJoin([
(content_canvas, None, focus, content_size[0]),
(scrollbar_canvas, None, False, 1),
])
return combined
def keypress(self, size, key):
maxcol, maxrow = size
content_size = (maxcol - 1, maxrow)
return self._wrapped.keypress(content_size, key)
def mouse_event(self, size, event, button, col, row, focus):
maxcol, maxrow = size
content_size = (maxcol - 1, maxrow)
# Expand clickable area - last 2 columns count as scrollbar
if col >= maxcol - 2:
if button == 1 and event == 'mouse press':
# Top 25% of scrollbar = scroll up
if row < maxrow // 4:
for _ in range(3):
self._wrapped.keypress(content_size, 'up')
self._invalidate()
return True
# Bottom 25% of scrollbar = scroll down
elif row >= maxrow - (maxrow // 4):
for _ in range(3):
self._wrapped.keypress(content_size, 'down')
self._invalidate()
return True
# Middle = page up/down based on which half
elif row < maxrow // 2:
for _ in range(maxrow // 2):
self._wrapped.keypress(content_size, 'up')
self._invalidate()
return True
else:
for _ in range(maxrow // 2):
self._wrapped.keypress(content_size, 'down')
self._invalidate()
return True
# Handle mouse wheel on scrollbar
if button == 4: # Scroll up
for _ in range(3):
self._wrapped.keypress(content_size, 'up')
self._invalidate()
return True
elif button == 5: # Scroll down
for _ in range(3):
self._wrapped.keypress(content_size, 'down')
self._invalidate()
return True
return True # Consume other scrollbar clicks
# Pass to wrapped widget
return self._wrapped.mouse_event(content_size, event, button, col, row, focus)
def selectable(self):
return self._wrapped.selectable()
def sizing(self):
return frozenset([urwid.Sizing.BOX])
class ToolBuilderLayout(urwid.WidgetWrap):
"""Custom layout for tool builder that handles Tab cycling across all sections."""
def __init__(self, left_box, args_box, steps_box, args_section, steps_section, bottom_buttons, on_cancel=None):
self._current_section = 0
self.on_cancel = on_cancel
# Store references to LineBoxes for title highlighting
self.left_box = left_box
self.args_box = args_box
self.steps_box = steps_box
# Build visual layout: left column and right column side by side
right_pile = urwid.Pile([
('weight', 1, args_section),
('pack', urwid.Divider()),
('weight', 1, steps_section),
])
columns = urwid.Columns([
('weight', 1, left_box),
('weight', 1, right_pile),
], dividechars=1)
main_pile = urwid.Pile([
('weight', 1, columns),
('pack', urwid.Divider()),
('pack', bottom_buttons),
])
super().__init__(main_pile)
# Set initial highlight
self._update_section_titles()
def keypress(self, size, key):
if key == 'tab':
self._current_section = (self._current_section + 1) % 4
self._focus_section(self._current_section)
self._update_section_titles()
return None
elif key == 'shift tab':
self._current_section = (self._current_section - 1) % 4
self._focus_section(self._current_section)
self._update_section_titles()
return None
elif key == 'esc':
# Go back to main menu instead of exiting
if self.on_cancel:
self.on_cancel(None)
return None
else:
return super().keypress(size, key)
def mouse_event(self, size, event, button, col, row, focus):
# Let the parent handle the mouse event first
result = super().mouse_event(size, event, button, col, row, focus)
# After mouse click, detect which section has focus and update titles
if event == 'mouse press':
self._detect_current_section()
self._update_section_titles()
return result
def _detect_current_section(self):
"""Detect which section currently has focus based on widget hierarchy."""
main_pile = self._w
# Check if bottom buttons have focus (position 2)
if main_pile.focus_position == 2:
self._current_section = 3
return
# Focus is on columns (position 0)
columns = main_pile.contents[0][0]
if columns.focus_position == 0:
# Left box (Tool Info)
self._current_section = 0
else:
# Right pile
right_pile = columns.contents[1][0]
if right_pile.focus_position == 0:
# Args section
self._current_section = 1
else:
# Steps section
self._current_section = 2
def _update_section_titles(self):
"""Update section titles to highlight the current one with markers."""
# Section 0 = Tool Info, Section 1 = Arguments, Section 2 = Steps, Section 3 = buttons
if self._current_section == 0:
self.left_box.set_title('[ Tool Info ]')
self.args_box.set_title('Arguments')
self.steps_box.set_title('Execution Steps')
elif self._current_section == 1:
self.left_box.set_title('Tool Info')
self.args_box.set_title('[ Arguments ]')
self.steps_box.set_title('Execution Steps')
elif self._current_section == 2:
self.left_box.set_title('Tool Info')
self.args_box.set_title('Arguments')
self.steps_box.set_title('[ Execution Steps ]')
else:
# Buttons focused - no section highlighted
self.left_box.set_title('Tool Info')
self.args_box.set_title('Arguments')
self.steps_box.set_title('Execution Steps')
def _focus_section(self, section_idx):
"""Set focus to the specified section."""
# Get the main pile
main_pile = self._w
if section_idx == 0:
# Tool Info (left box) - focus columns, then left
main_pile.focus_position = 0 # columns
columns = main_pile.contents[0][0]
columns.focus_position = 0 # left box
elif section_idx == 1:
# Arguments section - focus columns, then right, then args
main_pile.focus_position = 0 # columns
columns = main_pile.contents[0][0]
columns.focus_position = 1 # right pile
right_pile = columns.contents[1][0]
right_pile.focus_position = 0 # args section
elif section_idx == 2:
# Steps section - focus columns, then right, then steps
main_pile.focus_position = 0 # columns
columns = main_pile.contents[0][0]
columns.focus_position = 1 # right pile
right_pile = columns.contents[1][0]
right_pile.focus_position = 2 # steps section (after divider)
elif section_idx == 3:
# Save/Cancel buttons
main_pile.focus_position = 2 # bottom buttons (after divider)
class Dialog(urwid.WidgetWrap):
"""A dialog box overlay with 3D-style buttons."""
def __init__(self, title, body, buttons, width=60, height=None):
# Title
title_widget = urwid.Text(('header', f' {title} '), align='center')
# Buttons row - use 3D compact buttons for dialog actions
button_widgets = []
for label, callback in buttons:
btn = Button3DCompact(label, callback)
button_widgets.append(btn)
buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2)
buttons_centered = urwid.Padding(buttons_row, align='center', width='pack')
# Check if body is a box widget
# ListBox is always a box widget. For Piles with weighted items,
# check if it ONLY supports BOX sizing (not FLOW).
is_box_widget = isinstance(body, (urwid.ListBox, urwid.Scrollable, urwid.ScrollBar))
if not is_box_widget:
try:
sizing = body.sizing()
# Box widget if it ONLY supports BOX sizing
is_box_widget = sizing == frozenset({urwid.Sizing.BOX})
except (AttributeError, TypeError):
pass
if is_box_widget:
# Box widget - use directly with weight
pile = urwid.Pile([
('pack', title_widget),
('pack', urwid.Divider('─')),
('weight', 1, body),
('pack', urwid.Divider('─')),
('pack', buttons_centered),
])
else:
# Flow widget - wrap in Filler
body_padded = urwid.Padding(body, left=1, right=1)
body_filled = urwid.Filler(body_padded, valign='top')
pile = urwid.Pile([
('pack', title_widget),
('pack', urwid.Divider('─')),
body_filled,
('pack', urwid.Divider('─')),
('pack', buttons_centered),
])
# Box it
box = urwid.LineBox(pile, title='', title_align='center')
box = urwid.AttrMap(box, 'dialog')
super().__init__(box)
+15
View File
@@ -0,0 +1,15 @@
"""Web UI blueprint for CmdForge."""
import os
from flask import Blueprint
# Use absolute paths for templates and static folders
_pkg_dir = os.path.dirname(os.path.abspath(__file__))
web_bp = Blueprint(
"web",
__name__,
template_folder=os.path.join(_pkg_dir, "templates"),
static_folder=os.path.join(_pkg_dir, "static"),
static_url_path="/web-static",
)
+81
View File
@@ -0,0 +1,81 @@
"""Web app factory for CmdForge UI."""
from __future__ import annotations
import os
import secrets
from flask import Flask, render_template, session
# Initialize Sentry before importing Flask app (if DSN is configured)
_sentry_dsn = os.environ.get("SENTRY_DSN")
if _sentry_dsn:
try:
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
sentry_sdk.init(
dsn=_sentry_dsn,
integrations=[FlaskIntegration()],
traces_sample_rate=float(os.environ.get("SENTRY_TRACES_RATE", "0.1")),
profiles_sample_rate=float(os.environ.get("SENTRY_PROFILES_RATE", "0.1")),
environment=os.environ.get("CMDFORGE_ENV", "development"),
send_default_pii=False, # Privacy: don't send user IPs, emails, etc.
)
except ImportError:
pass # sentry-sdk not installed
from cmdforge.registry import app as registry_app
from . import web_bp
from .auth import login, register, logout
from .filters import register_filters
from .forum import forum_bp
from .sessions import SQLiteSessionInterface, cleanup_expired_sessions
def create_web_app() -> Flask:
app = registry_app.create_app()
# Import routes BEFORE registering blueprint
from . import routes # noqa: F401
app.register_blueprint(web_bp)
app.register_blueprint(forum_bp)
# Session configuration
app.session_interface = SQLiteSessionInterface(cookie_name="cmdforge_session")
app.config["SESSION_COOKIE_NAME"] = "cmdforge_session"
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("CMDFORGE_ENV") == "production"
app.config["SHOW_ADS"] = os.environ.get("CMDFORGE_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()
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
return render_template("errors/404.html"), 404
@app.errorhandler(500)
def internal_error(error):
return render_template("errors/500.html"), 500
return app
if __name__ == "__main__":
create_web_app().run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))
+103
View File
@@ -0,0 +1,103 @@
"""Web UI authentication routes."""
from __future__ import annotations
from typing import Dict
from flask import current_app, redirect, render_template, request, session, url_for
from . import web_bp
def _api_post(path: str, payload: Dict) -> Dict:
client = current_app.test_client()
response = client.post(path, json=payload)
return {
"status": response.status_code,
"data": response.get_json(silent=True) or {},
}
def _csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = current_app.config["CSRF_GENERATOR"]()
session["csrf_token"] = token
return token
def _validate_csrf() -> bool:
form_token = request.form.get("csrf_token", "")
session_token = session.get("csrf_token", "")
return bool(form_token and session_token and form_token == session_token)
@web_bp.route("/login", methods=["GET", "POST"])
def login():
next_url = request.args.get("next") or request.form.get("next")
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/login.html",
errors=["Invalid CSRF token"],
next_url=next_url,
)
email = request.form.get("email", "").strip()
password = request.form.get("password", "")
result = _api_post("/api/v1/login", {"email": email, "password": password})
if result["status"] == 200:
data = result["data"].get("data", {})
session.clear()
session["auth_token"] = data.get("token")
session["publisher"] = data.get("publisher", {})
session["user"] = data.get("publisher", {})
current_app.session_interface.rotate_session(session)
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect(url_for("web.dashboard"))
error = result["data"].get("error", {}).get("message", "Login failed")
return render_template(
"pages/login.html",
errors=[error],
email=email,
next_url=next_url,
)
return render_template("pages/login.html", next_url=next_url)
@web_bp.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/register.html",
errors=["Invalid CSRF token"],
)
payload = {
"email": request.form.get("email", "").strip(),
"password": request.form.get("password", ""),
"slug": request.form.get("slug", "").strip(),
"display_name": request.form.get("display_name", "").strip(),
}
result = _api_post("/api/v1/register", payload)
if result["status"] == 201:
return redirect(url_for("web.login"))
error = result["data"].get("error", {}).get("message", "Registration failed")
return render_template(
"pages/register.html",
errors=[error],
email=payload["email"],
slug=payload["slug"],
display_name=payload["display_name"],
)
return render_template("pages/register.html")
@web_bp.route("/logout", methods=["POST"])
def logout():
if not _validate_csrf():
return redirect(url_for("web.login"))
session.clear()
return redirect(url_for("web.login"))
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
"""Jinja2 template filters for the web UI."""
from __future__ import annotations
import html
import re
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from markupsafe import Markup
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 render_markdown(text: str | None) -> Markup:
"""Render markdown to HTML with safe defaults.
Supports basic markdown:
- **bold** and *italic*
- `inline code` and ```code blocks```
- [links](url)
- Lists (- item or 1. item)
- > blockquotes
- Paragraphs (double newline)
"""
if not text:
return Markup("")
# Escape HTML first for safety
text = html.escape(text)
# Code blocks (```...```)
def replace_code_block(match):
code = match.group(1)
return f'<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto my-4 text-sm"><code>{code}</code></pre>'
text = re.sub(r"```(\w*)\n?([\s\S]*?)```", lambda m: replace_code_block(type("", (), {"group": lambda s, i: m.group(2) if i == 1 else m.group(1)})()), text)
# Inline code (`...`)
text = re.sub(r"`([^`]+)`", r'<code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-sm font-mono">\1</code>', text)
# Bold (**...**)
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
# Italic (*...*)
text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
# Links [text](url) - only allow http/https URLs
def replace_link(match):
link_text = match.group(1)
url = match.group(2)
if url.startswith(("http://", "https://", "/")):
return f'<a href="{url}" class="text-indigo-600 hover:text-indigo-800 underline" rel="nofollow noopener" target="_blank">{link_text}</a>'
return match.group(0)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", replace_link, text)
# Blockquotes (> ...)
lines = text.split("\n")
result_lines = []
in_blockquote = False
blockquote_lines = []
for line in lines:
if line.startswith("&gt; "): # Escaped >
if not in_blockquote:
in_blockquote = True
blockquote_lines = []
blockquote_lines.append(line[5:])
else:
if in_blockquote:
result_lines.append(f'<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4">{" ".join(blockquote_lines)}</blockquote>')
in_blockquote = False
blockquote_lines = []
result_lines.append(line)
if in_blockquote:
result_lines.append(f'<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4">{" ".join(blockquote_lines)}</blockquote>')
text = "\n".join(result_lines)
# Unordered lists (- item)
text = re.sub(r"^- (.+)$", r'<li class="ml-4">\1</li>', text, flags=re.MULTILINE)
text = re.sub(r"(<li[^>]*>.*</li>\n?)+", r'<ul class="list-disc list-inside my-4">\g<0></ul>', text)
# Ordered lists (1. item)
text = re.sub(r"^\d+\. (.+)$", r'<li class="ml-4">\1</li>', text, flags=re.MULTILINE)
# Paragraphs (double newline)
paragraphs = re.split(r"\n\n+", text)
formatted = []
for p in paragraphs:
p = p.strip()
if p:
# Don't wrap if it's already a block element
if p.startswith(("<pre", "<ul", "<ol", "<blockquote", "<li")):
formatted.append(p)
else:
# Convert single newlines to <br> within paragraphs
p = p.replace("\n", "<br>\n")
formatted.append(f'<p class="mb-4">{p}</p>')
return Markup("\n".join(formatted))
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
app.jinja_env.filters["markdown"] = render_markdown
# Global functions
app.jinja_env.globals["now"] = datetime.utcnow
+5
View File
@@ -0,0 +1,5 @@
"""Forum blueprint for CmdForge community discussions."""
from .routes import forum_bp
__all__ = ["forum_bp"]
+245
View File
@@ -0,0 +1,245 @@
"""Forum database models and schema."""
from __future__ import annotations
import re
import sqlite3
from typing import Any
from cmdforge.registry.db import connect_db, query_one, query_all
FORUM_SCHEMA = """
CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
icon TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS forum_topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES forum_categories(id),
title TEXT NOT NULL,
slug TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_pinned INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0,
view_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS forum_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
author_name TEXT NOT NULL,
author_email TEXT,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_solution INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_forum_topics_category ON forum_topics(category_id);
CREATE INDEX IF NOT EXISTS idx_forum_topics_updated ON forum_topics(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_forum_replies_topic ON forum_replies(topic_id);
"""
DEFAULT_CATEGORIES = [
("general", "General Discussion", "Chat about anything CmdForge related", "chat", 1),
("help", "Help & Support", "Get help with installation, configuration, or usage", "question", 2),
("showcase", "Showcase", "Share tools and projects you've built with CmdForge", "star", 3),
("ideas", "Ideas & Feedback", "Suggest features and improvements", "lightbulb", 4),
("tutorials", "Tutorials & Guides", "Community-written guides and how-tos", "book", 5),
]
def init_forum_schema(conn: sqlite3.Connection) -> None:
"""Initialize forum tables."""
conn.executescript(FORUM_SCHEMA)
conn.commit()
def seed_categories(conn: sqlite3.Connection) -> None:
"""Seed default forum categories if they don't exist."""
for slug, name, description, icon, sort_order in DEFAULT_CATEGORIES:
existing = query_one(
conn, "SELECT id FROM forum_categories WHERE slug = ?", [slug]
)
if not existing:
conn.execute(
"""INSERT INTO forum_categories (slug, name, description, icon, sort_order)
VALUES (?, ?, ?, ?, ?)""",
[slug, name, description, icon, sort_order],
)
conn.commit()
def slugify(text: str) -> str:
"""Convert text to URL-friendly slug."""
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[-\s]+", "-", text)
return text[:100] # Limit length
def get_categories_with_stats(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Get all categories with topic counts and latest activity."""
rows = query_all(
conn,
"""
SELECT
c.*,
COUNT(t.id) as topic_count,
COALESCE(SUM((SELECT COUNT(*) FROM forum_replies r WHERE r.topic_id = t.id)), 0) as reply_count,
MAX(COALESCE(t.updated_at, c.created_at)) as latest_activity
FROM forum_categories c
LEFT JOIN forum_topics t ON t.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order
""",
)
return [dict(row) for row in rows]
def get_category_by_slug(conn: sqlite3.Connection, slug: str) -> dict[str, Any] | None:
"""Get a category by slug."""
row = query_one(conn, "SELECT * FROM forum_categories WHERE slug = ?", [slug])
return dict(row) if row else None
def get_topics_for_category(
conn: sqlite3.Connection, category_id: int, page: int = 1, per_page: int = 20
) -> tuple[list[dict[str, Any]], int]:
"""Get topics for a category with pagination."""
offset = (page - 1) * per_page
# Get total count
count_row = query_one(
conn,
"SELECT COUNT(*) as total FROM forum_topics WHERE category_id = ?",
[category_id],
)
total = count_row["total"] if count_row else 0
# Get topics with reply counts
rows = query_all(
conn,
"""
SELECT
t.*,
(SELECT COUNT(*) FROM forum_replies WHERE topic_id = t.id) as reply_count,
(SELECT MAX(created_at) FROM forum_replies WHERE topic_id = t.id) as last_reply_at
FROM forum_topics t
WHERE t.category_id = ?
ORDER BY t.is_pinned DESC, COALESCE(
(SELECT MAX(created_at) FROM forum_replies WHERE topic_id = t.id),
t.updated_at
) DESC
LIMIT ? OFFSET ?
""",
[category_id, per_page, offset],
)
return [dict(row) for row in rows], total
def get_topic_by_id(conn: sqlite3.Connection, topic_id: int) -> dict[str, Any] | None:
"""Get a topic by ID."""
row = query_one(conn, "SELECT * FROM forum_topics WHERE id = ?", [topic_id])
return dict(row) if row else None
def get_replies_for_topic(conn: sqlite3.Connection, topic_id: int) -> list[dict[str, Any]]:
"""Get all replies for a topic."""
rows = query_all(
conn,
"SELECT * FROM forum_replies WHERE topic_id = ? ORDER BY created_at",
[topic_id],
)
return [dict(row) for row in rows]
def create_topic(
conn: sqlite3.Connection,
category_id: int,
title: str,
content: str,
author_name: str,
author_email: str | None = None,
) -> int:
"""Create a new topic and return its ID."""
slug = slugify(title)
cursor = conn.execute(
"""INSERT INTO forum_topics
(category_id, title, slug, author_name, author_email, content)
VALUES (?, ?, ?, ?, ?, ?)""",
[category_id, title, slug, author_name, author_email, content],
)
conn.commit()
return cursor.lastrowid
def create_reply(
conn: sqlite3.Connection,
topic_id: int,
content: str,
author_name: str,
author_email: str | None = None,
) -> int:
"""Create a new reply and return its ID."""
cursor = conn.execute(
"""INSERT INTO forum_replies (topic_id, author_name, author_email, content)
VALUES (?, ?, ?, ?)""",
[topic_id, author_name, author_email, content],
)
# Update topic's updated_at timestamp
conn.execute(
"UPDATE forum_topics SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[topic_id],
)
conn.commit()
return cursor.lastrowid
def increment_view_count(conn: sqlite3.Connection, topic_id: int) -> None:
"""Increment a topic's view count."""
conn.execute(
"UPDATE forum_topics SET view_count = view_count + 1 WHERE id = ?",
[topic_id],
)
conn.commit()
def get_recent_topics(conn: sqlite3.Connection, limit: int = 5) -> list[dict[str, Any]]:
"""Get the most recently active topics across all categories."""
rows = query_all(
conn,
"""
SELECT
t.*,
c.name as category_name,
c.slug as category_slug,
(SELECT COUNT(*) FROM forum_replies WHERE topic_id = t.id) as reply_count
FROM forum_topics t
JOIN forum_categories c ON c.id = t.category_id
ORDER BY t.updated_at DESC
LIMIT ?
""",
[limit],
)
return [dict(row) for row in rows]
def get_forum_stats(conn: sqlite3.Connection) -> dict[str, int]:
"""Get overall forum statistics."""
topics = query_one(conn, "SELECT COUNT(*) as count FROM forum_topics")
replies = query_one(conn, "SELECT COUNT(*) as count FROM forum_replies")
return {
"topics": topics["count"] if topics else 0,
"replies": replies["count"] if replies else 0,
}
+291
View File
@@ -0,0 +1,291 @@
"""Forum blueprint routes."""
from __future__ import annotations
import html
import re
from functools import wraps
from typing import Callable
from flask import (
Blueprint,
abort,
flash,
g,
redirect,
render_template,
request,
session,
url_for,
)
from cmdforge.registry.db import connect_db
from .models import (
create_reply,
create_topic,
get_categories_with_stats,
get_category_by_slug,
get_forum_stats,
get_recent_topics,
get_replies_for_topic,
get_topic_by_id,
get_topics_for_category,
increment_view_count,
init_forum_schema,
seed_categories,
)
forum_bp = Blueprint(
"forum",
__name__,
url_prefix="/forum",
template_folder="../templates/forum",
)
def get_db():
"""Get database connection, creating forum tables if needed."""
if "forum_db" not in g:
g.forum_db = connect_db()
init_forum_schema(g.forum_db)
seed_categories(g.forum_db)
return g.forum_db
@forum_bp.before_request
def before_request():
"""Set up database connection before each request."""
g.forum_db = get_db()
@forum_bp.teardown_request
def teardown_request(exception):
"""Close database connection after each request."""
db = g.pop("forum_db", None)
if db is not None:
db.close()
def validate_content(content: str, min_length: int = 10, max_length: int = 50000) -> str | None:
"""Validate content and return error message if invalid."""
if not content or len(content.strip()) < min_length:
return f"Content must be at least {min_length} characters."
if len(content) > max_length:
return f"Content must be less than {max_length} characters."
return None
def validate_name(name: str) -> str | None:
"""Validate author name and return error message if invalid."""
if not name or len(name.strip()) < 2:
return "Name must be at least 2 characters."
if len(name) > 100:
return "Name must be less than 100 characters."
# Basic sanitization - allow letters, numbers, spaces, and common punctuation
if not re.match(r"^[\w\s\-'.]+$", name, re.UNICODE):
return "Name contains invalid characters."
return None
def validate_email(email: str | None) -> str | None:
"""Validate email if provided."""
if not email:
return None # Email is optional
if len(email) > 254:
return "Email is too long."
if not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
return "Invalid email format."
return None
def validate_title(title: str) -> str | None:
"""Validate topic title."""
if not title or len(title.strip()) < 5:
return "Title must be at least 5 characters."
if len(title) > 200:
return "Title must be less than 200 characters."
return None
def honeypot_check(func: Callable) -> Callable:
"""Decorator to check honeypot field for spam prevention."""
@wraps(func)
def wrapper(*args, **kwargs):
# If the honeypot field is filled, it's likely a bot
if request.form.get("website_url"): # Hidden field bots often fill
# Silently redirect without processing
return redirect(url_for("forum.index"))
return func(*args, **kwargs)
return wrapper
# ─────────────────────────────────────────────────────────────────────────────
# Forum Index - List all categories
# ─────────────────────────────────────────────────────────────────────────────
@forum_bp.route("/")
def index():
"""Forum home - list all categories with stats."""
db = get_db()
categories = get_categories_with_stats(db)
recent_topics = get_recent_topics(db, limit=5)
stats = get_forum_stats(db)
return render_template(
"forum/index.html",
categories=categories,
recent_topics=recent_topics,
stats=stats,
)
# ─────────────────────────────────────────────────────────────────────────────
# Category View - List topics in a category
# ─────────────────────────────────────────────────────────────────────────────
@forum_bp.route("/c/<slug>")
def category(slug: str):
"""List topics in a category."""
db = get_db()
cat = get_category_by_slug(db, slug)
if not cat:
abort(404)
page = request.args.get("page", 1, type=int)
per_page = 20
topics, total = get_topics_for_category(db, cat["id"], page, per_page)
total_pages = (total + per_page - 1) // per_page
return render_template(
"forum/category.html",
category=cat,
topics=topics,
page=page,
total_pages=total_pages,
total=total,
)
# ─────────────────────────────────────────────────────────────────────────────
# Topic View - View a topic and its replies
# ─────────────────────────────────────────────────────────────────────────────
@forum_bp.route("/t/<int:topic_id>/<slug>")
def topic(topic_id: int, slug: str):
"""View a topic and its replies."""
db = get_db()
t = get_topic_by_id(db, topic_id)
if not t:
abort(404)
# Redirect to correct slug if wrong
if t["slug"] != slug:
return redirect(url_for("forum.topic", topic_id=topic_id, slug=t["slug"]))
# Increment view count (simple - could add session check to prevent refresh abuse)
view_key = f"viewed_topic_{topic_id}"
if not session.get(view_key):
increment_view_count(db, topic_id)
session[view_key] = True
replies = get_replies_for_topic(db, topic_id)
cat = get_category_by_slug(db, "") # We need to get by ID
cat_row = db.execute(
"SELECT * FROM forum_categories WHERE id = ?", [t["category_id"]]
).fetchone()
cat = dict(cat_row) if cat_row else None
return render_template(
"forum/topic.html",
topic=t,
replies=replies,
category=cat,
)
# ─────────────────────────────────────────────────────────────────────────────
# New Topic - Create a new topic
# ─────────────────────────────────────────────────────────────────────────────
@forum_bp.route("/c/<slug>/new", methods=["GET", "POST"])
@honeypot_check
def new_topic(slug: str):
"""Create a new topic in a category."""
db = get_db()
cat = get_category_by_slug(db, slug)
if not cat:
abort(404)
error = None
form_data = {}
if request.method == "POST":
title = request.form.get("title", "").strip()
content = request.form.get("content", "").strip()
author_name = request.form.get("author_name", "").strip()
author_email = request.form.get("author_email", "").strip() or None
form_data = {
"title": title,
"content": content,
"author_name": author_name,
"author_email": author_email or "",
}
# Validate inputs
error = (
validate_title(title)
or validate_content(content)
or validate_name(author_name)
or validate_email(author_email)
)
if not error:
topic_id = create_topic(
db, cat["id"], title, content, author_name, author_email
)
from .models import slugify
topic_slug = slugify(title)
flash("Topic created successfully!", "success")
return redirect(url_for("forum.topic", topic_id=topic_id, slug=topic_slug))
return render_template(
"forum/new_topic.html",
category=cat,
error=error,
form_data=form_data,
)
# ─────────────────────────────────────────────────────────────────────────────
# Reply - Add a reply to a topic
# ─────────────────────────────────────────────────────────────────────────────
@forum_bp.route("/t/<int:topic_id>/reply", methods=["POST"])
@honeypot_check
def reply(topic_id: int):
"""Add a reply to a topic."""
db = get_db()
t = get_topic_by_id(db, topic_id)
if not t:
abort(404)
if t["is_locked"]:
flash("This topic is locked and cannot receive new replies.", "error")
return redirect(url_for("forum.topic", topic_id=topic_id, slug=t["slug"]))
content = request.form.get("content", "").strip()
author_name = request.form.get("author_name", "").strip()
author_email = request.form.get("author_email", "").strip() or None
# Validate inputs
error = (
validate_content(content)
or validate_name(author_name)
or validate_email(author_email)
)
if error:
flash(error, "error")
else:
create_reply(db, topic_id, content, author_name, author_email)
flash("Reply posted successfully!", "success")
return redirect(url_for("forum.topic", topic_id=topic_id, slug=t["slug"]))
+746
View File
@@ -0,0 +1,746 @@
"""Public web routes for the CmdForge 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 cmdforge.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 _api_post(path: str, data: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]:
client = current_app.test_client()
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
import json
response = client.post(path, data=json.dumps(data or {}), headers=headers)
return response.status_code, response.get_json(silent=True) or {}
def _api_delete(path: str, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]:
client = current_app.test_client()
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
response = client.delete(path, headers=headers)
return response.status_code, response.get_json(silent=True) or {}
def _title_case(value: str) -> str:
return value.replace("-", " ").title()
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()
# Sum all category counts for the "All Tools" total
all_tools_total = sum(c.count for c in categories)
return render_template(
"pages/tools.html",
tools=payload.get("data", []),
categories=categories,
current_category=category,
total_count=all_tools_total,
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):
from .docs_content import get_doc, get_toc
toc = get_toc()
current = path or "getting-started"
# Get content from docs_content module
doc = get_doc(current)
if doc:
# Convert (id, title) tuples to objects with id, text, level attributes
headings = [
SimpleNamespace(id=h[0], text=h[1], level=2)
for h in doc.get("headings", [])
]
page = SimpleNamespace(
title=doc["title"],
description=doc["description"],
content_html=Markup(doc["content"]),
headings=headings,
parent=doc.get("parent"),
)
else:
page = SimpleNamespace(
title=_title_case(current),
description="CmdForge documentation",
content_html=Markup(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):
from .docs_content import get_doc, get_toc
doc = get_doc(path)
if doc:
# Convert (id, title) tuples to objects with id, text, level attributes
headings = [
SimpleNamespace(id=h[0], text=h[1], level=2)
for h in doc.get("headings", [])
]
page = SimpleNamespace(
title=doc["title"],
description=doc["description"],
content_html=Markup(doc["content"]),
headings=headings,
parent=doc.get("parent"),
)
return render_template(
"pages/docs.html",
page=page,
toc=get_toc(),
current_path=path,
prev_page=None,
next_page=None,
)
return render_template(
"pages/content.html",
title=_title_case(path),
body="Tutorial content for this topic is coming soon.",
)
web_bp.add_url_rule("/tutorials/<path:path>", endpoint="tutorial", view_func=tutorials_path)
@web_bp.route("/community", endpoint="community")
def community():
return render_template("pages/community.html")
@web_bp.route("/about", endpoint="about")
def about():
return render_template("pages/about.html")
@web_bp.route("/donate", endpoint="donate")
def donate():
return render_template("pages/donate.html")
@web_bp.route("/privacy", endpoint="privacy")
def privacy():
return render_template("pages/privacy.html")
@web_bp.route("/terms", endpoint="terms")
def terms():
return render_template("pages/terms.html")
@web_bp.route("/forgot-password", endpoint="forgot_password")
def forgot_password():
return render_template(
"pages/content.html",
title="Reset Password",
body="Password resets are not available yet. Please contact support if needed.",
)
@web_bp.route("/robots.txt", endpoint="robots")
def robots():
"""Serve robots.txt - we welcome all crawlers including AI."""
from flask import send_from_directory
return send_from_directory(
web_bp.static_folder,
"robots.txt",
mimetype="text/plain"
)
@web_bp.route("/sitemap.xml", endpoint="sitemap")
def sitemap():
"""Generate dynamic sitemap.xml with all public pages and tools."""
from flask import Response
from datetime import datetime
conn = connect_db()
try:
tools = query_all(
conn,
"""
WITH latest AS (
SELECT owner, name, MAX(id) AS max_id
FROM tools
WHERE version NOT LIKE '%-%'
GROUP BY owner, name
)
SELECT t.owner, t.name, t.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.published_at DESC
""",
[],
)
tools = [dict(row) for row in tools]
finally:
conn.close()
base_url = request.url_root.rstrip("/")
today = datetime.utcnow().strftime("%Y-%m-%d")
# Static pages
static_pages = [
("/", "1.0", "daily"),
("/tools", "0.9", "daily"),
("/docs", "0.8", "weekly"),
("/docs/getting-started", "0.8", "weekly"),
("/docs/installation", "0.7", "weekly"),
("/docs/first-tool", "0.7", "weekly"),
("/docs/publishing", "0.7", "weekly"),
("/docs/providers", "0.7", "weekly"),
("/tutorials", "0.7", "weekly"),
("/about", "0.5", "monthly"),
("/community", "0.5", "weekly"),
("/donate", "0.4", "monthly"),
("/privacy", "0.3", "monthly"),
("/terms", "0.3", "monthly"),
]
xml_parts = ['<?xml version="1.0" encoding="UTF-8"?>']
xml_parts.append('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')
# Add static pages
for path, priority, freq in static_pages:
xml_parts.append(f""" <url>
<loc>{base_url}{path}</loc>
<lastmod>{today}</lastmod>
<changefreq>{freq}</changefreq>
<priority>{priority}</priority>
</url>""")
# Add tool pages
for tool in tools:
pub_date = tool.get("published_at", today)
if pub_date and "T" in str(pub_date):
pub_date = str(pub_date).split("T")[0]
xml_parts.append(f""" <url>
<loc>{base_url}/tools/{tool['owner']}/{tool['name']}</loc>
<lastmod>{pub_date or today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>""")
xml_parts.append("</urlset>")
return Response("\n".join(xml_parts), mimetype="application/xml")
# ============================================
# Dashboard API Proxy Routes
# These proxy browser requests to the API using session auth
# ============================================
from flask import jsonify
@web_bp.route("/dashboard/api/tokens", methods=["POST"])
def dashboard_create_token():
"""Proxy token creation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
data = request.get_json() or {}
status, payload = _api_post("/api/v1/tokens", data=data, token=token)
return jsonify(payload), status
@web_bp.route("/dashboard/api/tokens/<int:token_id>", methods=["DELETE"])
def dashboard_revoke_token(token_id: int):
"""Proxy token revocation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
status, payload = _api_delete(f"/api/v1/tokens/{token_id}", token=token)
return jsonify(payload), status
@web_bp.route("/dashboard/api/tools/<owner>/<name>/deprecate", methods=["POST"])
def dashboard_deprecate_tool(owner: str, name: str):
"""Proxy tool deprecation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
data = request.get_json() or {}
status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/deprecate", data=data, token=token)
return jsonify(payload), status
@web_bp.route("/dashboard/api/tools/<owner>/<name>/undeprecate", methods=["POST"])
def dashboard_undeprecate_tool(owner: str, name: str):
"""Proxy tool undeprecation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
data = request.get_json() or {}
status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/undeprecate", data=data, token=token)
return jsonify(payload), status
+78
View File
@@ -0,0 +1,78 @@
"""SEO helpers for sitemap and robots.txt."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import List
from flask import Response, current_app, url_for
from cmdforge.registry.db import connect_db, query_all
SITEMAP_TTL = timedelta(hours=6)
_sitemap_cache = {"generated_at": None, "xml": ""}
def generate_sitemap() -> str:
now = datetime.utcnow()
cached_at = _sitemap_cache.get("generated_at")
if cached_at and now - cached_at < SITEMAP_TTL:
return _sitemap_cache["xml"]
urls: List[str] = []
static_paths = ["/", "/tools", "/docs", "/tutorials", "/about"]
for path in static_paths:
urls.append(_url_entry(path, "daily", "1.0"))
conn = connect_db()
try:
rows = query_all(conn, "SELECT DISTINCT owner, name FROM tools")
for row in rows:
tool_path = url_for("web.tool_detail", owner=row["owner"], name=row["name"], _external=True)
urls.append(_url_entry(tool_path, "daily", "0.9"))
categories = query_all(conn, "SELECT DISTINCT category FROM tools WHERE category IS NOT NULL")
for row in categories:
cat_path = url_for("web.category", name=row["category"], _external=True)
urls.append(_url_entry(cat_path, "weekly", "0.7"))
finally:
conn.close()
xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
xml += "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"
xml += "\n".join(urls)
xml += "\n</urlset>\n"
_sitemap_cache["generated_at"] = now
_sitemap_cache["xml"] = xml
return xml
def sitemap_response() -> Response:
xml = generate_sitemap()
return Response(xml, mimetype="application/xml")
def robots_txt() -> Response:
lines = [
"User-agent: *",
"Allow: /",
"Disallow: /login",
"Disallow: /register",
"Disallow: /dashboard",
"Disallow: /api/",
f"Sitemap: {url_for('web.sitemap', _external=True)}",
]
return Response("\n".join(lines) + "\n", mimetype="text/plain")
def _url_entry(loc: str, changefreq: str, priority: str) -> str:
if not loc.startswith("http"):
loc = url_for("web.index", _external=True).rstrip("/") + loc
return "\n".join([
" <url>",
f" <loc>{loc}</loc>",
f" <changefreq>{changefreq}</changefreq>",
f" <priority>{priority}</priority>",
" </url>",
])
+109
View File
@@ -0,0 +1,109 @@
"""SQLite-backed server-side sessions for the web UI."""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timedelta
from typing import Optional
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
from cmdforge.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 = "cmdforge_session"):
self.cookie_name = cookie_name
def open_session(self, app, request):
session_id = request.cookies.get(self.cookie_name)
if not session_id:
return SQLiteSession(session_id=self._new_session_id())
conn = connect_db()
try:
row = conn.execute(
"SELECT data, expires_at FROM web_sessions WHERE session_id = ?",
[session_id],
).fetchone()
if not row:
return SQLiteSession(session_id=self._new_session_id())
expires_at = self._parse_dt(row["expires_at"])
if expires_at and expires_at < datetime.utcnow():
conn.execute("DELETE FROM web_sessions WHERE session_id = ?", [session_id])
conn.commit()
return SQLiteSession(session_id=self._new_session_id())
data = json.loads(row["data"] or "{}")
return SQLiteSession(initial=data, session_id=session_id)
finally:
conn.close()
def save_session(self, app, session, response):
if session is None:
return
session_id = session.session_id or self._new_session_id()
expires_at = datetime.utcnow() + SESSION_TTL
data = json.dumps(dict(session))
conn = connect_db()
try:
conn.execute(
"""
INSERT INTO web_sessions (session_id, data, created_at, expires_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET data = excluded.data, expires_at = excluded.expires_at
""",
[session_id, data, datetime.utcnow().isoformat(), expires_at.isoformat()],
)
conn.commit()
finally:
conn.close()
response.set_cookie(
self.cookie_name,
session_id,
httponly=True,
samesite="Lax",
secure=app.config.get("SESSION_COOKIE_SECURE", False),
max_age=int(SESSION_TTL.total_seconds()),
)
def rotate_session(self, session) -> None:
session.session_id = self._new_session_id()
@staticmethod
def _new_session_id() -> str:
return uuid.uuid4().hex
@staticmethod
def _parse_dt(value: str | None) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def cleanup_expired_sessions() -> int:
"""Remove expired sessions from the database."""
conn = connect_db()
try:
now = datetime.utcnow().isoformat()
cursor = conn.execute("DELETE FROM web_sessions WHERE expires_at < ?", [now])
conn.commit()
return cursor.rowcount or 0
finally:
conn.close()
File diff suppressed because one or more lines are too long
+505
View File
@@ -0,0 +1,505 @@
/**
* CmdForge Web UI - Main JavaScript
*/
// Copy to clipboard utility
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
if (button) {
const originalText = button.textContent;
button.textContent = 'Copied!';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}
}).catch(err => {
console.error('Failed to copy:', err);
});
}
// Copy code block handler
function copyCode(button) {
const code = button.dataset.code;
navigator.clipboard.writeText(code).then(() => {
const copyIcon = button.querySelector('.copy-icon');
const checkIcon = button.querySelector('.check-icon');
if (copyIcon && checkIcon) {
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
}
});
}
// Format numbers with K/M suffixes
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
// Debounce utility
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Mobile menu toggle
function toggleMobileMenu() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-icon-open');
const closeIcon = document.getElementById('menu-icon-close');
if (menu) {
const isHidden = menu.classList.contains('hidden');
menu.classList.toggle('hidden');
// Toggle icons
if (openIcon && closeIcon) {
if (isHidden) {
openIcon.classList.add('hidden');
closeIcon.classList.remove('hidden');
} else {
openIcon.classList.remove('hidden');
closeIcon.classList.add('hidden');
}
}
}
}
function closeMobileMenu() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-icon-open');
const closeIcon = document.getElementById('menu-icon-close');
if (menu) {
menu.classList.add('hidden');
if (openIcon) openIcon.classList.remove('hidden');
if (closeIcon) closeIcon.classList.add('hidden');
}
}
// Mobile filters toggle (tools page)
function toggleMobileFilters() {
const filters = document.getElementById('mobile-filters');
if (filters) {
filters.classList.toggle('hidden');
}
}
// Mobile TOC toggle (docs page)
function toggleMobileToc() {
const toc = document.getElementById('mobile-toc');
if (toc) {
toc.classList.toggle('hidden');
}
}
// Search modal
function openSearchModal() {
const modal = document.getElementById('search-modal');
if (modal) {
modal.classList.remove('hidden');
const input = modal.querySelector('input[type="text"]');
if (input) input.focus();
}
}
function closeSearchModal() {
const modal = document.getElementById('search-modal');
if (modal) {
modal.classList.add('hidden');
}
}
// Legacy aliases used by templates
function openSearch() {
openSearchModal();
}
function closeSearch() {
closeSearchModal();
}
// Report modal helpers
function openReportModal() {
const modal = document.getElementById('report-modal');
if (modal) {
modal.classList.remove('hidden');
}
}
function closeReportModal() {
const modal = document.getElementById('report-modal');
if (modal) {
modal.classList.add('hidden');
}
}
function submitReport(owner, name) {
const form = document.getElementById('report-form');
if (!form) return;
const ownerField = document.getElementById('report-owner');
const nameField = document.getElementById('report-name');
if (ownerField) ownerField.value = owner;
if (nameField) nameField.value = name;
if (!form.dataset.bound) {
form.addEventListener('submit', async function (e) {
e.preventDefault();
const payload = {
owner: ownerField ? ownerField.value : owner,
name: nameField ? nameField.value : name,
reason: form.querySelector('[name="reason"]')?.value || '',
details: form.querySelector('[name="description"]')?.value || ''
};
try {
const response = await fetch('/api/v1/reports', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (response.ok) {
closeReportModal();
alert('Thank you for your report. We will review it shortly.');
} else {
const error = await response.json();
alert((error.error && error.error.message) || 'Failed to submit report. Please try again.');
}
} catch (err) {
alert('Failed to submit report. Please try again.');
}
});
form.dataset.bound = 'true';
}
openReportModal();
}
// Analytics (respects consent)
function trackPageView() {
// Only track if user has given analytics consent
const consent = document.body.dataset.analyticsConsent;
if (consent !== 'true') {
return;
}
fetch('/api/v1/analytics/pageview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: window.location.pathname})
}).catch(() => {});
}
// 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();
trackPageView();
});
// Dropdown initialization
function initializeDropdowns() {
document.querySelectorAll('[data-dropdown]').forEach(dropdown => {
const toggle = dropdown.querySelector('[data-dropdown-toggle]');
const menu = dropdown.querySelector('[data-dropdown-menu]');
if (toggle && menu) {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', () => {
menu.classList.add('hidden');
});
}
});
}
// Intersection Observer for lazy loading
function initLazyLoad() {
const lazyImages = document.querySelectorAll('[data-lazy]');
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.lazy;
img.removeAttribute('data-lazy');
observer.unobserve(img);
}
});
});
lazyImages.forEach(img => imageObserver.observe(img));
} else {
// Fallback for browsers without IntersectionObserver
lazyImages.forEach(img => {
img.src = img.dataset.lazy;
});
}
}
// Toast notifications
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container') || createToastContainer();
const toast = document.createElement('div');
toast.className = `p-4 rounded-lg shadow-lg text-white mb-2 transform transition-all duration-300 translate-x-full`;
const colors = {
'success': 'bg-green-600',
'error': 'bg-red-600',
'warning': 'bg-amber-600',
'info': 'bg-indigo-600'
};
toast.classList.add(colors[type] || colors.info);
toast.textContent = message;
container.appendChild(toast);
// Animate in
requestAnimationFrame(() => {
toast.classList.remove('translate-x-full');
});
// Auto dismiss
setTimeout(() => {
toast.classList.add('translate-x-full', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed top-4 right-4 z-50 space-y-2';
document.body.appendChild(container);
return container;
}
// ============================================
// Tool Detail Page Functions
// ============================================
function copyInstall() {
const command = document.getElementById('install-command');
if (command) {
const text = command.textContent || command.innerText;
navigator.clipboard.writeText(text.trim()).then(() => {
showToast('Install command copied!', 'success');
});
}
}
function openReportModal() {
const modal = document.getElementById('report-modal');
if (modal) modal.classList.remove('hidden');
}
function closeReportModal() {
const modal = document.getElementById('report-modal');
if (modal) modal.classList.add('hidden');
}
// ============================================
// Dashboard - Token Management
// ============================================
function openCreateTokenModal() {
const modal = document.getElementById('create-token-modal');
if (modal) modal.classList.remove('hidden');
}
function closeCreateTokenModal() {
const modal = document.getElementById('create-token-modal');
if (modal) modal.classList.add('hidden');
}
function closeTokenCreatedModal() {
const modal = document.getElementById('token-created-modal');
if (modal) modal.classList.add('hidden');
// Reload to show new token in list
window.location.reload();
}
function copyNewToken() {
const tokenEl = document.getElementById('new-token-value');
if (tokenEl) {
navigator.clipboard.writeText(tokenEl.textContent.trim()).then(() => {
showToast('Token copied to clipboard!', 'success');
});
}
}
function revokeToken(tokenId, tokenName) {
if (confirm(`Are you sure you want to revoke the token "${tokenName}"? This cannot be undone.`)) {
fetch(`/api/v1/tokens/${tokenId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
showToast('Token revoked successfully', 'success');
window.location.reload();
} else {
showToast('Failed to revoke token', 'error');
}
}).catch(() => {
showToast('Failed to revoke token', 'error');
});
}
}
// ============================================
// Dashboard - Tool Management
// ============================================
let deprecateToolOwner = '';
let deprecateToolName = '';
function openDeprecateModal(owner, name, isDeprecated) {
deprecateToolOwner = owner;
deprecateToolName = name;
const modal = document.getElementById('deprecate-modal');
const title = document.getElementById('deprecate-modal-title');
const btn = document.getElementById('deprecate-submit-btn');
if (modal) {
if (title) {
title.textContent = isDeprecated ? `Restore ${name}` : `Deprecate ${name}`;
}
if (btn) {
btn.textContent = isDeprecated ? 'Restore Tool' : 'Deprecate Tool';
btn.classList.toggle('bg-red-600', !isDeprecated);
btn.classList.toggle('bg-green-600', isDeprecated);
}
modal.classList.remove('hidden');
}
}
function closeDeprecateModal() {
const modal = document.getElementById('deprecate-modal');
if (modal) modal.classList.add('hidden');
deprecateToolOwner = '';
deprecateToolName = '';
}
// ============================================
// Dashboard - Settings
// ============================================
function resendVerification() {
fetch('/api/v1/me/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
showToast('Verification email sent!', 'success');
} else {
showToast('Failed to send verification email', 'error');
}
}).catch(() => {
showToast('Failed to send verification email', 'error');
});
}
function confirmDeleteAccount() {
if (confirm('Are you absolutely sure you want to delete your account? This will permanently delete all your tools and cannot be undone.')) {
const confirmInput = prompt('Type "DELETE" to confirm:');
if (confirmInput === 'DELETE') {
fetch('/api/v1/me', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
window.location.href = '/';
} else {
showToast('Failed to delete account', 'error');
}
}).catch(() => {
showToast('Failed to delete account', 'error');
});
}
}
}
// ============================================
// Search Functionality
// ============================================
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
if (searchInput) {
searchInput.addEventListener('input', debounce(function() {
const query = this.value.trim();
if (query.length < 2) {
searchResults.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">Start typing to search...</p>';
return;
}
fetch(`/api/v1/tools/search?q=${encodeURIComponent(query)}&limit=10`)
.then(response => response.json())
.then(data => {
if (data.data && data.data.length > 0) {
searchResults.innerHTML = data.data.map(tool => `
<a href="/tools/${tool.owner}/${tool.name}"
class="block p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div class="font-medium text-gray-900">${tool.owner}/${tool.name}</div>
<div class="text-sm text-gray-500 truncate">${tool.description || 'No description'}</div>
</a>
`).join('');
} else {
searchResults.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">No tools found</p>';
}
})
.catch(() => {
searchResults.innerHTML = '<p class="text-sm text-red-500 text-center py-8">Search failed</p>';
});
}, 300));
}
+47
View File
@@ -0,0 +1,47 @@
# CmdForge Registry - robots.txt
# We welcome all crawlers! Index everything to help users find us
# and enable AI assistants to help solve CmdForge issues.
User-agent: *
Allow: /
# Encourage full indexing of documentation and tutorials
Allow: /docs/
Allow: /tutorials/
Allow: /tools/
Allow: /categories/
# Allow AI crawlers explicitly (we want AI to learn about CmdForge)
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: Anthropic-AI
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: PerplexityBot
Allow: /
# Only block private user areas (not useful to index)
Disallow: /dashboard/
Disallow: /api/v1/me/
Disallow: /api/v1/tokens
# Allow API docs to be indexed (helpful for developers)
Allow: /api/
# Sitemap location
Sitemap: https://cmdforge.brrd.tech/sitemap.xml
# Crawl-delay: none - crawl as fast as you want!
+105
View File
@@ -0,0 +1,105 @@
@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;
}
/* Prose styling for documentation content */
.prose {
@apply text-gray-700 leading-relaxed;
}
.prose p {
@apply mb-4;
}
.prose p.lead {
@apply text-xl text-gray-600 mb-6;
}
.prose h2 {
@apply text-2xl font-bold text-gray-900 mt-10 mb-4;
}
.prose h3 {
@apply text-xl font-semibold text-gray-900 mt-8 mb-3;
}
.prose h4 {
@apply text-lg font-semibold text-gray-900 mt-6 mb-2;
}
.prose ul, .prose ol {
@apply mb-4 pl-6;
}
.prose ul {
@apply list-disc;
}
.prose ol {
@apply list-decimal;
}
.prose li {
@apply mb-2;
}
.prose pre {
@apply bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto mb-4 text-sm;
}
.prose code {
@apply bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-sm font-mono;
}
.prose pre code {
@apply bg-transparent text-gray-100 p-0;
}
.prose a {
@apply text-indigo-600 hover:text-indigo-800 underline;
}
.prose table {
@apply w-full mb-4 border-collapse;
}
.prose th, .prose td {
@apply border border-gray-200 px-4 py-2 text-left;
}
.prose th {
@apply bg-gray-100 font-semibold;
}
.prose blockquote {
@apply border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4;
}
.prose details {
@apply mb-4;
}
.prose details summary {
@apply cursor-pointer font-medium;
}
.prose details[open] summary {
@apply mb-2;
}
.prose hr {
@apply my-8 border-gray-200;
}
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}CmdForge{% 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 %}CmdForge{% 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 %}CmdForge{% 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": "CmdForge",
"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" data-analytics-consent="{{ 'true' if session.get('consent_analytics') else 'false' }}">
<!-- Skip to content link for accessibility -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-indigo-600 text-white px-4 py-2 rounded-md z-50">
Skip to content
</a>
<!-- Header -->
{% include "components/header.html" %}
<!-- Main content -->
<main id="main-content" class="{% block main_class %}{% endblock %}">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
{% include "components/footer.html" %}
<!-- Cookie consent banner (if not consented) -->
{% if not session.get('consent_given') %}
{% include "components/consent_banner.html" %}
{% endif %}
<!-- Scripts -->
<script src="{{ url_for('web.static', filename='js/main.js') }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
@@ -0,0 +1,61 @@
{# Callout/alert macros for documentation and content pages #}
{# Info callout #}
{% macro info(title='Note', content='') %}
<div class="my-4 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-blue-800">{{ title }}</h4>
<p class="mt-1 text-sm text-blue-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Warning callout #}
{% macro warning(title='Warning', content='') %}
<div class="my-4 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-amber-800">{{ title }}</h4>
<p class="mt-1 text-sm text-amber-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Error callout #}
{% macro error(title='Important', content='') %}
<div class="my-4 p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-red-800">{{ title }}</h4>
<p class="mt-1 text-sm text-red-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Tip callout #}
{% macro tip(title='Tip', content='') %}
<div class="my-4 p-4 bg-green-50 border-l-4 border-green-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-green-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-green-800">{{ title }}</h4>
<p class="mt-1 text-sm text-green-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,47 @@
{# Code block macro with syntax highlighting and copy button #}
{% macro code(content, language='', filename='', show_line_numbers=false) %}
<div class="my-4 relative group">
{% if filename %}
<div class="bg-gray-800 text-gray-400 text-xs px-4 py-2 rounded-t-lg border-b border-gray-700">
{{ filename }}
</div>
{% endif %}
<div class="relative">
<pre class="{% if filename %}rounded-t-none{% endif %} bg-gray-900 text-gray-100 text-sm p-4 rounded-lg overflow-x-auto"><code class="language-{{ language }}">{{ content }}</code></pre>
<button type="button"
onclick="copyCode(this)"
data-code="{{ content|e }}"
class="absolute top-2 right-2 p-2 text-gray-400 hover:text-white bg-gray-800 rounded opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy code">
<svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
{% endmacro %}
{# Inline code #}
{% macro inline(content) %}
<code class="px-1.5 py-0.5 bg-gray-100 text-gray-800 text-sm font-mono rounded">{{ content }}</code>
{% endmacro %}
{# Install command (special styling) #}
{% macro install(command) %}
<div class="my-4 flex items-center bg-gray-100 rounded-lg p-4 group">
<span class="text-gray-400 mr-2 select-none">$</span>
<code class="flex-1 text-gray-800 font-mono text-sm">{{ command }}</code>
<button type="button"
onclick="copyToClipboard('{{ command }}')"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy command">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
{% endmacro %}
@@ -0,0 +1,148 @@
{# Cookie consent banner #}
<div id="consent-banner"
class="fixed bottom-0 inset-x-0 z-50 bg-white border-t border-gray-200 shadow-lg transform transition-transform duration-300"
role="dialog"
aria-labelledby="consent-title"
aria-describedby="consent-description">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="flex-1">
<h3 id="consent-title" class="text-sm font-semibold text-gray-900">
We value your privacy
</h3>
<p id="consent-description" class="mt-1 text-sm text-gray-600">
We use cookies to improve your experience and for analytics.
You can choose to accept all cookies or manage your preferences.
<a href="{{ url_for('web.privacy') }}" class="text-indigo-600 hover:text-indigo-800 underline">Learn more</a>
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<button type="button"
onclick="openConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Manage preferences
</button>
<button type="button"
onclick="rejectConsent()"
class="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
Reject all
</button>
<button type="button"
onclick="acceptAllConsent()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Accept all
</button>
</div>
</div>
</div>
</div>
<!-- Consent preferences modal -->
<div id="consent-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-modal="true"
role="dialog"
aria-labelledby="consent-modal-title">
<div class="min-h-screen px-4 text-center">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeConsentPreferences()"></div>
<!-- Modal -->
<div class="inline-block w-full max-w-lg my-8 text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="p-6">
<h3 id="consent-modal-title" class="text-lg font-semibold text-gray-900">
Cookie Preferences
</h3>
<p class="mt-2 text-sm text-gray-600">
Choose which cookies you want to accept. Essential cookies cannot be disabled as they are required for the site to function.
</p>
<div class="mt-6 space-y-4">
<!-- Essential cookies (always on) -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-essential" class="text-sm font-medium text-gray-900">Essential cookies</label>
<p class="mt-1 text-sm text-gray-500">Required for the website to function properly.</p>
</div>
<input type="checkbox" id="consent-essential" checked disabled aria-label="Essential cookies (always enabled)" class="mt-1 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<!-- Analytics cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-analytics" class="text-sm font-medium text-gray-900 cursor-pointer">Analytics cookies</label>
<p class="mt-1 text-sm text-gray-500">Help us understand how visitors use our site.</p>
</div>
<input type="checkbox"
id="consent-analytics"
name="analytics"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
<!-- Advertising cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-ads" class="text-sm font-medium text-gray-900 cursor-pointer">Advertising cookies</label>
<p class="mt-1 text-sm text-gray-500">Used to show relevant ads and support the project.</p>
</div>
<input type="checkbox"
id="consent-ads"
name="ads"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Cancel
</button>
<button type="button"
onclick="saveConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Save preferences
</button>
</div>
</div>
</div>
</div>
<script>
function acceptAllConsent() {
saveConsent(true, true);
}
function rejectConsent() {
saveConsent(false, false);
}
function openConsentPreferences() {
document.getElementById('consent-modal').classList.remove('hidden');
}
function closeConsentPreferences() {
document.getElementById('consent-modal').classList.add('hidden');
}
function saveConsentPreferences() {
const analytics = document.getElementById('consent-analytics').checked;
const ads = document.getElementById('consent-ads').checked;
saveConsent(analytics, ads);
closeConsentPreferences();
}
function saveConsent(analytics, ads) {
fetch('/api/v1/consent', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({analytics: analytics, ads: ads})
}).then(() => {
document.getElementById('consent-banner').style.transform = 'translateY(100%)';
setTimeout(() => {
document.getElementById('consent-banner').remove();
}, 300);
});
}
</script>
@@ -0,0 +1,58 @@
{# Contributor card macro #}
{% macro contributor_card(contributor=none, display_name=none, slug=none, bio=none, website=none, verified=none, featured=false) %}
{% if contributor is none %}
{% set contributor = {
"display_name": display_name,
"slug": slug,
"bio": bio,
"website": website,
"verified": verified
} %}
{% endif %}
{% if featured %}
<!-- Featured contributor (horizontal layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 flex items-center space-x-6">
<!-- Avatar -->
<div class="flex-shrink-0 w-16 h-16 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center">
<span class="text-white text-2xl font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
<p class="mt-2 text-gray-600 line-clamp-2">{{ contributor.bio or 'Active community member and tool creator.' }}</p>
</div>
<!-- Actions -->
<div class="flex-shrink-0">
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="inline-flex items-center px-4 py-2 border border-indigo-600 text-sm font-medium rounded-md text-indigo-600 hover:bg-indigo-50 transition-colors">
View Profile
</a>
</div>
</article>
{% else %}
<!-- Regular contributor card (vertical layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-4 text-center">
<!-- Avatar -->
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center mx-auto">
<span class="text-white text-lg font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<h3 class="mt-3 font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
{% if contributor.bio %}
<p class="mt-2 text-sm text-gray-600 line-clamp-2">{{ contributor.bio }}</p>
{% endif %}
<!-- Link -->
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="mt-3 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
View Profile
</a>
</article>
{% endif %}
{% endmacro %}
@@ -0,0 +1,60 @@
<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">CmdForge</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/cmdforge" 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/cmdforge" 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('forum.index') }}" class="text-gray-400 hover:text-white text-sm">Forum</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 }} CmdForge. Open source under MIT License.
</p>
<p class="text-gray-300 text-xs mt-2 sm:mt-0">
Made with care for the developer community.
</p>
</div>
</div>
</footer>
@@ -0,0 +1,169 @@
{# Reusable form macros #}
{# Text input field #}
{% macro text_input(name, label, type='text', placeholder='', value='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<input type="{{ type }}"
name="{{ name }}"
id="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Textarea field #}
{% macro textarea(name, label, placeholder='', value='', required=false, rows=4, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<textarea name="{{ name }}"
id="{{ name }}"
rows="{{ rows }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-y
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">{{ value }}</textarea>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Select dropdown #}
{% macro select(name, label, options, selected='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<select name="{{ name }}"
id="{{ name }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors bg-white
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% for value, label in options %}
<option value="{{ value }}" {% if value == selected %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Checkbox #}
{% macro checkbox(name, label, checked=false, help=none) %}
<div class="mb-4 flex items-start">
<input type="checkbox"
name="{{ name }}"
id="{{ name }}"
{% if checked %}checked{% endif %}
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<div class="ml-3">
<label for="{{ name }}" class="text-sm font-medium text-gray-700">{{ label }}</label>
{% if help %}
<p class="text-sm text-gray-500">{{ help }}</p>
{% endif %}
</div>
</div>
{% endmacro %}
{# Primary button #}
{% macro button_primary(text, type='submit', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Secondary button #}
{% macro button_secondary(text, type='button', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border-2 border-cyan-500 text-base font-medium rounded-md text-cyan-600 bg-transparent hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Danger button #}
{% macro button_danger(text, type='button', name=none, disabled=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Ghost/link button #}
{% macro button_ghost(text, href='#') %}
<a href="{{ href }}"
class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
{{ text }}
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% endmacro %}
{# Form error alert #}
{% macro form_errors(errors) %}
{% if errors %}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">There were errors with your submission</h3>
<ul class="mt-2 text-sm text-red-700 list-disc list-inside">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% endmacro %}
{# Success alert #}
{% macro success_alert(message) %}
{% if message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg" role="alert">
<div class="flex items-center">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="ml-3 text-sm font-medium text-green-800">{{ message }}</p>
</div>
</div>
{% endif %}
{% endmacro %}
@@ -0,0 +1,192 @@
<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">
CmdForge
</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('forum.index') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/forum') %}bg-slate-700{% endif %}">
Forum
</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('forum.index') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/forum') %}bg-slate-700{% endif %}">
Forum
</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..."
aria-label="Search tools, documentation, and tutorials"
class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autocomplete="off">
</div>
<div id="search-results" class="mt-4 max-h-96 overflow-y-auto">
<!-- Search results will be inserted here -->
<p class="text-sm text-gray-500 text-center py-8">Start typing to search...</p>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 rounded-b-lg flex justify-between items-center text-sm text-gray-500">
<span>Press <kbd class="px-2 py-1 bg-gray-200 rounded">ESC</kbd> to close</span>
<button onclick="closeSearch()" class="text-gray-500 hover:text-gray-700">Close</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,70 @@
{# Tool card macro #}
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none) %}
{% if tool is none %}
{% set tool = {
"owner": owner,
"name": name,
"description": description,
"category": category,
"downloads": downloads,
"version": version
} %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-4 relative">
<!-- Category badge -->
{% if tool.category %}
<span class="absolute top-3 right-3 px-2 py-1 bg-cyan-500 text-white text-xs font-medium rounded">
{{ tool.category }}
</span>
{% endif %}
<!-- Tool icon and name -->
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold text-sm">{{ tool.name[0]|upper }}</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="hover:text-indigo-600 transition-colors">
{{ tool.name }}
</a>
</h3>
<p class="text-sm text-gray-500">by {{ tool.owner }}</p>
</div>
</div>
<!-- Description -->
<p class="mt-3 text-sm text-gray-600 line-clamp-2">
{{ tool.description or 'No description available.' }}
</p>
<!-- Meta info -->
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ tool.downloads|default(0) }} downloads
</span>
<span class="text-xs text-gray-600">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">
cmdforge install {{ tool.owner }}/{{ tool.name }}
</code>
<button type="button"
onclick="copyToClipboard('cmdforge install {{ tool.owner }}/{{ tool.name }}')"
class="ml-2 p-1 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy install command">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
</article>
{% endmacro %}
@@ -0,0 +1,53 @@
{# Tutorial card macro #}
{% macro tutorial_card(tutorial=none, title=none, description=none, href=none, slug=none, thumbnail=none, step_number=none) %}
{% if tutorial is none %}
{% set tutorial = {
"slug": slug,
"title": title,
"description": description,
"thumbnail": thumbnail
} %}
{% endif %}
{% if href is none and tutorial.slug %}
{% set href = url_for('web.tutorial', slug=tutorial.slug) %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<!-- Thumbnail (optional) -->
{% if tutorial.thumbnail %}
<div class="aspect-video bg-gray-100 relative">
<img src="{{ tutorial.thumbnail }}"
alt="{{ tutorial.title }}"
class="w-full h-full object-cover"
loading="lazy">
</div>
{% else %}
<div class="aspect-video bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center">
<svg class="w-12 h-12 text-white opacity-75" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
{% endif %}
<div class="p-4">
{% if step_number %}
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-700 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 CmdForge effectively.' }}
</p>
<a href="{{ href or '#' }}"
class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
Read More
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</article>
{% endmacro %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Dashboard Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-1 text-gray-600">Welcome back, {{ user.display_name }}</p>
{% endblock %}
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Sidebar Navigation -->
<aside>
<nav class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<a href="{{ url_for('web.dashboard') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'overview' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
Overview
</a>
<a href="{{ url_for('web.dashboard_tools') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tools' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
My Tools
</a>
<a href="{{ url_for('web.dashboard_tokens') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tokens' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
API Tokens
</a>
<a href="{{ url_for('web.dashboard_settings') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'settings' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Settings
</a>
</nav>
<!-- Quick Stats (mobile hidden) -->
<div class="hidden lg:block mt-6 bg-white rounded-lg border border-gray-200 p-4">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Quick Stats</h3>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Tools</dt>
<dd class="text-sm font-medium text-gray-900">{{ stats.tools_count if stats else 0 }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Downloads</dt>
<dd class="text-sm font-medium text-gray-900">{{ (stats.total_downloads if stats else 0)|format_number }}</dd>
</div>
</dl>
</div>
</aside>
<!-- Main Content -->
<main class="lg:col-span-3">
{% block dashboard_content %}{% endblock %}
</main>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,135 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'overview' %}
{% block title %}Dashboard - CmdForge{% endblock %}
{% block dashboard_content %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-indigo-100 rounded-lg">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Published Tools</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tools_count }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Downloads</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.total_downloads|format_number }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-cyan-100 rounded-lg">
<svg class="w-6 h-6 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">API Tokens</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tokens_count }}</p>
</div>
</div>
</div>
</div>
<!-- Recent Tools -->
<div class="bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Your Tools</h2>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
+ Publish New Tool
</a>
</div>
{% if tools %}
<ul class="divide-y divide-gray-200">
{% for tool in tools[:5] %}
<li class="px-6 py-4 flex items-center justify-between">
<div>
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="font-medium text-gray-900 hover:text-indigo-600">
{{ tool.name }}
</a>
<p class="text-sm text-gray-500">v{{ tool.version }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-900">{{ tool.downloads|format_number }} downloads</p>
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
{% if tools|length > 5 %}
<div class="px-6 py-4 border-t border-gray-200">
<a href="{{ url_for('web.dashboard_tools') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
View all {{ tools|length }} tools
</a>
</div>
{% endif %}
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<p class="mt-4 text-gray-600">You haven't published any tools yet.</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Publish Your First Tool
</a>
</div>
{% endif %}
</div>
<!-- Recent Activity -->
{% if recent_activity %}
<div class="mt-6 bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Recent Activity</h2>
</div>
<ul class="divide-y divide-gray-200">
{% for activity in recent_activity[:5] %}
<li class="px-6 py-4 flex items-start">
<div class="flex-shrink-0">
{% if activity.type == 'download' %}
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
{% elif activity.type == 'publish' %}
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-4">
<p class="text-sm text-gray-900">{{ activity.description }}</p>
<p class="text-xs text-gray-500">{{ activity.timestamp|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,254 @@
{% extends "dashboard/base.html" %}
{% from "components/forms.html" import text_input, textarea, button_primary, form_errors, success_alert %}
{% set active_page = 'settings' %}
{% block title %}Settings - CmdForge Dashboard{% endblock %}
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
<p class="mt-1 text-gray-600">Manage your profile and account settings</p>
{% endblock %}
{% block dashboard_content %}
{{ success_alert(success_message) }}
{{ form_errors(errors) }}
<!-- Profile Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Profile</h2>
<p class="text-sm text-gray-600">This information will be displayed on your public profile.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="profile">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">
Display name
</label>
<input type="text"
name="display_name"
id="display_name"
value="{{ user.display_name }}"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<div class="flex">
<span class="inline-flex items-center px-3 text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-lg">
@
</span>
<input type="text"
name="slug"
id="slug"
value="{{ user.slug }}"
disabled
class="flex-1 px-4 py-2 border border-gray-300 rounded-r-lg bg-gray-50 text-gray-500">
</div>
<p class="mt-1 text-xs text-gray-500">Username cannot be changed</p>
</div>
</div>
<div class="mt-6">
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">
Bio
</label>
<textarea name="bio"
id="bio"
rows="3"
placeholder="Tell others a bit about yourself..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y">{{ user.bio or '' }}</textarea>
<p class="mt-1 text-xs text-gray-500">Brief description for your profile. Max 500 characters.</p>
</div>
<div class="mt-6">
<label for="website" class="block text-sm font-medium text-gray-700 mb-1">
Website
</label>
<input type="url"
name="website"
id="website"
value="{{ user.website or '' }}"
placeholder="https://example.com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Save Profile
</button>
</div>
</form>
</div>
<!-- Email Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Email</h2>
<p class="text-sm text-gray-600">Your email address for account notifications.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ user.email }}</p>
{% if user.verified %}
<p class="text-xs text-green-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
Verified
</p>
{% else %}
<p class="text-xs text-amber-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Not verified
</p>
{% endif %}
</div>
{% if not user.verified %}
<button type="button"
onclick="resendVerification()"
class="text-sm text-indigo-600 hover:text-indigo-800">
Resend verification
</button>
{% endif %}
</div>
</div>
</div>
<!-- Password Change -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Change Password</h2>
<p class="text-sm text-gray-600">Update your password to keep your account secure.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="password">
<div class="space-y-4 max-w-md">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-1">
Current password
</label>
<input type="password"
name="current_password"
id="current_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-1">
New password
</label>
<input type="password"
name="new_password"
id="new_password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<p class="mt-1 text-xs text-gray-500">Minimum 8 characters</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">
Confirm new password
</label>
<input type="password"
name="confirm_password"
id="confirm_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Update Password
</button>
</div>
</form>
</div>
<!-- Danger Zone -->
<div class="bg-white rounded-lg border border-red-200 overflow-hidden">
<div class="px-6 py-4 border-b border-red-200 bg-red-50">
<h2 class="text-lg font-semibold text-red-800">Danger Zone</h2>
<p class="text-sm text-red-600">Irreversible actions that affect your account.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">Delete Account</p>
<p class="text-sm text-gray-500">Permanently delete your account and all associated data.</p>
</div>
<button type="button"
onclick="confirmDeleteAccount()"
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-md hover:bg-red-50">
Delete Account
</button>
</div>
</div>
</div>
<script>
async function resendVerification() {
try {
const response = await fetch('/api/v1/me/resend-verification', {method: 'POST'});
if (response.ok) {
alert('Verification email sent! Please check your inbox.');
} else {
alert('Failed to send verification email. Please try again later.');
}
} catch (err) {
alert('Failed to send verification email. Please try again later.');
}
}
function confirmDeleteAccount() {
const confirmed = confirm(
'Are you absolutely sure you want to delete your account?\n\n' +
'This will permanently delete:\n' +
'- Your profile\n' +
'- All your published tools\n' +
'- All your API tokens\n\n' +
'This action cannot be undone.'
);
if (confirmed) {
const doubleConfirm = prompt('Type "DELETE" to confirm account deletion:');
if (doubleConfirm === 'DELETE') {
deleteAccount();
}
}
}
async function deleteAccount() {
try {
const response = await fetch('/api/v1/me', {method: 'DELETE'});
if (response.ok) {
window.location.href = '/?deleted=1';
} else {
alert('Failed to delete account. Please try again or contact support.');
}
} catch (err) {
alert('Failed to delete account. Please try again or contact support.');
}
}
</script>
{% endblock %}
@@ -0,0 +1,312 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tokens' %}
{% block title %}API Tokens - CmdForge 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 CmdForge registry from the CLI.
Use <code class="px-1 bg-blue-100 rounded">cmdforge auth login</code> to authenticate,
or set the <code class="px-1 bg-blue-100 rounded">CMDFORGE_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 CMDFORGE_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('/dashboard/api/tokens', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (response.ok) {
const result = await response.json();
closeCreateTokenModal();
showNewToken(result.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(`/dashboard/api/tokens/${tokenId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert(error.error || 'Failed to revoke token');
}
} catch (err) {
alert('Failed to revoke token. Please try again.');
}
}
</script>
{% endblock %}
@@ -0,0 +1,259 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tools' %}
{% block title %}My Tools - CmdForge 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>
let deprecateAction = '';
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');
deprecateAction = `/dashboard/api/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');
deprecateAction = `/dashboard/api/tools/${owner}/${name}/deprecate`;
}
document.getElementById('deprecate-modal').classList.remove('hidden');
}
function closeDeprecateModal() {
document.getElementById('deprecate-modal').classList.add('hidden');
}
document.getElementById('deprecate-form').addEventListener('submit', async function(e) {
e.preventDefault();
const message = document.getElementById('deprecated_message')?.value || '';
const replacement = document.getElementById('replacement')?.value || '';
try {
const response = await fetch(deprecateAction, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({deprecated_message: message, replacement: replacement})
});
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert((error.error && error.error.message) || 'Failed to update tool status');
}
} catch (err) {
alert('Failed to update tool status. Please try again.');
}
});
</script>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Page Not Found - CmdForge{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center px-4">
<div class="text-center max-w-md">
<p class="text-6xl font-bold text-indigo-600">404</p>
<h1 class="mt-4 text-3xl font-bold text-gray-900">Page not found</h1>
<p class="mt-4 text-gray-600">
Sorry, we couldn't find the page you're looking for. It may have been moved or deleted.
</p>
<div class="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ url_for('web.home') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Go Home
</a>
<a href="{{ url_for('web.tools') }}"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Browse Tools
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Server Error - CmdForge{% 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/cmdforge/issues"
target="_blank"
rel="noopener noreferrer"
class="w-full sm:w-auto inline-flex justify-center items-center px-6 py-3 text-base font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Report Issue
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}{{ category.name }} - Forum{% endblock %}
{% block meta_description %}{{ category.description }}{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-4">
<a href="{{ url_for('forum.index') }}" class="hover:text-indigo-600">Forum</a>
<span class="mx-2">/</span>
<span class="text-gray-900">{{ category.name }}</span>
</nav>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ category.name }}</h1>
<p class="mt-1 text-gray-600">{{ category.description }}</p>
</div>
<a href="{{ url_for('forum.new_topic', slug=category.slug) }}"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium 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>
New Topic
</a>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{% if topics %}
<!-- Topics list -->
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for topic in topics %}
<a href="{{ url_for('forum.topic', topic_id=topic.id, slug=topic.slug) }}"
class="block p-4 hover:bg-gray-50 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
{% if topic.is_pinned %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Pinned
</span>
{% endif %}
{% if topic.is_locked %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Locked
</span>
{% endif %}
<h3 class="font-medium text-gray-900 truncate">{{ topic.title }}</h3>
</div>
<div class="mt-1 text-sm text-gray-500">
by {{ topic.author_name }} &middot; {{ topic.created_at | timeago }}
</div>
</div>
<div class="ml-4 flex-shrink-0 text-right">
<div class="text-sm text-gray-900">{{ topic.reply_count }} replies</div>
<div class="text-xs text-gray-500">{{ topic.view_count }} views</div>
</div>
</div>
</a>
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav class="mt-6 flex items-center justify-center gap-2">
{% if page > 1 %}
<a href="{{ url_for('forum.category', slug=category.slug, page=page-1) }}"
class="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{% endif %}
<span class="px-3 py-2 text-sm text-gray-700">
Page {{ page }} of {{ total_pages }}
</span>
{% if page < total_pages %}
<a href="{{ url_for('forum.category', slug=category.slug, page=page+1) }}"
class="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<!-- Empty state -->
<div class="bg-white border border-gray-200 rounded-lg p-8 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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No topics yet</h3>
<p class="mt-2 text-gray-600">Be the first to start a discussion in this category.</p>
<a href="{{ url_for('forum.new_topic', slug=category.slug) }}"
class="mt-4 inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Create the first topic
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+136
View File
@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}Community Forum{% endblock %}
{% block meta_description %}Join the CmdForge community. Ask questions, share your projects, and connect with other users.{% 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-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900">Community Forum</h1>
<p class="mt-2 text-gray-600">
Ask questions, share your projects, and connect with other CmdForge users.
</p>
<div class="mt-4 flex items-center gap-6 text-sm text-gray-500">
<span>{{ stats.topics }} topics</span>
<span>{{ stats.replies }} replies</span>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="lg:grid lg:grid-cols-3 lg:gap-8">
<!-- Categories -->
<div class="lg:col-span-2">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Categories</h2>
<div class="space-y-3">
{% for cat in categories %}
<a href="{{ url_for('forum.category', slug=cat.slug) }}"
class="block bg-white border border-gray-200 rounded-lg p-4 hover:border-indigo-300 hover:shadow-sm transition-all">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3">
<!-- Icon -->
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center
{% if cat.icon == 'chat' %}bg-blue-100 text-blue-600
{% elif cat.icon == 'question' %}bg-amber-100 text-amber-600
{% elif cat.icon == 'star' %}bg-purple-100 text-purple-600
{% elif cat.icon == 'lightbulb' %}bg-green-100 text-green-600
{% elif cat.icon == 'book' %}bg-indigo-100 text-indigo-600
{% else %}bg-gray-100 text-gray-600{% endif %}">
{% if cat.icon == 'chat' %}
<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 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
{% elif cat.icon == 'question' %}
<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.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif cat.icon == 'star' %}
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
</svg>
{% elif cat.icon == 'lightbulb' %}
<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="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>
{% elif cat.icon == 'book' %}
<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="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>
{% else %}
<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="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>
{% endif %}
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ cat.name }}</h3>
<p class="text-sm text-gray-600 mt-0.5">{{ cat.description }}</p>
</div>
</div>
<div class="text-right text-sm text-gray-500 flex-shrink-0 ml-4">
<div>{{ cat.topic_count }} topics</div>
<div class="text-xs">{{ cat.reply_count }} replies</div>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
<!-- Sidebar -->
<div class="mt-8 lg:mt-0">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h2>
{% if recent_topics %}
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for topic in recent_topics %}
<a href="{{ url_for('forum.topic', topic_id=topic.id, slug=topic.slug) }}"
class="block p-3 hover:bg-gray-50 transition-colors">
<div class="font-medium text-gray-900 text-sm truncate">{{ topic.title }}</div>
<div class="text-xs text-gray-500 mt-1">
in {{ topic.category_name }} &middot; {{ topic.reply_count }} replies
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="bg-white border border-gray-200 rounded-lg p-4 text-center text-gray-500">
<p>No topics yet. Be the first to start a discussion!</p>
</div>
{% endif %}
<!-- Quick Links -->
<div class="mt-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Quick Links</h2>
<div class="space-y-2">
<a href="{{ url_for('web.docs', path='getting-started') }}"
class="flex items-center text-sm text-gray-600 hover:text-indigo-600">
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Getting Started Guide
</a>
<a href="{{ url_for('web.tools') }}"
class="flex items-center text-sm text-gray-600 hover:text-indigo-600">
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
Browse Tools
</a>
<a href="https://gitea.brrd.tech/rob/CmdForge/issues"
target="_blank" rel="noopener"
class="flex items-center text-sm text-gray-600 hover:text-indigo-600">
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Report a Bug
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}New Topic in {{ category.name }} - Forum{% endblock %}
{% block meta_description %}Start a new discussion in {{ category.name }}.{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-4">
<a href="{{ url_for('forum.index') }}" class="hover:text-indigo-600">Forum</a>
<span class="mx-2">/</span>
<a href="{{ url_for('forum.category', slug=category.slug) }}" class="hover:text-indigo-600">{{ category.name }}</a>
<span class="mx-2">/</span>
<span class="text-gray-900">New Topic</span>
</nav>
<h1 class="text-2xl font-bold text-gray-900">New Topic in {{ category.name }}</h1>
</div>
</div>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{% if error %}
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md text-red-800">
{{ error }}
</div>
{% endif %}
<form method="POST" class="bg-white border border-gray-200 rounded-lg p-6">
<!-- Honeypot field (hidden, bots fill this) -->
<div style="display: none;">
<input type="text" name="website_url" tabindex="-1" autocomplete="off">
</div>
<!-- Author info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label for="author_name" class="block text-sm font-medium text-gray-700 mb-1">
Your Name <span class="text-red-500">*</span>
</label>
<input type="text"
id="author_name"
name="author_name"
required
value="{{ form_data.author_name if form_data else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="John Doe">
</div>
<div>
<label for="author_email" class="block text-sm font-medium text-gray-700 mb-1">
Email <span class="text-gray-400">(optional)</span>
</label>
<input type="email"
id="author_email"
name="author_email"
value="{{ form_data.author_email if form_data else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="[email protected]">
<p class="mt-1 text-xs text-gray-500">Used for Gravatar only, not displayed publicly.</p>
</div>
</div>
<!-- Title -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">
Topic Title <span class="text-red-500">*</span>
</label>
<input type="text"
id="title"
name="title"
required
value="{{ form_data.title if form_data else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="How do I...?">
<p class="mt-1 text-xs text-gray-500">Be specific and descriptive (5-200 characters).</p>
</div>
<!-- Content -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-700 mb-1">
Content <span class="text-red-500">*</span>
</label>
<textarea id="content"
name="content"
rows="12"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 font-mono text-sm"
placeholder="Describe your question, idea, or project in detail...">{{ form_data.content if form_data else '' }}</textarea>
<p class="mt-1 text-xs text-gray-500">
Markdown is supported: **bold**, *italic*, `code`, ```code blocks```, [links](url)
</p>
</div>
<!-- Tips -->
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md">
<h3 class="text-sm font-medium text-blue-800 mb-2">Tips for a great post:</h3>
<ul class="text-sm text-blue-700 space-y-1 list-disc list-inside">
<li>Search first to avoid duplicate topics</li>
<li>Use a clear, descriptive title</li>
<li>Include relevant details and context</li>
<li>For bugs, include steps to reproduce</li>
<li>Format code with triple backticks: ```code```</li>
</ul>
</div>
<!-- Submit -->
<div class="flex items-center justify-between">
<a href="{{ url_for('forum.category', slug=category.slug) }}"
class="text-gray-600 hover:text-gray-900">
Cancel
</a>
<button type="submit"
class="px-6 py-2 bg-indigo-600 text-white font-medium rounded-md hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Create Topic
</button>
</div>
</form>
</div>
</div>
{% endblock %}
+178
View File
@@ -0,0 +1,178 @@
{% extends "base.html" %}
{% block title %}{{ topic.title }} - Forum{% endblock %}
{% block meta_description %}{{ topic.content[:160] }}{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- 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-6">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-4">
<a href="{{ url_for('forum.index') }}" class="hover:text-indigo-600">Forum</a>
<span class="mx-2">/</span>
{% if category %}
<a href="{{ url_for('forum.category', slug=category.slug) }}" class="hover:text-indigo-600">{{ category.name }}</a>
<span class="mx-2">/</span>
{% endif %}
<span class="text-gray-900 truncate">{{ topic.title[:50] }}{% if topic.title|length > 50 %}...{% endif %}</span>
</nav>
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2 flex-wrap">
{% if topic.is_pinned %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Pinned
</span>
{% endif %}
{% if topic.is_locked %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Locked
</span>
{% endif %}
<h1 class="text-2xl font-bold text-gray-900">{{ topic.title }}</h1>
</div>
<p class="mt-2 text-sm text-gray-500">
{{ topic.view_count }} views &middot; {{ replies|length }} replies
</p>
</div>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-4 rounded-md {% if category == 'error' %}bg-red-50 text-red-800{% else %}bg-green-50 text-green-800{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Original post -->
<article class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="p-6">
<div class="flex items-center mb-4">
<!-- Avatar placeholder -->
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-semibold">
{{ topic.author_name[0]|upper }}
</div>
<div class="ml-3">
<div class="font-medium text-gray-900">{{ topic.author_name }}</div>
<div class="text-sm text-gray-500">{{ topic.created_at | timeago }}</div>
</div>
</div>
<div class="prose prose-sm max-w-none">
{{ topic.content | markdown | safe }}
</div>
</div>
</article>
<!-- Replies -->
{% if replies %}
<div class="mt-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">{{ replies|length }} {% if replies|length == 1 %}Reply{% else %}Replies{% endif %}</h2>
{% for reply in replies %}
<article class="bg-white border border-gray-200 rounded-lg overflow-hidden {% if reply.is_solution %}ring-2 ring-green-500{% endif %}" id="reply-{{ reply.id }}">
<div class="p-6">
<div class="flex items-center mb-4">
<!-- Avatar placeholder -->
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-semibold">
{{ reply.author_name[0]|upper }}
</div>
<div class="ml-3 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ reply.author_name }}</span>
{% if reply.is_solution %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Solution
</span>
{% endif %}
</div>
<div class="text-sm text-gray-500">{{ reply.created_at | timeago }}</div>
</div>
</div>
<div class="prose prose-sm max-w-none">
{{ reply.content | markdown | safe }}
</div>
</div>
</article>
{% endfor %}
</div>
{% endif %}
<!-- Reply form -->
{% if not topic.is_locked %}
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Post a Reply</h2>
<form method="POST" action="{{ url_for('forum.reply', topic_id=topic.id) }}" class="bg-white border border-gray-200 rounded-lg p-6">
<!-- Honeypot field (hidden, bots fill this) -->
<div style="display: none;">
<input type="text" name="website_url" tabindex="-1" autocomplete="off">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="author_name" class="block text-sm font-medium text-gray-700 mb-1">
Your Name <span class="text-red-500">*</span>
</label>
<input type="text"
id="author_name"
name="author_name"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="John Doe">
</div>
<div>
<label for="author_email" class="block text-sm font-medium text-gray-700 mb-1">
Email <span class="text-gray-400">(optional)</span>
</label>
<input type="email"
id="author_email"
name="author_email"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="[email protected]">
</div>
</div>
<div class="mb-4">
<label for="content" class="block text-sm font-medium text-gray-700 mb-1">
Your Reply <span class="text-red-500">*</span>
</label>
<textarea id="content"
name="content"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 font-mono text-sm"
placeholder="Write your reply here... (Markdown supported)"></textarea>
<p class="mt-1 text-xs text-gray-500">
Markdown is supported: **bold**, *italic*, `code`, ```code blocks```, [links](url)
</p>
</div>
<div class="flex justify-end">
<button type="submit"
class="px-4 py-2 bg-indigo-600 text-white font-medium rounded-md hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Post Reply
</button>
</div>
</form>
</div>
{% else %}
<div class="mt-8 bg-gray-100 border border-gray-200 rounded-lg p-6 text-center">
<svg class="mx-auto w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<p class="mt-2 text-gray-600">This topic is locked and cannot receive new replies.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+138
View File
@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}About - CmdForge{% endblock %}
{% block meta_description %}Learn about CmdForge, 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 CmdForge</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>
CmdForge 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 CmdForge, 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">
CmdForge 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>
CmdForge 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">
CmdForge is open source and welcomes contributions of all kinds.
</p>
<a href="https://github.com/your-org/cmdforge"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-6 py-3 text-base font-medium text-white bg-gray-900 rounded-md hover:bg-gray-800">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
View on GitHub
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Community - CmdForge{% endblock %}
{% block meta_description %}Connect with CmdForge - report issues, request features, and contribute to the project.{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Hero -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<h1 class="text-3xl font-bold text-gray-900">Community</h1>
<p class="mt-4 text-lg text-gray-600">
CmdForge is open source. Get help, report issues, and contribute.
</p>
</div>
</div>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Forum CTA -->
<section class="mb-12">
<a href="{{ url_for('forum.index') }}"
class="block bg-indigo-600 rounded-lg p-6 hover:bg-indigo-700 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-white">Community Forum</h2>
<p class="text-indigo-100">Ask questions, share projects, and connect with other users</p>
</div>
</div>
<svg class="w-6 h-6 text-white" 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>
</div>
</a>
</section>
<!-- Community hubs -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Source Code</h2>
<p class="mt-2 text-sm text-gray-600">
Browse the source, fork the project, and submit pull requests.
</p>
<a href="https://gitea.brrd.tech/rob/CmdForge"
target="_blank" rel="noopener"
class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800">
View Repository
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
<div class="bg-white rounded-lg border border-gray-200 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Report Issues</h2>
<p class="mt-2 text-sm text-gray-600">
Found a bug? Have a feature request? Open an issue.
</p>
<a href="https://gitea.brrd.tech/rob/CmdForge/issues"
target="_blank" rel="noopener"
class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800">
Open Issue
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
<div class="bg-white rounded-lg border border-gray-200 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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Documentation</h2>
<p class="mt-2 text-sm text-gray-600">
Learn how to build tools, configure providers, and publish to the registry.
</p>
<a href="{{ url_for('web.docs', path='getting-started') }}"
class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800">
Read the Docs
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</section>
<!-- Get Involved -->
<section class="bg-white rounded-lg border border-gray-200 p-8">
<h2 class="text-2xl font-bold text-gray-900 text-center">Ways to Contribute</h2>
<div class="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-start space-x-4">
<div class="flex-shrink-0 w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold">1</span>
</div>
<div>
<h3 class="font-semibold text-gray-900">Share Your Tools</h3>
<p class="mt-1 text-sm text-gray-600">
Built something useful? <a href="{{ url_for('web.docs', path='publishing') }}" class="text-indigo-600 hover:underline">Publish it to the registry</a> so others can use it.
</p>
</div>
</div>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0 w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold">2</span>
</div>
<div>
<h3 class="font-semibold text-gray-900">Improve the Docs</h3>
<p class="mt-1 text-sm text-gray-600">
Found something confusing? Submit a PR to improve the documentation.
</p>
</div>
</div>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0 w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold">3</span>
</div>
<div>
<h3 class="font-semibold text-gray-900">Report Bugs</h3>
<p class="mt-1 text-sm text-gray-600">
Help make CmdForge better by reporting issues with clear reproduction steps.
</p>
</div>
</div>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0 w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold">4</span>
</div>
<div>
<h3 class="font-semibold text-gray-900">Add Features</h3>
<p class="mt-1 text-sm text-gray-600">
Check the issues for feature requests or propose your own ideas.
</p>
</div>
</div>
</div>
</section>
<!-- Quick Links -->
<section class="mt-8 text-center">
<div class="flex flex-wrap items-center justify-center gap-4">
<a href="{{ url_for('forum.index') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Visit Forum
</a>
<a href="https://gitea.brrd.tech/rob/CmdForge#installation"
target="_blank" rel="noopener"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Installation
</a>
<a href="{{ url_for('web.tools') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Browse Tools
</a>
<a href="{{ url_for('web.tutorials') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Tutorials
</a>
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ title }} - CmdForge{% 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 %}
+208
View File
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% from "components/callouts.html" import info, warning, tip %}
{% block title %}{{ page.title }} - CmdForge 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/cmdforge/edit/main/docs/{{ current_path }}.md"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
</svg>
Edit this page on GitHub
</a>
</div>
</main>
<!-- Right Sidebar - On This Page + Ads -->
<aside class="hidden lg:block lg:col-span-3">
<div class="sticky top-4 space-y-6">
<!-- On This Page -->
{% if page.headings %}
<nav class="bg-white rounded-lg border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">On this page</h3>
<ul class="space-y-2 text-sm" id="page-toc">
{% for heading in page.headings %}
<li class="{{ 'ml-4' if heading.level > 2 else '' }}">
<a href="#{{ heading.id }}"
class="text-gray-500 hover:text-gray-900 transition-colors"
data-heading="{{ heading.id }}">
{{ heading.text }}
</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
<!-- Ad Zone -->
{% if show_ads %}
<div class="bg-blue-50 rounded-lg border border-blue-100 p-4">
<p class="text-xs text-gray-500 mb-2">Advertisement</p>
<div id="docs-sidebar-ad" class="min-h-[250px]">
<!-- Ad content -->
</div>
</div>
{% endif %}
</div>
</aside>
</div>
</div>
</div>
<script>
function toggleMobileToc() {
const toc = document.getElementById('mobile-toc');
const chevron = document.getElementById('toc-chevron');
toc.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
// Scroll spy for page TOC
document.addEventListener('DOMContentLoaded', function() {
const headings = document.querySelectorAll('article h2, article h3');
const tocLinks = document.querySelectorAll('#page-toc a');
if (headings.length === 0 || tocLinks.length === 0) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocLinks.forEach(link => {
link.classList.remove('text-indigo-600', 'font-medium');
link.classList.add('text-gray-500');
if (link.dataset.heading === entry.target.id) {
link.classList.remove('text-gray-500');
link.classList.add('text-indigo-600', 'font-medium');
}
});
}
});
}, {rootMargin: '-100px 0px -66%'});
headings.forEach(heading => observer.observe(heading));
});
</script>
{% endblock %}
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Support CmdForge{% endblock %}
{% block meta_description %}Support CmdForge by contributing code, tools, or feedback.{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Hero -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<h1 class="text-3xl font-bold text-gray-900">Support CmdForge</h1>
<p class="mt-4 text-lg text-gray-600">
CmdForge is a passion project. The best way to support it is to use it and share what you build.
</p>
</div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Ways to help -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<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 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Build Tools</h2>
<p class="mt-2 text-sm text-gray-600">
Create and publish useful tools to the registry. A growing library helps everyone.
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 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="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Spread the Word</h2>
<p class="mt-2 text-sm text-gray-600">
Tell a friend or colleague who might find CmdForge useful.
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 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="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Give Feedback</h2>
<p class="mt-2 text-sm text-gray-600">
Report bugs, suggest features, or just share what you're building with it.
</p>
</div>
</section>
<!-- Main CTA -->
<section class="bg-white rounded-lg border border-gray-200 p-8 text-center">
<h2 class="text-2xl font-bold text-gray-900">Get Involved</h2>
<p class="mt-3 text-gray-600 max-w-2xl mx-auto">
CmdForge is open source. The code, docs, and registry are all open for contributions.
</p>
<div class="mt-6 flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="https://gitea.brrd.tech/rob/CmdForge"
target="_blank" rel="noopener"
class="inline-flex items-center px-6 py-3 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
View on Gitea
</a>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="inline-flex items-center px-6 py-3 text-sm font-medium text-indigo-600 border border-indigo-600 rounded-md hover:bg-indigo-50">
Publish a Tool
</a>
</div>
</section>
<!-- Thank you -->
<section class="mt-12 text-center">
<p class="text-gray-500">
Thanks for using CmdForge!
</p>
</section>
</div>
</div>
{% endblock %}
+200
View File
@@ -0,0 +1,200 @@
{% 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 %}CmdForge - 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 Link -->
<div class="mt-10">
<a href="https://gitea.brrd.tech/rob/CmdForge#installation"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 text-indigo-600 hover:text-indigo-800 font-medium transition-colors">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
View Installation Options (pip, Docker, 60-second setup)
</a>
</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-700 border-2 border-cyan-600 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 CmdForge?
</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 CmdForge 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 CmdForge Development</p>
</div>
</div>
</section>
{% endif %}
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Sign In - CmdForge{% 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 CmdForge</h1>
<p class="mt-2 text-gray-600">
Access your dashboard and manage your tools
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{{ form_errors(errors) }}
<form action="{{ url_for('web.login') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if next_url %}
<input type="hidden" name="next" value="{{ next_url }}">
{% endif %}
{{ text_input(
name='email',
label='Email address',
type='email',
placeholder='[email protected]',
required=true,
value=email or ''
) }}
{{ text_input(
name='password',
label='Password',
type='password',
placeholder='Your password',
required=true
) }}
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<input type="checkbox"
name="remember"
id="remember"
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<label for="remember" class="ml-2 text-sm text-gray-600">
Remember me
</label>
</div>
<a href="{{ url_for('web.forgot_password') }}" class="text-sm text-indigo-600 hover:text-indigo-800">
Forgot password?
</a>
</div>
{{ button_primary('Sign in', full_width=true) }}
</form>
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Don't have an account?
<a href="{{ url_for('web.register') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Create one
</a>
</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Privacy Policy - CmdForge{% endblock %}
{% block meta_description %}CmdForge 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>
CmdForge ("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: [email protected]</li>
<li>GitHub: <a href="https://github.com/your-org/cmdforge/issues">Open an issue</a></li>
</ul>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% from "components/tool_card.html" import tool_card %}
{% block title %}{{ publisher.display_name }} (@{{ publisher.slug }}) - CmdForge{% endblock %}
{% block meta_description %}{{ publisher.bio or 'CmdForge 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 %}
@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors, checkbox %}
{% block title %}Create Account - CmdForge{% 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='[email protected]',
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 %}
@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% from "components/tool_card.html" import tool_card %}
{% block title %}Search: {{ query }} - CmdForge Registry{% endblock %}
{% block meta_description %}Search results for "{{ query }}" in the CmdForge 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..."
aria-label="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 %}
+158
View File
@@ -0,0 +1,158 @@
{% extends "base.html" %}
{% block title %}Terms of Service - CmdForge{% endblock %}
{% block meta_description %}CmdForge 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 CmdForge ("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>
CmdForge is a platform for creating, publishing, and sharing AI-powered
command-line tools. The Service includes:
</p>
<ul>
<li>The CmdForge CLI application</li>
<li>The CmdForge 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 CmdForge 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 CmdForge 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>
CmdForge 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 CmdForge software is open source and licensed under the MIT License.
The CmdForge name, logo, and branding are trademarks of CmdForge.
</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 CmdForge. You use third-party
tools at your own risk.
</p>
<h2>10. Limitation of Liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, CMDFORGE 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 CmdForge 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 CmdForge
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: [email protected]</li>
<li>GitHub: <a href="https://github.com/your-org/cmdforge/issues">Open an issue</a></li>
</ul>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,284 @@
{% extends "base.html" %}
{% from "components/callouts.html" import warning, info %}
{% block title %}{{ tool.owner }}/{{ tool.name }} - CmdForge Registry{% endblock %}
{% block meta_description %}{{ tool.description or 'A CmdForge command-line tool' }}{% endblock %}
{% block og_title %}{{ tool.owner }}/{{ tool.name }}{% endblock %}
{% block og_description %}{{ tool.description or 'A CmdForge 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>cmdforge 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">cmdforge 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>
<button type="button"
onclick="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
class="mt-4 w-full inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
Report Tool
</button>
</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="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
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_owner" id="report-owner" value="{{ tool.owner }}">
<input type="hidden" name="tool_name" id="report-name" value="{{ tool.name }}">
<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="spam">Spam</option>
<option value="malicious">Malicious</option>
<option value="duplicate">Duplicate</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<textarea name="description" id="description" 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 = 'cmdforge 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);
});
}
</script>
{% endblock %}
+232
View File
@@ -0,0 +1,232 @@
{% extends "base.html" %}
{% from "components/tool_card.html" import tool_card %}
{% block title %}Browse Tools - CmdForge 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">
<button type="submit" class="absolute left-3 top-2.5" aria-label="Search">
<svg class="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>
</button>
</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-600">{{ 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-600">{{ cat.count }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
<!-- Sort -->
<div>
<label for="sort-select" class="block text-sm font-medium text-gray-700 mb-3">Sort By</label>
<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" class="flex gap-2">
<input type="text"
name="q"
value="{{ query or '' }}"
placeholder="Search tools..."
aria-label="Search tools"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700" aria-label="Search">
Search
</button>
</form>
<!-- Mobile Categories -->
<div>
<label for="mobile-category-select" class="block text-sm font-medium text-gray-700 mb-2">Category</label>
<select id="mobile-category-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 for="mobile-sort-select" class="block text-sm font-medium text-gray-700 mb-2">Sort By</label>
<select id="mobile-sort-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 %}
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% from "components/tutorial_card.html" import tutorial_card %}
{% block title %}Tutorials - CmdForge{% endblock %}
{% block meta_description %}Learn how to use CmdForge 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 CmdForge 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 CmdForge? 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 CmdForge 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 CmdForge 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 CmdForge.
</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 %}