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:
@@ -0,0 +1,3 @@
|
||||
"""CmdForge - A lightweight personal tool builder for AI-powered CLI commands."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Allow running the CLI as a module."""
|
||||
|
||||
import sys
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Registry API server package."""
|
||||
|
||||
__all__ = ["app"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
"""Category suggestion helpers for registry tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_categories(categories_path: Path) -> List[Dict]:
|
||||
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
|
||||
return data.get("categories", [])
|
||||
|
||||
|
||||
def suggest_categories(
|
||||
name: str,
|
||||
description: str,
|
||||
tags: List[str],
|
||||
categories_path: Path,
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""Suggest categories ranked by confidence.
|
||||
|
||||
Uses keyword matching against name/description/tags.
|
||||
Returns a list of (category_name, confidence).
|
||||
"""
|
||||
categories = load_categories(categories_path)
|
||||
text = f"{name} {description} {' '.join(tags)}".lower()
|
||||
suggestions: List[Tuple[str, float]] = []
|
||||
|
||||
for cat in categories:
|
||||
cat_name = cat.get("name")
|
||||
keywords = [str(k).lower() for k in cat.get("keywords", []) if k]
|
||||
if not cat_name or not keywords:
|
||||
continue
|
||||
hits = sum(1 for k in keywords if k in text)
|
||||
confidence = hits / max(len(keywords), 1)
|
||||
if hits:
|
||||
suggestions.append((cat_name, round(confidence, 3)))
|
||||
|
||||
suggestions.sort(key=lambda item: item[1], reverse=True)
|
||||
return suggestions
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""In-memory rate limiting for the registry API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitState:
|
||||
reset_at: float
|
||||
count: int
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self) -> None:
|
||||
self._state: Dict[Tuple[str, str], RateLimitState] = {}
|
||||
|
||||
def check(self, scope_key: str, limit: int, window_seconds: int) -> Tuple[bool, RateLimitState]:
|
||||
now = time.time()
|
||||
key = (scope_key, str(window_seconds))
|
||||
state = self._state.get(key)
|
||||
if not state or now >= state.reset_at:
|
||||
state = RateLimitState(reset_at=now + window_seconds, count=0)
|
||||
self._state[key] = state
|
||||
state.count += 1
|
||||
allowed = state.count <= limit
|
||||
return allowed, state
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Similarity detection for registry tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
def _tokenize(text: str) -> List[str]:
|
||||
return [t for t in re_split_nonword(text.lower()) if t]
|
||||
|
||||
|
||||
def re_split_nonword(text: str) -> List[str]:
|
||||
token = ""
|
||||
tokens = []
|
||||
for ch in text:
|
||||
if ch.isalnum():
|
||||
token += ch
|
||||
else:
|
||||
if token:
|
||||
tokens.append(token)
|
||||
token = ""
|
||||
if token:
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def jaccard(a: List[str], b: List[str]) -> float:
|
||||
set_a = set(a)
|
||||
set_b = set(b)
|
||||
if not set_a and not set_b:
|
||||
return 0.0
|
||||
return len(set_a & set_b) / max(len(set_a | set_b), 1)
|
||||
|
||||
|
||||
def name_similarity(name_a: str, name_b: str) -> float:
|
||||
return SequenceMatcher(None, name_a.lower(), name_b.lower()).ratio()
|
||||
|
||||
|
||||
def description_similarity(desc_a: str, desc_b: str) -> float:
|
||||
return jaccard(_tokenize(desc_a), _tokenize(desc_b))
|
||||
|
||||
|
||||
def tags_similarity(tags_a: List[str], tags_b: List[str]) -> float:
|
||||
return jaccard([t.lower() for t in tags_a], [t.lower() for t in tags_b])
|
||||
|
||||
|
||||
def score_similarity(
|
||||
candidate: Dict,
|
||||
name: str,
|
||||
description: str,
|
||||
tags: List[str],
|
||||
category: str | None,
|
||||
) -> float:
|
||||
name_score = name_similarity(name, candidate.get("name", ""))
|
||||
desc_score = description_similarity(description, candidate.get("description", ""))
|
||||
tags_score = tags_similarity(tags, candidate.get("tags", []))
|
||||
category_bonus = 0.1 if category and candidate.get("category") == category else 0.0
|
||||
score = (0.5 * name_score) + (0.3 * desc_score) + (0.2 * tags_score) + category_bonus
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
def find_similar_tools(
|
||||
tools: List[Dict],
|
||||
name: str,
|
||||
description: str,
|
||||
tags: List[str],
|
||||
category: str | None,
|
||||
threshold: float = 0.6,
|
||||
) -> List[Tuple[Dict, float]]:
|
||||
results = []
|
||||
for tool in tools:
|
||||
score = score_similarity(tool, name, description, tags, category)
|
||||
if score >= threshold:
|
||||
results.append((tool, round(score, 3)))
|
||||
results.sort(key=lambda item: item[1], reverse=True)
|
||||
return results
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Registry repository sync and webhook processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from .db import connect_db, query_one
|
||||
|
||||
|
||||
def get_repo_dir() -> Path:
|
||||
default_dir = Path.home() / ".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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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, ""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
"""Allow running the UI as a module."""
|
||||
|
||||
from . import run_ui
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_ui()
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)))
|
||||
@@ -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
@@ -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("> "): # 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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Forum blueprint for CmdForge community discussions."""
|
||||
|
||||
from .routes import forum_bp
|
||||
|
||||
__all__ = ["forum_bp"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]))
|
||||
@@ -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
|
||||
@@ -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>",
|
||||
])
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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!
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
© {{ 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 }} · {{ 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 %}
|
||||
@@ -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 }} · {{ 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 %}
|
||||
@@ -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 · {{ 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user