Add meta-tools, collections, and attribution features

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 <[email protected]>
This commit is contained in:
rob
2026-01-03 03:05:23 -04:00
co-authored by Claude Opus 4.5
parent 497fe87fc5
commit 2c139b3982
20 changed files with 1299 additions and 11 deletions
+6 -1
View File
@@ -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")
+55 -1
View File
@@ -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
+96 -2
View File
@@ -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/<name>", 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(),
],
)
+20
View File
@@ -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);
"""
+47
View File
@@ -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()
+160 -3
View File
@@ -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)
+42 -2
View File
@@ -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
+33
View File
@@ -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/<name>", 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,
)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
{# Collection card macro #}
{% macro collection_card(collection) %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-5">
<!-- Collection header -->
<div class="flex items-start space-x-4">
<div class="flex-shrink-0 w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
{% if collection.icon %}
<span class="text-2xl">{{ collection.icon }}</span>
{% else %}
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900">
<a href="{{ url_for('web.collection_detail', name=collection.name) }}"
class="hover:text-purple-600 transition-colors">
{{ collection.display_name }}
</a>
</h3>
<p class="text-sm text-gray-500">by {{ collection.maintainer }}</p>
</div>
</div>
<!-- Description -->
<p class="mt-3 text-sm text-gray-600 line-clamp-2">
{{ collection.description or 'A curated collection of tools.' }}
</p>
<!-- Meta info -->
<div class="mt-4 flex items-center justify-between text-sm">
<span class="flex items-center text-purple-600">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{{ collection.tool_count }} tools
</span>
{% if collection.tags %}
<div class="flex flex-wrap gap-1">
{% for tag in collection.tags[:3] %}
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Install command -->
<div class="mt-4">
<div class="flex items-center bg-gray-100 rounded px-3 py-2 group">
<code class="text-xs text-gray-700 flex-1 truncate font-mono">
cmdforge install --collection {{ collection.name }}
</code>
<button type="button"
onclick="copyToClipboard('cmdforge install --collection {{ collection.name }}')"
class="ml-2 p-1 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy install command">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
</article>
{% endmacro %}
@@ -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
</a>
<a href="{{ url_for('web.collections') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/collections') %}bg-slate-700{% endif %}">
Collections
</a>
<a href="{{ url_for('forum.index') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/forum') %}bg-slate-700{% endif %}">
Forum
@@ -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
</a>
<a href="{{ url_for('web.collections') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/collections') %}bg-slate-700{% endif %}">
Collections
</a>
<a href="{{ url_for('forum.index') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/forum') %}bg-slate-700{% endif %}">
Forum
@@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Collection Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<nav class="mb-4 text-sm text-gray-500">
<a href="{{ url_for('web.collections') }}" class="hover:text-indigo-600">Collections</a>
<span class="mx-2">/</span>
<span class="text-gray-900">{{ collection.display_name }}</span>
</nav>
<div class="flex items-start space-x-4">
<!-- Collection Icon -->
<div class="flex-shrink-0 w-16 h-16 bg-purple-100 rounded-lg flex items-center justify-center">
{% if collection.icon %}
<span class="text-3xl">{{ collection.icon }}</span>
{% else %}
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
{% endif %}
</div>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900">{{ collection.display_name }}</h1>
<p class="mt-1 text-gray-500">
Curated by <span class="font-medium text-gray-700">{{ collection.maintainer }}</span>
</p>
{% if collection.description %}
<p class="mt-3 text-gray-600">{{ collection.description }}</p>
{% endif %}
</div>
</div>
<!-- Tags -->
{% if collection.tags %}
<div class="mt-4 flex flex-wrap gap-2">
{% for tag in collection.tags %}
<span class="px-3 py-1 bg-purple-100 text-purple-700 text-sm rounded-full">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Install Command -->
<div class="mt-6 p-4 bg-gray-900 rounded-lg">
<div class="flex items-center justify-between">
<code class="text-green-400 font-mono">cmdforge install --collection {{ collection.name }}</code>
<button type="button"
onclick="copyToClipboard('cmdforge install --collection {{ collection.name }}')"
class="ml-4 p-2 text-gray-400 hover:text-white transition-colors"
aria-label="Copy install command">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
<p class="mt-2 text-sm text-gray-400">
Installs {{ collection.tools|length if collection.tools else 0 }} tool{{ 's' if collection.tools|length != 1 else '' }} with a single command
</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Tools Section -->
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900">
Tools in this Collection
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ collection.tools|length if collection.tools else 0 }} tool{{ 's' if collection.tools|length != 1 else '' }} included
</p>
</div>
{% if collection.tools %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for tool in collection.tools %}
{% if tool.owner and tool.name %}
{{ tool_card(tool) }}
{% else %}
<!-- Tool not found in registry -->
<article class="bg-white rounded-lg border border-gray-200 border-dashed p-4 opacity-60">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-500">{{ tool }}</h3>
<p class="text-sm text-gray-400">Tool not yet in registry</p>
</div>
</div>
</article>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="text-center py-12 bg-white rounded-lg border border-gray-200">
<svg class="mx-auto w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No tools in this collection</h3>
<p class="mt-2 text-gray-600">
This collection doesn't have any tools yet.
</p>
</div>
{% endif %}
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Could add a toast notification here
});
}
</script>
{% endblock %}
@@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Page Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900">Collections</h1>
<p class="mt-2 text-gray-600">
Curated groups of tools that work great together. Install an entire collection with a single command.
</p>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% if collections %}
<!-- Results Header -->
<div class="flex items-center justify-between mb-6">
<p class="text-sm text-gray-600">
{{ collections|length }} collection{{ 's' if collections|length != 1 else '' }} available
</p>
</div>
<!-- Collections Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for collection in collections %}
{{ collection_card(collection) }}
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No collections yet</h3>
<p class="mt-2 text-gray-600">
Collections are coming soon! They'll let you install groups of related tools together.
</p>
<a href="{{ url_for('web.tools') }}"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Browse Individual Tools
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -131,6 +131,29 @@
</div>
{% endfor %}
</div>
<!-- Collections Callout -->
<div class="mt-12 p-6 bg-purple-50 rounded-lg border border-purple-100">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mr-4">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">Tool Collections</h3>
<p class="text-sm text-gray-600">Install curated groups of tools with a single command</p>
</div>
</div>
<a href="{{ url_for('web.collections') }}" class="text-purple-600 hover:text-purple-800 font-medium flex items-center">
Browse Collections
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</div>
</section>
@@ -65,6 +65,25 @@
{% endfor %}
</div>
{% endif %}
{% if tool.source %}
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-amber-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm text-amber-800">
Based on
{% if tool.source_url %}
<a href="{{ tool.source_url }}" target="_blank" rel="noopener" class="font-medium underline hover:text-amber-900">{{ tool.source }}</a>
{% else %}
<span class="font-medium">{{ tool.source }}</span>
{% endif %}
pattern
</span>
</div>
</div>
{% endif %}
</div>
<!-- Deprecation Warning -->