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:
parent
497fe87fc5
commit
2c139b3982
36
README.md
36
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
|
- **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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
127
docs/REGISTRY.md
127
docs/REGISTRY.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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 %}">
|
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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue