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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-03 03:05:23 -04:00
parent 497fe87fc5
commit 2c139b3982
20 changed files with 1299 additions and 11 deletions

View File

@ -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 - **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models
- **No Lock-in** - Your tools are YAML files, your prompts are yours - **No Lock-in** - Your tools are YAML files, your prompts are yours
- **Multi-step Pipelines** - Chain AI prompts with Python code for validation - **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 - **12+ Providers Profiled** - We tested them so you don't have to
## Try It Now (60 seconds) ## Try It Now (60 seconds)
@ -129,6 +130,7 @@ cmdforge edit mytool # Edit in $EDITOR
cmdforge delete mytool # Delete tool cmdforge delete mytool # Delete tool
cmdforge run mytool # Run a tool cmdforge run mytool # Run a tool
cmdforge test mytool # Test with mock provider cmdforge test mytool # Test with mock provider
cmdforge check mytool # Check dependencies (meta-tools)
cmdforge refresh # Update executable wrappers cmdforge refresh # Update executable wrappers
``` ```
@ -340,13 +342,44 @@ steps:
output: "{validated}" 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 ### Variables
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `{input}` | The piped/file input | | `{input}` | The piped/file input |
| `{argname}` | Custom argument value | | `{argname}` | Custom argument value |
| `{step_output}` | Output from previous step | | `{output_var}` | Output from previous step (prompt, code, or tool) |
## Shell Integration ## Shell Integration
@ -563,6 +596,7 @@ cmdforge providers test claude
### Developer Documentation ### Developer Documentation
- [Project Overview](docs/PROJECT.md) - Start here to understand the codebase - [Project Overview](docs/PROJECT.md) - Start here to understand the codebase
- [Design Document](docs/DESIGN.md) - [Design Document](docs/DESIGN.md)
- [Meta-Tools Design](docs/META_TOOLS.md) - Tools that call other tools
- [Registry API Design](docs/REGISTRY.md) - [Registry API Design](docs/REGISTRY.md)
- [Web UI Design](docs/WEB_UI.md) - [Web UI Design](docs/WEB_UI.md)
- [Deployment Guide](docs/DEPLOYMENT.md) - [Deployment Guide](docs/DEPLOYMENT.md)

116
docs/COLLECTIONS.md Normal file
View File

@ -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.

View File

@ -46,6 +46,11 @@ steps:
provider: claude provider: claude
output_var: response output_var: response
output: "{response}" output: "{response}"
# Optional: declare dependencies for meta-tools
dependencies:
- official/summarize
- official/translate@^1.0.0 # With version constraint
``` ```
### Step Types ### Step Types
@ -68,6 +73,17 @@ output: "{response}"
code_file: processed.py # Optional: external file storage 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. Steps execute in order. Each step's `output_var` becomes available to subsequent steps.
### Variables ### Variables
@ -168,6 +184,7 @@ cmdforge delete sum # Delete tool
cmdforge test sum # Test with mock provider cmdforge test sum # Test with mock provider
cmdforge run sum # Run tool for real cmdforge run sum # Run tool for real
cmdforge refresh # Refresh all wrapper scripts cmdforge refresh # Refresh all wrapper scripts
cmdforge check sum # Check dependencies for meta-tools
cmdforge ui # Launch interactive UI cmdforge ui # Launch interactive UI
``` ```

249
docs/META_TOOLS.md Normal file
View File

@ -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/<name>/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

View File

@ -36,6 +36,8 @@ Core API endpoints:
- `GET /api/v1/tools/{owner}/{name}/download?version=...` - `GET /api/v1/tools/{owner}/{name}/download?version=...`
- `POST /api/v1/tools` (publish) - `POST /api/v1/tools` (publish)
- `GET /api/v1/categories` - `GET /api/v1/categories`
- `GET /api/v1/collections`
- `GET /api/v1/collections/{name}`
- `GET /api/v1/stats/popular` - `GET /api/v1/stats/popular`
- `POST /api/v1/webhook/gitea` - `POST /api/v1/webhook/gitea`
@ -239,6 +241,124 @@ registry:
downloads: 142 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: **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) 1. Update the YAML parser to ignore unknown keys (permissive mode)
2. Or explicitly define these fields in the Tool dataclass with defaults 2. Or explicitly define these fields in the Tool dataclass with defaults
@ -1151,6 +1271,11 @@ CmdForge-Registry/
├── categories/ ├── categories/
│ └── categories.yaml # Category definitions │ └── 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 ├── index.json # Auto-generated search index
├── .gitea/ ├── .gitea/
@ -1736,6 +1861,8 @@ cmdforge.brrd.tech (or gitea.brrd.tech/registry)
├── /tools/{owner}/{name} # Tool detail page ├── /tools/{owner}/{name} # Tool detail page
├── /categories # Browse by category ├── /categories # Browse by category
├── /categories/{name} # Tools in category ├── /categories/{name} # Tools in category
├── /collections # Browse curated collections
├── /collections/{name} # Collection detail page
├── /search?q=... # Search results ├── /search?q=... # Search results
├── /docs # Documentation ├── /docs # Documentation
│ ├── /docs/getting-started │ ├── /docs/getting-started

View File

@ -7,7 +7,7 @@ from .. import __version__
from .tool_commands import ( from .tool_commands import (
cmd_list, cmd_create, cmd_edit, cmd_delete, cmd_test, cmd_run, 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 .provider_commands import cmd_providers
from .registry_commands import cmd_registry 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.add_argument("-e", "--edit", action="store_true", help="Edit/create README in $EDITOR")
p_docs.set_defaults(func=cmd_docs) 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 # 'providers' command
p_providers = subparsers.add_parser("providers", help="Manage AI providers") p_providers = subparsers.add_parser("providers", help="Manage AI providers")
providers_sub = p_providers.add_subparsers(dest="providers_cmd", help="Provider commands") providers_sub = p_providers.add_subparsers(dest="providers_cmd", help="Provider commands")

View File

@ -5,7 +5,7 @@ from pathlib import Path
from ..tool import ( from ..tool import (
list_tools, load_tool, save_tool, delete_tool, get_tools_dir, 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 from ..ui import run_ui
@ -39,6 +39,8 @@ def cmd_list(args):
step_info.append(f"PROMPT[{step.provider}]") step_info.append(f"PROMPT[{step.provider}]")
elif isinstance(step, CodeStep): elif isinstance(step, CodeStep):
step_info.append("CODE") step_info.append("CODE")
elif isinstance(step, ToolStep):
step_info.append(f"TOOL[{step.tool}]")
print(f" Steps: {' -> '.join(step_info)}") print(f" Steps: {' -> '.join(step_info)}")
print() print()
@ -331,3 +333,55 @@ echo "input" | {args.name}
print(readme_path.read_text()) print(readme_path.read_text())
return 0 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

View File

@ -710,6 +710,96 @@ def create_app() -> Flask:
response.headers["Cache-Control"] = "max-age=3600" response.headers["Cache-Control"] = "max-age=3600"
return response 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"]) @app.route("/api/v1/stats/popular", methods=["GET"])
def popular_tools() -> Response: def popular_tools() -> Response:
limit = min(int(request.args.get("limit", 10)), 50) limit = min(int(request.args.get("limit", 10)), 50)
@ -1010,6 +1100,8 @@ def create_app() -> Flask:
description = (data.get("description") or "").strip() description = (data.get("description") or "").strip()
category = (data.get("category") or "").strip() or None category = (data.get("category") or "").strip() or None
tags = data.get("tags") or [] 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: 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") return error_response("VALIDATION_ERROR", "Invalid tool name")
@ -1126,8 +1218,8 @@ def create_app() -> Flask:
INSERT INTO tools ( INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme, owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads, publisher_id, deprecated, deprecated_message, replacement, downloads,
scrutiny_status, scrutiny_report, published_at scrutiny_status, scrutiny_report, source, source_url, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
[ [
owner, owner,
@ -1145,6 +1237,8 @@ def create_app() -> Flask:
0, 0,
scrutiny_status, scrutiny_status,
scrutiny_json, scrutiny_json,
source,
source_url,
datetime.utcnow().isoformat(), datetime.utcnow().isoformat(),
], ],
) )

View File

@ -51,6 +51,8 @@ CREATE TABLE IF NOT EXISTS tools (
downloads INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0,
scrutiny_status TEXT DEFAULT 'pending', scrutiny_status TEXT DEFAULT 'pending',
scrutiny_report TEXT, scrutiny_report TEXT,
source TEXT,
source_url TEXT,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version) 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_path ON pageviews(path, viewed_at DESC);
CREATE INDEX IF NOT EXISTS idx_pageviews_date ON pageviews(date(viewed_at), path); 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);
""" """

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") 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]: def sync_from_repo() -> Tuple[bool, str]:
repo_dir = get_repo_dir() repo_dir = get_repo_dir()
clone_or_update_repo(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) upsert_tool(conn, owner, name, data, config_text, readme_text)
except Exception: except Exception:
continue continue
sync_collections(conn, repo_dir)
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()

View File

@ -5,11 +5,63 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Optional 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 .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec from .resolver import resolve_tool, ToolNotFoundError, ToolSpec
from .manifest import load_manifest 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: 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 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( def run_tool(
tool: Tool, tool: Tool,
input_text: str, input_text: str,
@ -118,7 +239,8 @@ def run_tool(
provider_override: Optional[str] = None, provider_override: Optional[str] = None,
dry_run: bool = False, dry_run: bool = False,
show_prompt: 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]: ) -> tuple[str, int]:
""" """
Execute a tool. Execute a tool.
@ -131,10 +253,19 @@ def run_tool(
dry_run: Just show what would happen dry_run: Just show what would happen
show_prompt: Show prompts in addition to output show_prompt: Show prompts in addition to output
verbose: Show debug info verbose: Show debug info
_depth: Internal recursion depth tracker
Returns: Returns:
Tuple of (output_text, exit_code) 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 # Initialize variables with input and arguments
variables = {"input": input_text} variables = {"input": input_text}
@ -156,7 +287,14 @@ def run_tool(
# Execute each step # Execute each step
for i, step in enumerate(tool.steps): for i, step in enumerate(tool.steps):
if verbose: 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) print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
if isinstance(step, PromptStep): if isinstance(step, PromptStep):
@ -192,6 +330,25 @@ def run_tool(
# Merge all output vars into variables # Merge all output vars into variables
variables.update(outputs) 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 # Generate final output
output = substitute_variables(tool.output, variables) output = substitute_variables(tool.output, variables)

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 # Default categories for organizing tools
@ -113,6 +147,7 @@ class Tool:
arguments: List[ToolArgument] = field(default_factory=list) arguments: List[ToolArgument] = field(default_factory=list)
steps: List[Step] = field(default_factory=list) steps: List[Step] = field(default_factory=list)
output: str = "{input}" # Output template output: str = "{input}" # Output template
dependencies: List[str] = field(default_factory=list) # Required tools for meta-tools
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Tool": def from_dict(cls, data: dict) -> "Tool":
@ -126,6 +161,8 @@ class Tool:
steps.append(PromptStep.from_dict(step)) steps.append(PromptStep.from_dict(step))
elif step.get("type") == "code": elif step.get("type") == "code":
steps.append(CodeStep.from_dict(step)) steps.append(CodeStep.from_dict(step))
elif step.get("type") == "tool":
steps.append(ToolStep.from_dict(step))
return cls( return cls(
name=data["name"], name=data["name"],
@ -133,7 +170,8 @@ class Tool:
category=data.get("category", "Other"), category=data.get("category", "Other"),
arguments=arguments, arguments=arguments,
steps=steps, steps=steps,
output=data.get("output", "{input}") output=data.get("output", "{input}"),
dependencies=data.get("dependencies", [])
) )
def to_dict(self) -> dict: def to_dict(self) -> dict:
@ -144,6 +182,8 @@ class Tool:
# Only include category if it's not the default # Only include category if it's not the default
if self.category and self.category != "Other": if self.category and self.category != "Other":
d["category"] = self.category d["category"] = self.category
if self.dependencies:
d["dependencies"] = self.dependencies
d["arguments"] = [arg.to_dict() for arg in self.arguments] d["arguments"] = [arg.to_dict() for arg in self.arguments]
d["steps"] = [step.to_dict() for step in self.steps] d["steps"] = [step.to_dict() for step in self.steps]
d["output"] = self.output d["output"] = self.output

View File

@ -644,6 +644,7 @@ def sitemap():
static_pages = [ static_pages = [
("/", "1.0", "daily"), ("/", "1.0", "daily"),
("/tools", "0.9", "daily"), ("/tools", "0.9", "daily"),
("/collections", "0.8", "weekly"),
("/docs", "0.8", "weekly"), ("/docs", "0.8", "weekly"),
("/docs/getting-started", "0.8", "weekly"), ("/docs/getting-started", "0.8", "weekly"),
("/docs/installation", "0.7", "weekly"), ("/docs/installation", "0.7", "weekly"),
@ -744,3 +745,35 @@ def dashboard_undeprecate_tool(owner: str, name: str):
data = request.get_json() or {} data = request.get_json() or {}
status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/undeprecate", data=data, token=token) status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/undeprecate", data=data, token=token)
return jsonify(payload), status 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

View File

@ -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 %}

View File

@ -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 %}"> 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 Registry
</a> </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') }}" <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 %}"> 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 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 %}"> 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 Registry
</a> </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') }}" <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 %}"> 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 Forum

View File

@ -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 %}

View File

@ -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 %}

View File

@ -131,6 +131,29 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </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> </div>
</section> </section>

View File

@ -65,6 +65,25 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% 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> </div>
<!-- Deprecation Warning --> <!-- Deprecation Warning -->