From 2c139b3982c0535f6c80a65e1ed4521a414ddd57 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 3 Jan 2026 03:05:23 -0400 Subject: [PATCH] Add meta-tools, collections, and attribution features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta-tools: - Add ToolStep dataclass for tools calling other tools - Implement execute_tool_step() with recursion depth limiting - Add check_dependencies() for validating tool dependencies - Add 'cmdforge check' command for dependency verification Collections: - Add collections API endpoints and database schema - Create collections web UI (list and detail pages) - Add collections to navigation header and homepage - Document collections in REGISTRY.md Attribution: - Add source attribution fields to tool detail page - Document source types (original, adapted, imported) - Add license field documentation Documentation updates across README, DESIGN.md, REGISTRY.md, and new META_TOOLS.md design document. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 36 ++- docs/COLLECTIONS.md | 116 ++++++++ docs/DESIGN.md | 17 ++ docs/META_TOOLS.md | 249 ++++++++++++++++++ docs/REGISTRY.md | 127 +++++++++ src/cmdforge/cli/__init__.py | 7 +- src/cmdforge/cli/tool_commands.py | 56 +++- src/cmdforge/registry/app.py | 98 ++++++- src/cmdforge/registry/db.py | 20 ++ src/cmdforge/registry/sync.py | 47 ++++ src/cmdforge/runner.py | 163 +++++++++++- src/cmdforge/tool.py | 44 +++- src/cmdforge/web/routes.py | 33 +++ src/cmdforge/web/static/css/main.css | 2 +- .../templates/components/collection_card.html | 65 +++++ .../web/templates/components/header.html | 8 + .../templates/pages/collection_detail.html | 127 +++++++++ .../web/templates/pages/collections.html | 53 ++++ src/cmdforge/web/templates/pages/index.html | 23 ++ .../web/templates/pages/tool_detail.html | 19 ++ 20 files changed, 1299 insertions(+), 11 deletions(-) create mode 100644 docs/COLLECTIONS.md create mode 100644 docs/META_TOOLS.md create mode 100644 src/cmdforge/web/templates/components/collection_card.html create mode 100644 src/cmdforge/web/templates/pages/collection_detail.html create mode 100644 src/cmdforge/web/templates/pages/collections.html diff --git a/README.md b/README.md index 7cc2065..38e8840 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku" - **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models - **No Lock-in** - Your tools are YAML files, your prompts are yours - **Multi-step Pipelines** - Chain AI prompts with Python code for validation +- **Meta-tools** - Tools can call other tools for powerful composition - **12+ Providers Profiled** - We tested them so you don't have to ## Try It Now (60 seconds) @@ -129,6 +130,7 @@ cmdforge edit mytool # Edit in $EDITOR cmdforge delete mytool # Delete tool cmdforge run mytool # Run a tool cmdforge test mytool # Test with mock provider +cmdforge check mytool # Check dependencies (meta-tools) cmdforge refresh # Update executable wrappers ``` @@ -340,13 +342,44 @@ steps: output: "{validated}" ``` +### Meta-Tools (Tools Calling Tools) + +Tools can call other tools as steps, enabling powerful composition: + +```yaml +name: summarize-and-translate +description: Summarize text then translate the summary +dependencies: + - official/summarize + - official/translate +steps: + - type: tool + tool: official/summarize + input: "{input}" + args: + max_words: "100" + output_var: summary + + - type: tool + tool: official/translate + input: "{summary}" + args: + target_language: "{language}" + output_var: translated +output: "{translated}" +``` + +Check dependencies before running: `cmdforge check my-meta-tool` + +See [docs/META_TOOLS.md](docs/META_TOOLS.md) for full documentation. + ### Variables | Variable | Description | |----------|-------------| | `{input}` | The piped/file input | | `{argname}` | Custom argument value | -| `{step_output}` | Output from previous step | +| `{output_var}` | Output from previous step (prompt, code, or tool) | ## Shell Integration @@ -563,6 +596,7 @@ cmdforge providers test claude ### Developer Documentation - [Project Overview](docs/PROJECT.md) - Start here to understand the codebase - [Design Document](docs/DESIGN.md) +- [Meta-Tools Design](docs/META_TOOLS.md) - Tools that call other tools - [Registry API Design](docs/REGISTRY.md) - [Web UI Design](docs/WEB_UI.md) - [Deployment Guide](docs/DEPLOYMENT.md) diff --git a/docs/COLLECTIONS.md b/docs/COLLECTIONS.md new file mode 100644 index 0000000..5ba9fc3 --- /dev/null +++ b/docs/COLLECTIONS.md @@ -0,0 +1,116 @@ +# CmdForge Collections + +Collections are curated groups of tools that can be installed together with a single command. + +## Use Cases + +1. **Thematic bundles**: "writing-toolkit" with grammar, tone, simplify tools +2. **Application stacks**: "data-science" with json-extract, csv-insights, etc. +3. **Source bundles**: "fabric-text" with all Fabric text processing patterns +4. **Workflow packages**: Tools that work well together for a specific task + +## Collection Manifest Format + +Collections are defined in the registry repo under `collections/`: + +```yaml +# collections/writing-toolkit.yaml +name: writing-toolkit +display_name: Writing Toolkit +description: Essential tools for writers and editors +icon: pencil # Optional icon identifier +maintainer: official # Publisher who maintains this collection + +tools: + - official/fix-grammar + - official/simplify + - official/tone-shift + - official/expand + - official/proofread + +# Optional: version constraints +pinned: + official/fix-grammar: "^1.0.0" + +# Optional: suggested order for documentation +order: + - official/fix-grammar + - official/proofread + - official/simplify + - official/tone-shift + - official/expand + +tags: + - writing + - editing + - grammar +``` + +## CLI Usage + +```bash +# List available collections +cmdforge collections list + +# View collection details +cmdforge collections info writing-toolkit + +# Install all tools in a collection +cmdforge collections install writing-toolkit + +# Install with version constraints from collection +cmdforge collections install writing-toolkit --pinned +``` + +## API Endpoints + +``` +GET /api/v1/collections # List all collections +GET /api/v1/collections/:name # Get collection details with tool info +GET /api/v1/collections/:name/tools # Get just the tools list +``` + +## Database Schema + +```sql +CREATE TABLE collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + icon TEXT, + maintainer TEXT NOT NULL, + tools TEXT NOT NULL, -- JSON array of tool refs + pinned TEXT, -- JSON object of version constraints + tags TEXT, -- JSON array + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_collections_name ON collections(name); +CREATE INDEX idx_collections_maintainer ON collections(maintainer); +``` + +## Web UI + +- `/collections` - Browse all collections +- `/collections/:name` - Collection detail page with tool grid +- Install button that shows CLI command +- Filter by tag, maintainer + +## Sync from Registry Repo + +Collections sync from the registry repo just like tools: + +``` +CmdForge-Registry/ +├── tools/ +│ └── ... +└── collections/ + ├── writing-toolkit.yaml + ├── data-science.yaml + ├── fabric-text.yaml + └── fabric-code.yaml +``` + +The webhook sync process reads `collections/*.yaml` and upserts to database. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bcf4e38..07257fe 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -46,6 +46,11 @@ steps: provider: claude output_var: response output: "{response}" + +# Optional: declare dependencies for meta-tools +dependencies: + - official/summarize + - official/translate@^1.0.0 # With version constraint ``` ### Step Types @@ -68,6 +73,17 @@ output: "{response}" code_file: processed.py # Optional: external file storage ``` +**Tool Step** - Calls another CmdForge tool (meta-tools): +```yaml +- type: tool + tool: official/summarize # Tool reference (owner/name or just name for local) + input: "{input}" # Input to pass (supports variable substitution) + args: # Optional arguments + max_words: "100" + output_var: summary # Variable to store the tool's output + provider: claude # Optional: override the called tool's provider +``` + Steps execute in order. Each step's `output_var` becomes available to subsequent steps. ### Variables @@ -168,6 +184,7 @@ cmdforge delete sum # Delete tool cmdforge test sum # Test with mock provider cmdforge run sum # Run tool for real cmdforge refresh # Refresh all wrapper scripts +cmdforge check sum # Check dependencies for meta-tools cmdforge ui # Launch interactive UI ``` diff --git a/docs/META_TOOLS.md b/docs/META_TOOLS.md new file mode 100644 index 0000000..8766d29 --- /dev/null +++ b/docs/META_TOOLS.md @@ -0,0 +1,249 @@ +# Meta-Tools: Tools That Call Other Tools + +Meta-tools are CmdForge tools that can invoke other tools as steps in their workflow. This enables powerful composition and reuse of existing tools. + +## Step Type: `tool` + +A new step type `tool` allows calling another CmdForge tool from within a tool's workflow. + +### Manifest Format + +```yaml +name: summarize-and-translate +description: Summarize text then translate the summary +version: 1.0.0 +category: Text + +steps: + - type: tool + tool: official/summarize # Tool to call (owner/name or just name for local) + input: "{input}" # What to pass as input (supports variable substitution) + args: # Optional arguments to pass to the tool + max_words: "100" + output_var: summary # Variable to store the output + + - type: tool + tool: official/translate + input: "{summary}" # Use output from previous step + args: + target_language: "{language}" # Can use tool's own arguments + output_var: translated + +output: "{translated}" + +arguments: + - flag: "--language" + variable: language + default: "Spanish" + description: "Target language for translation" +``` + +### Resolution Order + +When resolving a tool reference in a `tool` step: + +1. **Fully qualified**: `owner/name` - looks up in registry or installed tools +2. **Local tool**: Just `name` - checks local `~/.cmdforge//config.yaml` first +3. **Registry fallback**: If not found locally, checks installed registry tools + +### Tool Step Properties + +| Property | Required | Description | +|----------|----------|-------------| +| `type` | Yes | Must be `"tool"` | +| `tool` | Yes | Tool reference (owner/name or name) | +| `input` | No | Input to pass to the tool (default: current `{input}`) | +| `args` | No | Dictionary of arguments to pass | +| `output_var` | Yes | Variable name to store the tool's output | +| `provider` | No | Override provider for the called tool | + +### Execution + +When a `tool` step executes: + +1. Resolve the tool reference to find the tool definition +2. Substitute variables in `input` and `args` values +3. Execute the tool with the resolved input and arguments +4. Capture the output in the specified `output_var` + +### Error Handling + +- If a called tool fails, the parent tool fails with an appropriate error +- Tool not found errors include helpful installation suggestions +- Circular dependencies are detected and prevented + +## Dependency Resolution + +### Declaring Dependencies + +Tools can declare their dependencies in the config: + +```yaml +name: my-meta-tool +dependencies: + - official/summarize + - official/translate@^1.0.0 # With version constraint +``` + +### CLI Integration + +```bash +# Install a tool and its dependencies +cmdforge install official/summarize-and-translate + +# Check if dependencies are satisfied +cmdforge check my-meta-tool + +# Install missing dependencies +cmdforge install --deps my-meta-tool +``` + +### Dependency Checking + +Before running a meta-tool, the runner verifies all dependencies are installed: + +```python +def check_dependencies(tool: Tool) -> List[str]: + """Returns list of missing dependencies.""" + missing = [] + for dep in tool.dependencies: + if not is_tool_installed(dep): + missing.append(dep) + return missing +``` + +## Security Considerations + +1. **Sandboxing**: Called tools run in the same process context as the parent +2. **Input validation**: All inputs are treated as untrusted strings +3. **No shell execution**: Tool references cannot contain shell commands +4. **Depth limit**: Maximum nesting depth (default: 10) prevents runaway recursion + +## Example: Multi-Step Analysis Pipeline + +```yaml +name: code-review-pipeline +description: Comprehensive code review using multiple specialized tools +version: 1.0.0 +category: Developer + +dependencies: + - official/analyze-complexity + - official/find-bugs + - official/suggest-improvements + +steps: + # Step 1: Analyze code complexity + - type: tool + tool: official/analyze-complexity + input: "{input}" + output_var: complexity_report + + # Step 2: Find potential bugs + - type: tool + tool: official/find-bugs + input: "{input}" + output_var: bug_report + + # Step 3: Generate improvement suggestions based on findings + - type: tool + tool: official/suggest-improvements + input: | + ## Code: + {input} + + ## Complexity Analysis: + {complexity_report} + + ## Bug Report: + {bug_report} + output_var: suggestions + +output: | + # Code Review Results + + ## Complexity Analysis + {complexity_report} + + ## Potential Issues + {bug_report} + + ## Improvement Suggestions + {suggestions} +``` + +## Implementation Notes + +### ToolStep Dataclass + +```python +@dataclass +class ToolStep: + """A step that calls another tool.""" + tool: str # Tool reference (owner/name or name) + output_var: str # Variable to store output + input_template: str = "{input}" # Input template + args: Dict[str, str] = field(default_factory=dict) + provider: Optional[str] = None # Provider override + + def to_dict(self) -> dict: + d = { + "type": "tool", + "tool": self.tool, + "output_var": self.output_var, + } + if self.input_template != "{input}": + d["input"] = self.input_template + if self.args: + d["args"] = self.args + if self.provider: + d["provider"] = self.provider + return d +``` + +### Execution Function + +```python +def execute_tool_step( + step: ToolStep, + variables: dict, + depth: int = 0, + max_depth: int = 10 +) -> tuple[str, bool]: + """Execute a tool step by calling another tool.""" + if depth >= max_depth: + return "", False # Prevent infinite recursion + + # Resolve the tool + try: + resolved = resolve_tool(step.tool) + except ToolNotFoundError: + print(f"Error: Tool '{step.tool}' not found", file=sys.stderr) + return "", False + + # Prepare input + input_text = substitute_variables(step.input_template, variables) + + # Prepare arguments + args = {} + for key, value in step.args.items(): + args[key] = substitute_variables(value, variables) + + # Run the tool + output, exit_code = run_tool( + tool=resolved.tool, + input_text=input_text, + custom_args=args, + provider_override=step.provider, + _depth=depth + 1 # Track nesting depth + ) + + return output, exit_code == 0 +``` + +## Future Enhancements + +- **Parallel tool execution**: Run independent tool steps concurrently +- **Conditional execution**: Skip steps based on conditions +- **Tool aliases**: Define shorthand names for frequently used tools +- **Caching**: Cache tool outputs for identical inputs diff --git a/docs/REGISTRY.md b/docs/REGISTRY.md index 9e2ead0..e3dc968 100644 --- a/docs/REGISTRY.md +++ b/docs/REGISTRY.md @@ -36,6 +36,8 @@ Core API endpoints: - `GET /api/v1/tools/{owner}/{name}/download?version=...` - `POST /api/v1/tools` (publish) - `GET /api/v1/categories` +- `GET /api/v1/collections` +- `GET /api/v1/collections/{name}` - `GET /api/v1/stats/popular` - `POST /api/v1/webhook/gitea` @@ -239,6 +241,124 @@ registry: downloads: 142 ``` +### Attribution and Source Fields + +Tools can include optional source attribution for provenance and licensing: + +```yaml +name: summarize +version: "1.2.0" +description: "Summarize text using AI" + +# Attribution fields (optional) +source: + type: original # original, adapted, or imported + license: MIT # SPDX license identifier + url: https://example.com/tool-repo + author: "Original Author" + + # For adapted/imported tools + original_tool: other/original-summarize@1.0.0 + changes: "Added French language support" +``` + +**Source types:** + +| Type | Description | +|------|-------------| +| `original` | Created from scratch by the publisher | +| `adapted` | Based on another tool with modifications | +| `imported` | Direct import of external tool (e.g., from npm/pip) | + +**License field:** +- Uses SPDX identifiers: `MIT`, `Apache-2.0`, `GPL-3.0`, etc. +- Required for registry publication +- Validated against SPDX license list + +## Collections + +Collections are curated groups of tools that can be installed together with a single command. + +### Collection Structure + +Collections are defined in `collections/{name}.yaml`: + +```yaml +name: text-processing-essentials +display_name: "Text Processing Essentials" +description: "Essential tools for text processing and manipulation" +icon: "📝" + +tools: + - official/summarize + - official/translate + - official/fix-grammar + - official/simplify + - official/tone-shift + +# Optional +curator: official +tags: ["text", "nlp", "writing"] +``` + +### Collections API + +**List all collections:** +``` +GET /api/v1/collections + +Response: +{ + "data": [ + { + "name": "text-processing-essentials", + "display_name": "Text Processing Essentials", + "description": "Essential tools for text processing...", + "icon": "📝", + "tool_count": 5, + "curator": "official" + } + ], + "meta": {"page": 1, "per_page": 20, "total": 8} +} +``` + +**Get collection details:** +``` +GET /api/v1/collections/{name} + +Response: +{ + "data": { + "name": "text-processing-essentials", + "display_name": "Text Processing Essentials", + "description": "Essential tools for text processing...", + "icon": "📝", + "curator": "official", + "tools": [ + {"owner": "official", "name": "summarize", "version": "1.2.0", ...}, + {"owner": "official", "name": "translate", "version": "2.1.0", ...} + ] + } +} +``` + +### CLI Commands + +```bash +# List available collections +cmdforge registry collections + +# View collection details +cmdforge registry collections text-processing-essentials + +# Install all tools in a collection +cmdforge registry install --collection text-processing-essentials + +# Show what would be installed (dry run) +cmdforge registry install --collection text-processing-essentials --dry-run +``` + **Schema compatibility note:** The current CmdForge config parser may reject unknown top-level keys like `deprecated`, `replacement`, and `registry`. Before implementing registry features: 1. Update the YAML parser to ignore unknown keys (permissive mode) 2. Or explicitly define these fields in the Tool dataclass with defaults @@ -1151,6 +1271,11 @@ CmdForge-Registry/ ├── categories/ │ └── categories.yaml # Category definitions │ +├── collections/ # Curated tool collections +│ ├── text-processing-essentials.yaml +│ ├── developer-toolkit.yaml +│ └── data-pipeline-basics.yaml +│ ├── index.json # Auto-generated search index │ ├── .gitea/ @@ -1736,6 +1861,8 @@ cmdforge.brrd.tech (or gitea.brrd.tech/registry) ├── /tools/{owner}/{name} # Tool detail page ├── /categories # Browse by category ├── /categories/{name} # Tools in category +├── /collections # Browse curated collections +├── /collections/{name} # Collection detail page ├── /search?q=... # Search results ├── /docs # Documentation │ ├── /docs/getting-started diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index d23678f..4b55e2e 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -7,7 +7,7 @@ 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 + cmd_ui, cmd_refresh, cmd_docs, cmd_check ) from .provider_commands import cmd_providers from .registry_commands import cmd_registry @@ -84,6 +84,11 @@ def main(): p_docs.add_argument("-e", "--edit", action="store_true", help="Edit/create README in $EDITOR") p_docs.set_defaults(func=cmd_docs) + # 'check' command + p_check = subparsers.add_parser("check", help="Check dependencies for a tool (meta-tools)") + p_check.add_argument("name", help="Tool name") + p_check.set_defaults(func=cmd_check) + # 'providers' command p_providers = subparsers.add_parser("providers", help="Manage AI providers") providers_sub = p_providers.add_subparsers(dest="providers_cmd", help="Provider commands") diff --git a/src/cmdforge/cli/tool_commands.py b/src/cmdforge/cli/tool_commands.py index 0c12116..b00bc2e 100644 --- a/src/cmdforge/cli/tool_commands.py +++ b/src/cmdforge/cli/tool_commands.py @@ -5,7 +5,7 @@ from pathlib import Path from ..tool import ( list_tools, load_tool, save_tool, delete_tool, get_tools_dir, - Tool, ToolArgument, PromptStep, CodeStep + Tool, ToolArgument, PromptStep, CodeStep, ToolStep ) from ..ui import run_ui @@ -39,6 +39,8 @@ def cmd_list(args): step_info.append(f"PROMPT[{step.provider}]") elif isinstance(step, CodeStep): step_info.append("CODE") + elif isinstance(step, ToolStep): + step_info.append(f"TOOL[{step.tool}]") print(f" Steps: {' -> '.join(step_info)}") print() @@ -331,3 +333,55 @@ echo "input" | {args.name} print(readme_path.read_text()) return 0 + + +def cmd_check(args): + """Check dependencies for a tool (meta-tools).""" + from ..runner import check_dependencies + from ..tool import ToolStep + + tool = load_tool(args.name) + if not tool: + print(f"Error: Tool '{args.name}' not found.") + return 1 + + # Check if this is a meta-tool + has_tool_steps = any(isinstance(s, ToolStep) for s in tool.steps) + has_deps = bool(tool.dependencies) + + if not has_tool_steps and not has_deps: + print(f"Tool '{args.name}' has no dependencies (not a meta-tool).") + return 0 + + print(f"Checking dependencies for '{args.name}'...") + print() + + # Show declared dependencies + if tool.dependencies: + print("Declared dependencies:") + for dep in tool.dependencies: + print(f" - {dep}") + print() + + # Show tool steps + if has_tool_steps: + print("Tool steps (implicit dependencies):") + for i, step in enumerate(tool.steps): + if isinstance(step, ToolStep): + print(f" Step {i+1}: {step.tool}") + print() + + # Check which are missing + missing = check_dependencies(tool) + + if missing: + print(f"Missing dependencies ({len(missing)}):") + for dep in missing: + print(f" - {dep}") + print() + print("Install with:") + print(f" cmdforge install {' '.join(missing)}") + return 1 + else: + print("All dependencies are satisfied.") + return 0 diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 7c579da..c38c750 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -710,6 +710,96 @@ def create_app() -> Flask: response.headers["Cache-Control"] = "max-age=3600" return response + # ─── Collections ───────────────────────────────────────────────────────────── + + @app.route("/api/v1/collections", methods=["GET"]) + def list_collections() -> Response: + """List all collections.""" + rows = query_all( + g.db, + "SELECT * FROM collections ORDER BY name", + ) + data = [] + for row in rows: + tools = json.loads(row["tools"]) if row["tools"] else [] + tags = json.loads(row["tags"]) if row["tags"] else [] + data.append({ + "name": row["name"], + "display_name": row["display_name"], + "description": row["description"], + "icon": row["icon"], + "maintainer": row["maintainer"], + "tool_count": len(tools), + "tags": tags, + }) + response = jsonify({"data": data}) + response.headers["Cache-Control"] = "max-age=3600" + return response + + @app.route("/api/v1/collections/", methods=["GET"]) + def get_collection(name: str) -> Response: + """Get collection details with tool information.""" + row = query_one(g.db, "SELECT * FROM collections WHERE name = ?", [name]) + if not row: + return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404) + + tools_refs = json.loads(row["tools"]) if row["tools"] else [] + pinned = json.loads(row["pinned"]) if row["pinned"] else {} + tags = json.loads(row["tags"]) if row["tags"] else [] + + # Fetch tool details for each tool in the collection + tools_data = [] + for ref in tools_refs: + parts = ref.split("/") + if len(parts) != 2: + continue + owner, tool_name = parts + tool_row = query_one( + g.db, + """ + SELECT * FROM tools + WHERE owner = ? AND name = ? AND version NOT LIKE '%-%' + ORDER BY id DESC LIMIT 1 + """, + [owner, tool_name], + ) + if tool_row: + tools_data.append({ + "owner": tool_row["owner"], + "name": tool_row["name"], + "version": tool_row["version"], + "description": tool_row["description"], + "category": tool_row["category"], + "downloads": tool_row["downloads"], + "pinned_version": pinned.get(ref), + }) + else: + # Tool not found in registry + tools_data.append({ + "owner": owner, + "name": tool_name, + "version": None, + "description": None, + "category": None, + "downloads": 0, + "pinned_version": pinned.get(ref), + "missing": True, + }) + + response = jsonify({ + "data": { + "name": row["name"], + "display_name": row["display_name"], + "description": row["description"], + "icon": row["icon"], + "maintainer": row["maintainer"], + "tools": tools_data, + "tags": tags, + } + }) + response.headers["Cache-Control"] = "max-age=3600" + return response + @app.route("/api/v1/stats/popular", methods=["GET"]) def popular_tools() -> Response: limit = min(int(request.args.get("limit", 10)), 50) @@ -1010,6 +1100,8 @@ def create_app() -> Flask: description = (data.get("description") or "").strip() category = (data.get("category") or "").strip() or None tags = data.get("tags") or [] + source = (data.get("source") or "").strip() or None + source_url = (data.get("source_url") or "").strip() or None if not name or not TOOL_NAME_RE.match(name) or len(name) > MAX_TOOL_NAME_LEN: return error_response("VALIDATION_ERROR", "Invalid tool name") @@ -1126,8 +1218,8 @@ def create_app() -> Flask: INSERT INTO tools ( owner, name, version, description, category, tags, config_yaml, readme, publisher_id, deprecated, deprecated_message, replacement, downloads, - scrutiny_status, scrutiny_report, published_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + scrutiny_status, scrutiny_report, source, source_url, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ owner, @@ -1145,6 +1237,8 @@ def create_app() -> Flask: 0, scrutiny_status, scrutiny_json, + source, + source_url, datetime.utcnow().isoformat(), ], ) diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index 8295da3..8ce8bad 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -51,6 +51,8 @@ CREATE TABLE IF NOT EXISTS tools ( downloads INTEGER DEFAULT 0, scrutiny_status TEXT DEFAULT 'pending', scrutiny_report TEXT, + source TEXT, + source_url TEXT, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner, name, version) ); @@ -228,6 +230,24 @@ CREATE TABLE IF NOT EXISTS pageviews ( 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); + +-- Collections (groups of tools that can be installed together) +CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + icon TEXT, + maintainer TEXT NOT NULL, + tools TEXT NOT NULL, + pinned TEXT, + tags TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_collections_name ON collections(name); +CREATE INDEX IF NOT EXISTS idx_collections_maintainer ON collections(maintainer); """ diff --git a/src/cmdforge/registry/sync.py b/src/cmdforge/registry/sync.py index 5088feb..da76af7 100644 --- a/src/cmdforge/registry/sync.py +++ b/src/cmdforge/registry/sync.py @@ -173,6 +173,52 @@ def sync_categories(repo_dir: Path) -> None: cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") +def sync_collections(conn, repo_dir: Path) -> None: + """Sync collections from registry repo to database.""" + collections_dir = repo_dir / "collections" + if not collections_dir.exists(): + return + + for collection_path in collections_dir.glob("*.yaml"): + try: + data = load_yaml(collection_path) + name = data.get("name") or collection_path.stem + display_name = data.get("display_name") or name + description = data.get("description") + icon = data.get("icon") + maintainer = data.get("maintainer") or "official" + tools = data.get("tools") or [] + pinned = data.get("pinned") or {} + tags = data.get("tags") or [] + + tools_json = json.dumps(tools) + pinned_json = json.dumps(pinned) if pinned else None + tags_json = json.dumps(tags) if tags else None + + existing = query_one(conn, "SELECT id FROM collections WHERE name = ?", [name]) + if existing: + conn.execute( + """ + UPDATE collections + SET display_name = ?, description = ?, icon = ?, maintainer = ?, + tools = ?, pinned = ?, tags = ?, updated_at = ? + WHERE id = ? + """, + [display_name, description, icon, maintainer, tools_json, + pinned_json, tags_json, datetime.utcnow().isoformat(), existing["id"]], + ) + else: + conn.execute( + """ + INSERT INTO collections (name, display_name, description, icon, maintainer, tools, pinned, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + [name, display_name, description, icon, maintainer, tools_json, pinned_json, tags_json], + ) + except Exception: + continue + + def sync_from_repo() -> Tuple[bool, str]: repo_dir = get_repo_dir() clone_or_update_repo(repo_dir) @@ -195,6 +241,7 @@ def sync_from_repo() -> Tuple[bool, str]: upsert_tool(conn, owner, name, data, config_text, readme_text) except Exception: continue + sync_collections(conn, repo_dir) conn.commit() finally: conn.close() diff --git a/src/cmdforge/runner.py b/src/cmdforge/runner.py index 26249db..e9f0958 100644 --- a/src/cmdforge/runner.py +++ b/src/cmdforge/runner.py @@ -5,11 +5,63 @@ import sys from pathlib import Path from typing import Optional -from .tool import Tool, PromptStep, CodeStep +from .tool import Tool, PromptStep, CodeStep, ToolStep from .providers import call_provider, mock_provider from .resolver import resolve_tool, ToolNotFoundError, ToolSpec from .manifest import load_manifest +# Maximum recursion depth for nested tool calls +MAX_TOOL_DEPTH = 10 + + +def check_dependencies(tool: Tool, checked: set = None) -> list[str]: + """ + Check if all dependencies for a tool are available. + + Args: + tool: Tool to check dependencies for + checked: Set of already checked tools (prevents infinite loops) + + Returns: + List of missing dependency tool references + """ + if checked is None: + checked = set() + + missing = [] + + # Check explicit dependencies + for dep in tool.dependencies: + if dep in checked: + continue + checked.add(dep) + + try: + resolved = resolve_tool(dep) + # Recursively check nested dependencies + nested_missing = check_dependencies(resolved.tool, checked) + missing.extend(nested_missing) + except ToolNotFoundError: + missing.append(dep) + + # Also check tool steps for implicit dependencies + for step in tool.steps: + if isinstance(step, ToolStep): + tool_ref = step.tool + if tool_ref in checked: + continue + checked.add(tool_ref) + + try: + resolved = resolve_tool(tool_ref) + # Recursively check nested dependencies + nested_missing = check_dependencies(resolved.tool, checked) + missing.extend(nested_missing) + except ToolNotFoundError: + missing.append(tool_ref) + + return missing + def substitute_variables(template: str, variables: dict) -> str: """ @@ -111,6 +163,75 @@ def execute_code_step(step: CodeStep, variables: dict) -> tuple[dict, bool]: return {}, False +def execute_tool_step( + step: ToolStep, + variables: dict, + depth: int = 0, + provider_override: Optional[str] = None, + dry_run: bool = False, + verbose: bool = False +) -> tuple[str, bool]: + """ + Execute a tool step by calling another tool. + + Args: + step: The tool step to execute + variables: Current variable values + depth: Current recursion depth + provider_override: Override provider for nested calls + dry_run: Just show what would happen + verbose: Show debug info + + Returns: + Tuple of (output_value, success) + """ + if depth >= MAX_TOOL_DEPTH: + print(f"Error: Maximum tool nesting depth ({MAX_TOOL_DEPTH}) exceeded", file=sys.stderr) + return "", False + + # Resolve the tool reference + try: + resolved = resolve_tool(step.tool) + nested_tool = resolved.tool + except ToolNotFoundError as e: + print(f"Error: Tool '{step.tool}' not found", file=sys.stderr) + print(f"Hint: Install it with: cmdforge install {step.tool}", file=sys.stderr) + return "", False + + # Prepare input by substituting variables + input_text = substitute_variables(step.input_template, variables) + + # Prepare arguments by substituting variables in arg values + custom_args = {} + for key, value in step.args.items(): + custom_args[key] = substitute_variables(str(value), variables) + + # Determine effective provider (step override > parent override) + effective_provider = step.provider or provider_override + + if verbose: + print(f"[verbose] Tool step: calling {step.tool}", file=sys.stderr) + print(f"[verbose] Input length: {len(input_text)} chars", file=sys.stderr) + print(f"[verbose] Args: {list(custom_args.keys())}", file=sys.stderr) + + if dry_run: + return f"[DRY RUN - would call tool {step.tool}]", True + + # Run the nested tool + output, exit_code = run_tool( + tool=nested_tool, + input_text=input_text, + custom_args=custom_args, + provider_override=effective_provider, + dry_run=dry_run, + show_prompt=False, + verbose=verbose, + _depth=depth + 1 # Track nesting depth + ) + + return output, exit_code == 0 + + def run_tool( tool: Tool, input_text: str, @@ -118,7 +239,8 @@ def run_tool( provider_override: Optional[str] = None, dry_run: bool = False, show_prompt: bool = False, - verbose: bool = False + verbose: bool = False, + _depth: int = 0 # Internal: tracks nesting depth for tool steps ) -> tuple[str, int]: """ Execute a tool. @@ -131,10 +253,19 @@ def run_tool( dry_run: Just show what would happen show_prompt: Show prompts in addition to output verbose: Show debug info + _depth: Internal recursion depth tracker Returns: Tuple of (output_text, exit_code) """ + # Check dependencies on first level only (not for nested calls) + if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)): + missing = check_dependencies(tool) + if missing: + print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr) + print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr) + # Continue anyway - the actual step execution will fail with a better error + # Initialize variables with input and arguments variables = {"input": input_text} @@ -156,7 +287,14 @@ def run_tool( # Execute each step for i, step in enumerate(tool.steps): if verbose: - step_type = "PROMPT" if isinstance(step, PromptStep) else "CODE" + if isinstance(step, PromptStep): + step_type = "PROMPT" + elif isinstance(step, CodeStep): + step_type = "CODE" + elif isinstance(step, ToolStep): + step_type = f"TOOL({step.tool})" + else: + step_type = "UNKNOWN" print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr) if isinstance(step, PromptStep): @@ -192,6 +330,25 @@ def run_tool( # Merge all output vars into variables variables.update(outputs) + elif isinstance(step, ToolStep): + if verbose or dry_run: + print(f"=== TOOL (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr) + print(f" Calling: {step.tool}", file=sys.stderr) + print(f" Args: {step.args}", file=sys.stderr) + print("=== END TOOL ===", file=sys.stderr) + + output, success = execute_tool_step( + step, + variables, + depth=_depth, + provider_override=provider_override, + dry_run=dry_run, + verbose=verbose + ) + if not success: + return "", 3 + variables[step.output_var] = output + # Generate final output output = substitute_variables(tool.output, variables) diff --git a/src/cmdforge/tool.py b/src/cmdforge/tool.py index 618a6f5..c9ab30e 100644 --- a/src/cmdforge/tool.py +++ b/src/cmdforge/tool.py @@ -97,7 +97,41 @@ class CodeStep: ) -Step = PromptStep | CodeStep +@dataclass +class ToolStep: + """A step that calls another tool.""" + tool: str # Tool reference (owner/name or just name) + output_var: str # Variable to store output + input_template: str = "{input}" # Input template (supports variable substitution) + args: dict = field(default_factory=dict) # Arguments to pass to the tool + provider: Optional[str] = None # Provider override for the called tool + + def to_dict(self) -> dict: + d = { + "type": "tool", + "tool": self.tool, + "output_var": self.output_var, + } + if self.input_template != "{input}": + d["input"] = self.input_template + if self.args: + d["args"] = self.args + if self.provider: + d["provider"] = self.provider + return d + + @classmethod + def from_dict(cls, data: dict) -> "ToolStep": + return cls( + tool=data["tool"], + output_var=data["output_var"], + input_template=data.get("input", "{input}"), + args=data.get("args", {}), + provider=data.get("provider") + ) + + +Step = PromptStep | CodeStep | ToolStep # Default categories for organizing tools @@ -113,6 +147,7 @@ class Tool: arguments: List[ToolArgument] = field(default_factory=list) steps: List[Step] = field(default_factory=list) output: str = "{input}" # Output template + dependencies: List[str] = field(default_factory=list) # Required tools for meta-tools @classmethod def from_dict(cls, data: dict) -> "Tool": @@ -126,6 +161,8 @@ class Tool: steps.append(PromptStep.from_dict(step)) elif step.get("type") == "code": steps.append(CodeStep.from_dict(step)) + elif step.get("type") == "tool": + steps.append(ToolStep.from_dict(step)) return cls( name=data["name"], @@ -133,7 +170,8 @@ class Tool: category=data.get("category", "Other"), arguments=arguments, steps=steps, - output=data.get("output", "{input}") + output=data.get("output", "{input}"), + dependencies=data.get("dependencies", []) ) def to_dict(self) -> dict: @@ -144,6 +182,8 @@ class Tool: # Only include category if it's not the default if self.category and self.category != "Other": d["category"] = self.category + if self.dependencies: + d["dependencies"] = self.dependencies d["arguments"] = [arg.to_dict() for arg in self.arguments] d["steps"] = [step.to_dict() for step in self.steps] d["output"] = self.output diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index 3d12640..ddfef69 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -644,6 +644,7 @@ def sitemap(): static_pages = [ ("/", "1.0", "daily"), ("/tools", "0.9", "daily"), + ("/collections", "0.8", "weekly"), ("/docs", "0.8", "weekly"), ("/docs/getting-started", "0.8", "weekly"), ("/docs/installation", "0.7", "weekly"), @@ -744,3 +745,35 @@ def dashboard_undeprecate_tool(owner: str, name: str): data = request.get_json() or {} status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/undeprecate", data=data, token=token) return jsonify(payload), status + + +# ============================================ +# Collections Routes +# ============================================ + +@web_bp.route("/collections", endpoint="collections") +def collections(): + """List all available collections.""" + status, payload = _api_get("/api/v1/collections") + if status != 200: + return render_template("errors/500.html"), 500 + return render_template( + "pages/collections.html", + collections=payload.get("data", []), + ) + + +@web_bp.route("/collections/", endpoint="collection_detail") +def collection_detail(name: str): + """Show a specific collection with its tools.""" + status, payload = _api_get(f"/api/v1/collections/{name}") + if status == 404: + return render_template("errors/404.html"), 404 + if status != 200: + return render_template("errors/500.html"), 500 + + collection = payload.get("data", {}) + return render_template( + "pages/collection_detail.html", + collection=collection, + ) diff --git a/src/cmdforge/web/static/css/main.css b/src/cmdforge/web/static/css/main.css index 72fd02c..388eb9c 100644 --- a/src/cmdforge/web/static/css/main.css +++ b/src/cmdforge/web/static/css/main.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-2{top:.5rem}.top-2\.5{top:.625rem}.top-3{top:.75rem}.top-4{top:1rem}.z-40{z-index:40}.z-50{z-index:50}.col-span-1{grid-column:span 1/span 1}.col-span-full{grid-column:1/-1}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-24{height:6rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-96{max-height:24rem}.max-h-\[calc\(100vh-6rem\)\]{max-height:calc(100vh - 6rem)}.min-h-\[250px\]{min-height:250px}.min-h-\[60vh\]{min-height:60vh}.min-h-\[70vh\]{min-height:70vh}.min-h-\[90px\]{min-height:90px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-24{width:6rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.rotate-180,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.resize-y{resize:vertical}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b-lg{border-bottom-right-radius:.5rem}.rounded-b-lg,.rounded-l-lg{border-bottom-left-radius:.5rem}.rounded-l-lg{border-top-left-radius:.5rem}.rounded-r-lg{border-bottom-right-radius:.5rem}.rounded-r-lg,.rounded-t-lg{border-top-right-radius:.5rem}.rounded-t-lg{border-top-left-radius:.5rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l-4{border-left-width:4px}.border-r-0{border-right-width:0}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-blue-100{--tw-border-opacity:1;border-color:rgb(219 234 254/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-cyan-600{--tw-border-opacity:1;border-color:rgb(8 145 178/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-indigo-600{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity,1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-slate-700{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-indigo-500{--tw-gradient-from:#6366f1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(99,102,241,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-cyan-500{--tw-gradient-to:#06b6d4 var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-12{padding-left:3rem}.pr-4{padding-right:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace}.font-sans{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-tight{line-height:1.25}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-cyan-600{--tw-text-opacity:1;color:rgb(8 145 178/var(--tw-text-opacity,1))}.text-cyan-700{--tw-text-opacity:1;color:rgb(14 116 144/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.btn-primary{border-radius:.375rem;--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-primary:hover{opacity:.9}.btn-secondary{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.btn-secondary:hover{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.card{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.prose{line-height:1.625;--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.prose p{margin-bottom:1rem}.prose p.lead{margin-bottom:1.5rem;font-size:1.25rem;line-height:1.75rem;--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.prose h2{margin-top:2.5rem;margin-bottom:1rem;font-size:1.5rem;line-height:2rem;font-weight:700}.prose h2,.prose h3{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.prose h3{margin-top:2rem;margin-bottom:.75rem;font-size:1.25rem}.prose h3,.prose h4{line-height:1.75rem;font-weight:600}.prose h4{margin-top:1.5rem;margin-bottom:.5rem;font-size:1.125rem;--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.prose ol,.prose ul{margin-bottom:1rem;padding-left:1.5rem}.prose ul{list-style-type:disc}.prose ol{list-style-type:decimal}.prose li{margin-bottom:.5rem}.prose pre{margin-bottom:1rem;overflow-x:auto;border-radius:.5rem;background-color:rgb(17 24 39/var(--tw-bg-opacity,1));padding:1rem;color:rgb(243 244 246/var(--tw-text-opacity,1))}.prose code,.prose pre{--tw-bg-opacity:1;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1}.prose code{border-radius:.25rem;background-color:rgb(243 244 246/var(--tw-bg-opacity,1));padding:.125rem .375rem;font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace;color:rgb(79 70 229/var(--tw-text-opacity,1))}.prose pre code{background-color:transparent;padding:0;--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.prose a{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1));text-decoration-line:underline}.prose a:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.prose table{margin-bottom:1rem;width:100%;border-collapse:collapse}.prose td,.prose th{border-width:1px;--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1));padding:.5rem 1rem;text-align:left}.prose th{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1));font-weight:600}.prose blockquote{margin-top:1rem;margin-bottom:1rem;border-left-width:4px;--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1));padding-left:1rem;font-style:italic;--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.prose details{margin-bottom:1rem}.prose details summary{cursor:pointer;font-weight:500}.prose details[open] summary{margin-bottom:.5rem}.prose hr{margin-top:2rem;margin-bottom:2rem;--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.hover\:border-indigo-500:hover{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-cyan-50:hover{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-50:hover{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-slate-700:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.hover\:text-amber-600:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-400:hover{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.hover\:text-indigo-600:hover{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.hover\:text-indigo-800:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.hover\:text-indigo-900:hover{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-red-900:hover{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-lg:hover,.hover\:shadow-md:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.focus\:not-sr-only:focus{position:static;width:auto;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal}.focus\:absolute:focus{position:absolute}.focus\:left-4:focus{left:1rem}.focus\:top-4:focus{top:1rem}.focus\:border-indigo-500:focus{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-cyan-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(6 182 212/var(--tw-ring-opacity,1))}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity,1))}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:mt-0{margin-top:0}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:py-24{padding-top:6rem;padding-bottom:6rem}.md\:text-5xl{font-size:3rem;line-height:1}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:mt-0{margin-top:0}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:gap-8{gap:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-2{top:.5rem}.top-2\.5{top:.625rem}.top-3{top:.75rem}.top-4{top:1rem}.z-40{z-index:40}.z-50{z-index:50}.col-span-1{grid-column:span 1/span 1}.col-span-full{grid-column:1/-1}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-24{height:6rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-96{max-height:24rem}.max-h-\[calc\(100vh-6rem\)\]{max-height:calc(100vh - 6rem)}.min-h-\[250px\]{min-height:250px}.min-h-\[60vh\]{min-height:60vh}.min-h-\[70vh\]{min-height:70vh}.min-h-\[90px\]{min-height:90px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-24{width:6rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.rotate-180,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.resize-y{resize:vertical}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b-lg{border-bottom-right-radius:.5rem}.rounded-b-lg,.rounded-l-lg{border-bottom-left-radius:.5rem}.rounded-l-lg{border-top-left-radius:.5rem}.rounded-r-lg{border-bottom-right-radius:.5rem}.rounded-r-lg,.rounded-t-lg{border-top-right-radius:.5rem}.rounded-t-lg{border-top-left-radius:.5rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l-4{border-left-width:4px}.border-r-0{border-right-width:0}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-blue-100{--tw-border-opacity:1;border-color:rgb(219 234 254/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-cyan-600{--tw-border-opacity:1;border-color:rgb(8 145 178/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-indigo-600{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity,1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-slate-700{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-indigo-500{--tw-gradient-from:#6366f1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(99,102,241,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-cyan-500{--tw-gradient-to:#06b6d4 var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-12{padding-left:3rem}.pr-4{padding-right:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace}.font-sans{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-tight{line-height:1.25}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-cyan-600{--tw-text-opacity:1;color:rgb(8 145 178/var(--tw-text-opacity,1))}.text-cyan-700{--tw-text-opacity:1;color:rgb(14 116 144/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-100{--tw-text-opacity:1;color:rgb(224 231 255/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-green-500{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.btn-primary{border-radius:.375rem;--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-primary:hover{opacity:.9}.btn-secondary{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.btn-secondary:hover{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.card{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.prose{line-height:1.625;--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.prose p{margin-bottom:1rem}.prose p.lead{margin-bottom:1.5rem;font-size:1.25rem;line-height:1.75rem;--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.prose h2{margin-top:2.5rem;margin-bottom:1rem;font-size:1.5rem;line-height:2rem;font-weight:700}.prose h2,.prose h3{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.prose h3{margin-top:2rem;margin-bottom:.75rem;font-size:1.25rem}.prose h3,.prose h4{line-height:1.75rem;font-weight:600}.prose h4{margin-top:1.5rem;margin-bottom:.5rem;font-size:1.125rem;--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.prose ol,.prose ul{margin-bottom:1rem;padding-left:1.5rem}.prose ul{list-style-type:disc}.prose ol{list-style-type:decimal}.prose li{margin-bottom:.5rem}.prose pre{margin-bottom:1rem;overflow-x:auto;border-radius:.5rem;background-color:rgb(17 24 39/var(--tw-bg-opacity,1));padding:1rem;color:rgb(243 244 246/var(--tw-text-opacity,1))}.prose code,.prose pre{--tw-bg-opacity:1;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1}.prose code{border-radius:.25rem;background-color:rgb(243 244 246/var(--tw-bg-opacity,1));padding:.125rem .375rem;font-family:JetBrains Mono,ui-monospace,Cascadia Code,monospace;color:rgb(79 70 229/var(--tw-text-opacity,1))}.prose pre code{background-color:transparent;padding:0;--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.prose a{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1));text-decoration-line:underline}.prose a:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.prose table{margin-bottom:1rem;width:100%;border-collapse:collapse}.prose td,.prose th{border-width:1px;--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1));padding:.5rem 1rem;text-align:left}.prose th{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1));font-weight:600}.prose blockquote{margin-top:1rem;margin-bottom:1rem;border-left-width:4px;--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1));padding-left:1rem;font-style:italic;--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.prose details{margin-bottom:1rem}.prose details summary{cursor:pointer;font-weight:500}.prose details[open] summary{margin-bottom:.5rem}.prose hr{margin-top:2rem;margin-bottom:2rem;--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.hover\:border-indigo-300:hover{--tw-border-opacity:1;border-color:rgb(165 180 252/var(--tw-border-opacity,1))}.hover\:border-indigo-500:hover{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-cyan-50:hover{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-50:hover{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-slate-700:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.hover\:text-amber-600:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-400:hover{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.hover\:text-indigo-600:hover{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.hover\:text-indigo-800:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.hover\:text-indigo-900:hover{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-red-900:hover{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-lg:hover,.hover\:shadow-md:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.hover\:shadow-sm:hover{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:not-sr-only:focus{position:static;width:auto;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal}.focus\:absolute:focus{position:absolute}.focus\:left-4:focus{left:1rem}.focus\:top-4:focus{top:1rem}.focus\:border-indigo-500:focus{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-cyan-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(6 182 212/var(--tw-ring-opacity,1))}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity,1))}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:mt-0{margin-top:0}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:py-24{padding-top:6rem;padding-bottom:6rem}.md\:text-5xl{font-size:3rem;line-height:1}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:mt-0{margin-top:0}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:gap-8{gap:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file diff --git a/src/cmdforge/web/templates/components/collection_card.html b/src/cmdforge/web/templates/components/collection_card.html new file mode 100644 index 0000000..7b1371f --- /dev/null +++ b/src/cmdforge/web/templates/components/collection_card.html @@ -0,0 +1,65 @@ +{# Collection card macro #} +{% macro collection_card(collection) %} +
+ +
+
+ {% if collection.icon %} + {{ collection.icon }} + {% else %} + + + + {% endif %} +
+
+

+ + {{ collection.display_name }} + +

+

by {{ collection.maintainer }}

+
+
+ + +

+ {{ collection.description or 'A curated collection of tools.' }} +

+ + +
+ + + + + {{ collection.tool_count }} tools + + {% if collection.tags %} +
+ {% for tag in collection.tags[:3] %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+ + +
+
+ + cmdforge install --collection {{ collection.name }} + + +
+
+
+{% endmacro %} diff --git a/src/cmdforge/web/templates/components/header.html b/src/cmdforge/web/templates/components/header.html index 669a003..1d6aa66 100644 --- a/src/cmdforge/web/templates/components/header.html +++ b/src/cmdforge/web/templates/components/header.html @@ -22,6 +22,10 @@ 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 + + Collections + Forum @@ -119,6 +123,10 @@ 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 + + Collections + Forum diff --git a/src/cmdforge/web/templates/pages/collection_detail.html b/src/cmdforge/web/templates/pages/collection_detail.html new file mode 100644 index 0000000..c661994 --- /dev/null +++ b/src/cmdforge/web/templates/pages/collection_detail.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} +{% from "components/tool_card.html" import tool_card %} + +{% block title %}{{ collection.display_name }} - CmdForge Collections{% endblock %} + +{% block meta_description %}{{ collection.description or 'A curated collection of AI-powered CLI tools.' }}{% endblock %} + +{% block content %} +
+ +
+
+ + + +
+ +
+ {% if collection.icon %} + {{ collection.icon }} + {% else %} + + + + {% endif %} +
+ +
+

{{ collection.display_name }}

+

+ Curated by {{ collection.maintainer }} +

+ {% if collection.description %} +

{{ collection.description }}

+ {% endif %} +
+
+ + + {% if collection.tags %} +
+ {% for tag in collection.tags %} + {{ tag }} + {% endfor %} +
+ {% endif %} + + +
+
+ cmdforge install --collection {{ collection.name }} + +
+

+ Installs {{ collection.tools|length if collection.tools else 0 }} tool{{ 's' if collection.tools|length != 1 else '' }} with a single command +

+
+
+
+ +
+ +
+

+ Tools in this Collection +

+

+ {{ collection.tools|length if collection.tools else 0 }} tool{{ 's' if collection.tools|length != 1 else '' }} included +

+
+ + {% if collection.tools %} +
+ {% for tool in collection.tools %} + {% if tool.owner and tool.name %} + {{ tool_card(tool) }} + {% else %} + +
+
+
+ + + +
+
+

{{ tool }}

+

Tool not yet in registry

+
+
+
+ {% endif %} + {% endfor %} +
+ {% else %} +
+ + + +

No tools in this collection

+

+ This collection doesn't have any tools yet. +

+
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/src/cmdforge/web/templates/pages/collections.html b/src/cmdforge/web/templates/pages/collections.html new file mode 100644 index 0000000..bbd80a5 --- /dev/null +++ b/src/cmdforge/web/templates/pages/collections.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% from "components/collection_card.html" import collection_card %} + +{% block title %}Collections - CmdForge Registry{% endblock %} + +{% block meta_description %}Browse curated collections of AI tools. Install multiple tools at once with a single command.{% endblock %} + +{% block content %} +
+ +
+
+

Collections

+

+ Curated groups of tools that work great together. Install an entire collection with a single command. +

+
+
+ +
+ {% if collections %} + +
+

+ {{ collections|length }} collection{{ 's' if collections|length != 1 else '' }} available +

+
+ + +
+ {% for collection in collections %} + {{ collection_card(collection) }} + {% endfor %} +
+ {% else %} + +
+ + + +

No collections yet

+

+ Collections are coming soon! They'll let you install groups of related tools together. +

+ + Browse Individual Tools + +
+ {% endif %} +
+
+{% endblock %} diff --git a/src/cmdforge/web/templates/pages/index.html b/src/cmdforge/web/templates/pages/index.html index 4e5204f..48de8eb 100644 --- a/src/cmdforge/web/templates/pages/index.html +++ b/src/cmdforge/web/templates/pages/index.html @@ -131,6 +131,29 @@ {% endfor %} + + +
+
+
+
+ + + +
+
+

Tool Collections

+

Install curated groups of tools with a single command

+
+
+ + Browse Collections + + + + +
+
diff --git a/src/cmdforge/web/templates/pages/tool_detail.html b/src/cmdforge/web/templates/pages/tool_detail.html index ab11fcb..8ffc37a 100644 --- a/src/cmdforge/web/templates/pages/tool_detail.html +++ b/src/cmdforge/web/templates/pages/tool_detail.html @@ -65,6 +65,25 @@ {% endfor %} {% endif %} + + {% if tool.source %} +
+
+ + + + + Based on + {% if tool.source_url %} + {{ tool.source }} + {% else %} + {{ tool.source }} + {% endif %} + pattern + +
+
+ {% endif %}